商品分类完成以后,自然轮到了品牌功能了。
先看看我们要实现的效果:
接下来,我们从0开始,实现下从前端到后端的完整开发。
为了方便看到效果,我们新建一个MyBrand.vue(注意先停掉服务器),从0开始搭建。
内容初始化一下:
<template>
<span>
hello
</span>
</template>
<script>
export default {
name: "myBrand"
}
</script>
<!-- scoped:当前样式只作用于当前组件的节点 -->
<style scoped>
</style>
改变router新的index.js,将路由地址指向MyBrand.vue
打开服务器,再次查看页面:
干干净净了。只剩hello
大家看到这个原型页面肯定能看出,其主体就是一个table。我们去Vuetify查看有关table的文档:
仔细阅读,发现v-data-table
中有以下核心属性:
我们向下翻,找找有没有看起来牛逼的案例。
找到这样一条:
其它的案例都是由Vuetify帮我们对查询到的当前页数据进行排序和分页,这显然不是我们想要的。我们希望能在服务端完成对整体品牌数据的排序和分页,而这个案例恰好合适。
点击按钮,我们直接查看源码,然后直接复制到MyBrand.vue中
模板:
<template>
<div>
<v-data-table
:headers="headers"
:items="desserts"
:pagination.sync="pagination"
:total-items="totalDesserts"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td>{{ props.item.name }}</td>
<td class="text-xs-right">{{ props.item.calories }}</td>
<td class="text-xs-right">{{ props.item.fat }}</td>
<td class="text-xs-right">{{ props.item.carbs }}</td>
<td class="text-xs-right">{{ props.item.protein }}</td>
<td class="text-xs-right">{{ props.item.iron }}</td>
</template>
</v-data-table>
</div>
</template>
接下来,就分析一下案例中每一部分是什么意思,搞清楚了,我们也可以自己玩了。
先看模板中table上的一些属性:
<v-data-table
:headers="headers"
:items="desserts"
:pagination.sync="pagination"
:total-items="totalDesserts"
:loading="loading"
class="elevation-1"
>
</v-data-table>
另外,在v-data-tables
中,我们还看到另一段代码:
<template slot="items" slot-scope="props">
<td>{{ props.item.name }}</td>
<td class="text-xs-right">{{ props.item.calories }}</td>
<td class="text-xs-right">{{ props.item.fat }}</td>
<td class="text-xs-right">{{ props.item.carbs }}</td>
<td class="text-xs-right">{{ props.item.protein }}</td>
<td class="text-xs-right">{{ props.item.iron }}</td>
</template>
这段就是在渲染每一行的数据。Vue会自动遍历上面传递的items
属性,并把得到的对象传递给这段template
中的props.item
属性。我们从中得到数据,渲染在页面即可。
我们需要做的事情,主要有两件:
表格中具体有哪些列呢?参照品牌表:
品牌中有id,name,image,letter字段。
<template>
<div>
<v-data-table
:headers="headers"
:items="brands"
:pagination.sync="pagination"
:total-items="totalBrands"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img v-if="props.item.image" :src="props.item.image" width="130" height="40"/></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
</template>
</v-data-table>
</div>
</template>
我们修改了以下部分:
img
标签的src属性中,并且做了非空判断接下来编写要用到的数据:
data () {
return {
totalBrands: 0, // 总条数
brands: [], // 当前页品牌数据
loading: true, // 是否在加载中
pagination: {}, // 分页信息
headers: [ // 头信息
{text: 'id', align: 'center', value: 'id'},
{text: '名称', align: 'center', value: 'name', sortable: false},
{text: 'LOGO', align: 'center', value: 'image', sortable: false},
{text: '首字母', align: 'center', value: 'letter'},
]
}
}
接下来就是对brands和totalBrands完成赋值动作了。
我们编写一个函数来完成赋值,提高复用性:
methods: {
getDataFromServer(){ // 从服务端加载数据的函数
// 伪造演示数据
const brands = [
{
"id": 2032,
"name": "OPPO",
"image": "http://img10.360buyimg.com/popshop/jfs/t2119/133/2264148064/4303/b8ab3755/56b2f385N8e4eb051.jpg",
"letter": "O",
"categories": null
},
{
"id": 2033,
"name": "飞利浦(PHILIPS)",
"image": "http://img12.360buyimg.com/popshop/jfs/t18361/122/1318410299/1870/36fe70c9/5ac43a4dNa44a0ce0.jpg",
"letter": "F",
"categories": null
},
{
"id": 2034,
"name": "华为(HUAWEI)",
"image": "http://img10.360buyimg.com/popshop/jfs/t5662/36/8888655583/7806/1c629c01/598033b4Nd6055897.jpg",
"letter": "H",
"categories": null
},
{
"id": 2036,
"name": "酷派(Coolpad)",
"image": "http://img10.360buyimg.com/popshop/jfs/t2521/347/883897149/3732/91c917ec/5670cf96Ncffa2ae6.jpg",
"letter": "K",
"categories": null
},
{
"id": 2037,
"name": "魅族(MEIZU)",
"image": "http://img13.360buyimg.com/popshop/jfs/t3511/131/31887105/4943/48f83fa9/57fdf4b8N6e95624d.jpg",
"letter": "M",
"categories": null
}
];
// 延迟一段时间,模拟数据请求时间
setTimeout(()=>{
this.brands = brands; // 赋值给品牌数组
this.totalBrands = brands.length; // 赋值数据总条数
this.loading = false; // 数据加载完成
}, 1000);
}
}
然后使用钩子函数,在Vue实例初始化完毕后调用这个方法,这里使用mounted(渲染后)函数:
// 渲染后执行
mounted(){
this.getDataFromServer() // 调用数据初始化函数
}
<template>
<div>
<v-data-table
:headers="headers"
:items="brands"
:pagination.sync="pagination"
:total-items="totalBrands"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img v-if="props.item.image" :src="props.item.image" width="130" height="40"/></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
</template>
</v-data-table>
</div>
</template>
<script>
export default {
name: "myBrand",
data () {
return {
totalBrands: 0, // 总条数
brands: [], // 当前页品牌数据
loading: true, // 是否在加载中
pagination: {}, // 分页信息
headers: [ // 头信息
{text: 'id', align: 'center', value: 'id'},
{text: '名称', align: 'center', value: 'name', sortable: false},
{text: 'LOGO', align: 'center', value: 'image', sortable: false},
{text: '首字母', align: 'center', value: 'letter'},
]
}
},
methods: {
getDataFromServer(){ // 从服务端加载数据的函数
// 伪造演示数据
const brands = [
{
"id": 2032,
"name": "OPPO",
"image": "http://img10.360buyimg.com/popshop/jfs/t2119/133/2264148064/4303/b8ab3755/56b2f385N8e4eb051.jpg",
"letter": "O",
"categories": null
},
{
"id": 2033,
"name": "飞利浦(PHILIPS)",
"image": "http://img12.360buyimg.com/popshop/jfs/t18361/122/1318410299/1870/36fe70c9/5ac43a4dNa44a0ce0.jpg",
"letter": "F",
"categories": null
},
{
"id": 2034,
"name": "华为(HUAWEI)",
"image": "http://img10.360buyimg.com/popshop/jfs/t5662/36/8888655583/7806/1c629c01/598033b4Nd6055897.jpg",
"letter": "H",
"categories": null
},
{
"id": 2036,
"name": "酷派(Coolpad)",
"image": "http://img10.360buyimg.com/popshop/jfs/t2521/347/883897149/3732/91c917ec/5670cf96Ncffa2ae6.jpg",
"letter": "K",
"categories": null
},
{
"id": 2037,
"name": "魅族(MEIZU)",
"image": "http://img13.360buyimg.com/popshop/jfs/t3511/131/31887105/4943/48f83fa9/57fdf4b8N6e95624d.jpg",
"letter": "M",
"categories": null
}
];
// 延迟一段时间,模拟数据请求时间
setTimeout(()=>{
this.brands = brands; // 赋值给品牌数组
this.totalBrands = brands.length; // 赋值数据总条数
this.loading = false; // 数据加载完成
}, 1000);
}
},
// 渲染后执行
mounted(){
this.getDataFromServer() // 调用数据初始化函数
}
}
</script>
<!-- scoped:当前样式只作用于当前组件的节点 -->
<style scoped>
</style>
我们将来要对品牌进行增删改,需要给每一行数据添加 修改删除的按钮,一般放到改行的最后一列。
其实就是多了一列,只是这一列没有数据,而是两个按钮而已。可以在官方文档中找一个带有操作按钮的表格,作为参考。
我们先在头(headers)中添加一列:
headers: [ // 头信息
{text: 'id', align: 'center', value: 'id'},
{text: '名称', align: 'center', value: 'name', sortable: false},
{text: 'LOGO', align: 'center', value: 'image', sortable: false},
{text: '首字母', align: 'center', value: 'letter'},
{text: '操作', align: 'center', value: 'id', sortable: false }
]
然后在模板中添加按钮:
<template slot="items" slot-scope="props">
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img v-if="props.item.image" :src="props.item.image" width="130" height="40"/></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="text-xs-center">
<v-icon small class="mr-2" @click="editItem(props.item)">
edit
</v-icon>
<v-icon small @click="deleteItem(props.item)">
delete
</v-icon>
</td>
</template>
效果:
在官方文档中找到按钮的用法:
因为新增跟某个品牌无关,是独立的,因此我们可以放到表格的外面。
效果:
为了不让按钮显得过于孤立,我们可以将按新增按钮
和表格
放到一张卡片(card)中。
我们去官网查看卡片的用法:
卡片v-card
包含四个基本组件:
我们可以把新增的按钮
放到v-card-title
位置,把table
放到下面,这样就成一个上下关系。
<template>
<v-card>
<v-card-title flat color="white">
<v-btn color="primary">新增</v-btn>
</v-card-title>
<v-data-table
:headers="headers"
:items="brands"
:pagination.sync="pagination"
:total-items="totalBrands"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img v-if="props.item.image" :src="props.item.image" width="130" height="40"/></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="text-xs-center">
<v-icon small class="mr-2" @click="editItem(props.item)">
edit
</v-icon>
<v-icon small @click="deleteItem(props.item)">
delete
</v-icon>
</td>
</template>
</v-data-table>
</v-card>
</template>
效果:
我们还可以在卡片头部添加一个搜索框,其实就是一个文本输入框。
查看官网中,文本框的用法:
修改模板,添加输入框:
<v-card-title>
<v-btn color="primary">新增品牌</v-btn>
<!--搜索框,与search属性关联-->
<v-text-field label="输入关键字搜索" v-model="search"/>
</v-card-title>
注意:要在数据模型中,添加search字段:
data() {
return {
totalBrands: 0, // 总条数
brands: [], // 当前页品牌数据
search: "", // 查询关键字
loading: true, // 是否在加载中
pagination: {}, // 分页信息
headers: [ // 头信息
{text: 'id', align: 'center', value: 'id'},
{text: '名称', align: 'center', value: 'name', sortable: false},
{text: 'LOGO', align: 'center', value: 'image', sortable: false},
{text: '首字母', align: 'center', value: 'letter'},
{text: '操作', align: 'center', value: 'id', sortable: false}
]
}
}
效果:
发现输入框超级长!!!
这个时候,我们可以使用Vuetify提供的一个空间隔离工具:
修改代码:
<v-card-title>
<v-btn color="primary">新增品牌</v-btn>
<!--空间隔离组件-->
<v-spacer />
<!--搜索框,与search属性关联-->
<v-text-field label="输入关键字搜索" v-model="search"/>
</v-card-title>
查看textfiled的文档,发现:
通过append-icon属性可以为 输入框添加后置图标,所有可用图标名称可以到 material-icons官网去查看。
修改我们的代码:
<v-text-field label="输入关键字搜索" v-model="search" append-icon="search"/>
搜索框看起来高度比较高,页面不够紧凑。这其实是因为默认在文本框下面预留有错误提示空间。通过下面的属性可以取消提示:
修改代码:
<v-text-field label="输入关键字搜索" v-model="search" append-icon="search" hide-details/>
效果:
几乎已经达到了原来一样的效果了吧!
前台页面已经准备好,接下来就是后台提供数据接口了。
CREATE TABLE `tb_brand` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
`name` varchar(50) NOT NULL COMMENT '品牌名称',
`image` varchar(200) DEFAULT '' COMMENT '品牌图片地址',
`letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325400 DEFAULT CHARSET=utf8 COMMENT='品牌表,一个品牌下有多个商品(spu),一对多关系';
简单的四个字段,不多解释。
这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:
CREATE TABLE `tb_category_brand` (
`category_id` bigint(20) NOT NULL COMMENT '商品类目id',
`brand_id` bigint(20) NOT NULL COMMENT '品牌id',
PRIMARY KEY (`category_id`,`brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品分类和品牌的中间表,两者是多对多关系';
但是,你可能会发现,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?
在电商行业,性能是非常重要的。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。
@Table(name = "tb_brand")
public class Brand {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;// 品牌名称
private String image;// 品牌图片
private Character letter;
// getter setter 略
}
通用mapper来简化开发:
public interface BrandMapper extends Mapper<Brand> {
}
编写controller先思考四个问题,这次没有前端代码,需要我们自己来设定
这里我们封装一个类,来表示分页结果:
public class PageResult<T> {
private Long total;// 总条数
private Long totalPage;// 总页数
private List<T> items;// 当前页数据
public PageResult() {
}
public PageResult(Long total, List<T> items) {
this.total = total;
this.items = items;
}
public PageResult(Long total, Long totalPage, List<T> items) {
this.total = total;
this.totalPage = totalPage;
this.items = items;
}
public Long getTotal() {
return total;
}
public void setTotal(Long total) {
this.total = total;
}
public List<T> getItems() {
return items;
}
public void setItems(List<T> items) {
this.items = items;
}
public Long getTotalPage() {
return totalPage;
}
public void setTotalPage(Long totalPage) {
this.totalPage = totalPage;
}
}
另外,这个PageResult以后可能在其它项目中也有需求,因此我们将其抽取到leyou-common
中,提高复用性:
不要忘记在leyou-item-service工程的pom.xml中引入leyou-common的依赖:
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
接下来,我们编写Controller
@RestController
@RequestMapping("brand")
public class BrandController {
@Autowired
private BrandService brandService;
@GetMapping("page")
public ResponseEntity<PageResult<Brand>> queryBrandByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "sortBy", required = false) String sortBy,
@RequestParam(value = "desc", defaultValue = "false") Boolean desc,
@RequestParam(value = "key", required = false) String key) {
PageResult<Brand> result = this.brandService.queryBrandByPageAndSort(page,rows,sortBy,desc, key);
if (result == null || result.getItems().size() == 0) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(result);
}
}
@Service
public class BrandService {
@Autowired
private BrandMapper brandMapper;
public PageResult<Brand> queryBrandByPageAndSort(
Integer page, Integer rows, String sortBy, Boolean desc, String key) {
// 开始分页
PageHelper.startPage(page, rows);
// 过滤
Example example = new Example(Brand.class);
if (StringUtils.isNotBlank(key)) {
example.createCriteria().andLike("name", "%" + key + "%")
.orEqualTo("letter", key);
}
if (StringUtils.isNotBlank(sortBy)) {
// 排序
String orderByClause = sortBy + (desc ? " DESC" : " ASC");
example.setOrderByClause(orderByClause);
}
// 查询
Page<Brand> pageInfo = (Page<Brand>) brandMapper.selectByExample(example);
// 返回结果
return new PageResult<>(pageInfo.getTotal(), pageInfo);
}
}
通过浏览器访问试试:http://api.leyou.com/api/item/brand/page
接下来,去页面请求数据并渲染
异步查询数据,自然是通过ajax查询,大家首先想起的肯定是jQuery。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。
Vue官方推荐的ajax请求框架叫做:axios,看下demo:
axios的Get请求语法:
axios.get("/item/category/list?pid=0") // 请求路径和请求参数拼接
.then(function(resp){
// 成功回调函数
})
.catch(function(){
// 失败回调函数
})
// 参数较多时,可以通过params来传递参数
axios.get("/item/category/list", {
params:{
pid:0
}
})
.then(function(resp){})// 成功时的回调
.catch(function(error){})// 失败时的回调
axios的POST请求语法:
比如新增一个用户
axios.post("/user",{
name:"Jack",
age:21
})
.then(function(resp){})
.catch(function(error){})
PUT和DELETE请求与POST请求类似
而在我们的项目中,已经引入了axios,并且进行了简单的封装,在src下的http.js中:
http.js中对axios进行了一些默认配置:
import Vue from 'vue'
import axios from 'axios'
import config from './config'
// config中定义的基础路径是:http://api.leyou.com/api
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间
Vue.prototype.$http = axios;// 将axios赋值给Vue原型的$http属性,这样所有vue实例都可使用该对象
baseURL=config.api
,即http://api.leyou.com/api
。因此以后所有用axios发起的请求,都会以这个地址作为前缀。
Vue.property.$http = axios
,将axios
赋值给了 Vue原型中的$http
。这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios了。
我们在组件MyBrand.vue
的getDataFromServer方法,通过$http发起get请求,测试查询品牌的接口,看是否能获取到数据:
网络监视:
resp到底都有那些数据,查看控制台结果:
可以看到,在请求成功的返回结果response中,有一个data属性,里面就是真正的响应数据。
响应结果中与我们设计的一致,包含3个内容:
虽然已经通过ajax请求获取了品牌数据,但是刚才的请求没有携带任何参数,这样显然不对。我们后端接口需要5个参数:
而页面中分页信息应该是在pagination对象中,我们通过浏览器工具,查看pagination中有哪些属性:
分别是:
缺少一个搜索关键词,这个应该是通过v-model与输入框绑定的属性:search。这样,所有参数就都有了。
另外,不要忘了把查询的结果赋值给brands和totalBrands属性,Vuetify会帮我们渲染页面。
接下来,我们完善请求参数:
methods: {
getDataFromServer() { // 从服务端加载数据的函数
this.loading = true; // 加载数据
// 通过axios获取数据
this.$http.get("/item/brand/page", {
params: {
page: this.pagination.page, // 当前页
rows: this.pagination.rowsPerPage, // 每页条数
sortBy: this.pagination.sortBy, // 排序字段
desc: this.pagination.descending, // 是否降序
key: this.search // 查询字段
}
}).then(resp => { // 获取响应结果对象
this.totalBrands = resp.data.total; // 总条数
this.brands = resp.data.items; // 品牌数据
this.loading = false; // 加载完成
});
}
}
查看网络请求:
效果:
现在我们实现了页面加载时的第一次查询,你会发现你点击分页或搜索不会发起新的请求,怎么办?
虽然点击分页,不会发起请求,但是通过浏览器工具查看,会发现pagination对象的属性一直在变化:
我们可以利用Vue的监视功能:watch,当pagination发生改变时,会调用我们的回调函数,我们在回调函数中进行数据的查询即可!
具体实现:
成功实现分页功能:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yawG2RAA-1577945226046)(assets/22.gif)]
分页实现了,过滤也很好实现了。过滤字段对应的是search属性,我们只要监视这个属性即可:
页面结果:
<template>
<v-card>
<v-card-title flat color="white">
<v-btn color="primary">新增</v-btn>
<!--空间隔离组件-->
<v-spacer />
<!--搜索框,与search属性关联-->
<v-text-field label="输入关键字搜索" append-icon="search" v-model="search" hide-details/>
</v-card-title>
<v-data-table
:headers="headers"
:items="brands"
:pagination.sync="pagination"
:total-items="totalBrands"
:loading="loading"
class="elevation-1"
>
<template slot="items" slot-scope="props">
<td class="text-xs-center">{{ props.item.id }}</td>
<td class="text-xs-center">{{ props.item.name }}</td>
<td class="text-xs-center"><img v-if="props.item.image" :src="props.item.image" width="130" height="40"/></td>
<td class="text-xs-center">{{ props.item.letter }}</td>
<td class="text-xs-center">
<v-icon small class="mr-2" @click="editItem(props.item)">
edit
</v-icon>
<v-icon small @click="deleteItem(props.item)">
delete
</v-icon>
</td>
</template>
</v-data-table>
</v-card>
</template>
<script>
export default {
name: "myBrand",
data() {
return {
totalBrands: 0, // 总条数
brands: [], // 当前页品牌数据
search: "", // 查询关键字
loading: true, // 是否在加载中
pagination: {}, // 分页信息
headers: [ // 头信息
{text: 'id', align: 'center', value: 'id'},
{text: '名称', align: 'center', value: 'name', sortable: false},
{text: 'LOGO', align: 'center', value: 'image', sortable: false},
{text: '首字母', align: 'center', value: 'letter'},
{text: '操作', align: 'center', value: 'id', sortable: false}
]
}
},
watch: {
pagination:{
deep: true, // 深度监视
handler(){
this.getDataFromServer();
}
},
search(){
this.pagination.page = 1;
this.getDataFromServer();
}
},
methods: {
getDataFromServer() { // 从服务端加载数据的函数
this.loading = true; // 加载数据
// 通过axios获取数据
this.$http.get("/item/brand/page", {
params: {
page: this.pagination.page, // 当前页
rows: this.pagination.rowsPerPage, // 每页条数
sortBy: this.pagination.sortBy, // 排序字段
desc: this.pagination.descending, // 是否降序
key: this.search // 查询字段
}
}).then(resp => { // 获取响应结果对象
this.totalBrands = resp.data.total; // 总条数
this.brands = resp.data.items; // 品牌数据
this.loading = false; // 加载完成
});
}
},
// 渲染后执行
mounted() {
this.getDataFromServer() // 调用数据初始化函数
}
}
</script>
<!-- scoped:当前样式只作用于当前组件的节点 -->
<style scoped>
</style>