从无到有搭建一个电商项目(九):品牌管理

功能一:实现品牌查询功能

品牌查询这个功能,我们从0开始,实现下从前端到后端的完整开发

前端页面设计

新建一个MyBrand.vue(注意先停掉服务器),从0开始搭建
MyBrand.vue内容初始化:

<template>
  <span>
    hello
  </span>
</template>

<script>
  export default {
    name: "myBrand"
  }
</script>

<!-- scoped:当前样式只作用于当前组件的节点 -->
<style scoped>

</style>

改变index.js中品牌的路由,将路由地址指向MyBrand.vue

route("/item/brand",'/item/MyBrand',"MyBrand")

打开服务器,查看页面:只显示一个hello,说明初始化完毕

1)查询表格
查看原型页面,猜测其主体就是一个table。我们去Vuetify查看有关table的文档:
从无到有搭建一个电商项目(九):品牌管理
仔细阅读,发现v-data-table中有以下核心属性:

  • dark:是否使用黑暗色彩主题,默认是false
  • expand:表格的行是否可以展开,默认是false
  • headers:定义表头的数组,数组的每个元素就是一个表头信息对象,结构:
{
  text: string, // 表头的显示文本
  value: string, // 表头对应的每行数据的key
  align: 'left' | 'center' | 'right', // 位置
  sortable: boolean, // 是否可排序
  class: string[] | string,// 样式
  width: string,// 宽度
}
  • items:表格的数据的数组,数组的每个元素是一行数据的对象,对象的key要与表头的value一致
  • loading:是否显示加载数据的进度条,默认是false
  • no-data-text:当没有查询到数据时显示的提示信息,string类型,无默认值
  • pagination.sync:包含分页和排序信息的对象,将其与vue实例中的属性关联,表格的分页或排序按钮被触发时,会自动将最新的分页和排序信息更新。对象结构:
{
    page: 1, // 当前页
    rowsPerPage: 5, // 每页大小
    sortBy: '', // 排序字段
    descending:false, // 是否降序
}
  • total-items:分页的总条数信息,number类型,无默认值
  • select-all :是否显示每一行的复选框,Boolean类型,无默认值
  • value:当表格可选的时候,返回选中的行

我们继续翻阅文档,看看有没有现成的案例:我们希望能在服务端完成对整体品牌数据的排序和分页,而下面这个案例恰好合适
从无到有搭建一个电商项目(九):品牌管理
查看源码,然后直接复制到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>

2)表格分析
先看模板中table上的一些属性:

<v-data-table
              :headers="headers"
              :items="desserts"
              :pagination.sync="pagination"
              :total-items="totalDesserts"
              :loading="loading"
              class="elevation-1"
              >
</v-data-table>
  • headers:表头信息,是一个数组
  • items:要在表格中展示的数据,数组结构,每一个元素是一行。在这里应该是品牌集合
  • pagination.sync:分页信息,包含了当前页,每页大小,排序字段,排序方式等。加上.sync代表服务端排序,当用户点击分页条时,该对象的值会跟着变化。监控这个值,并在这个值变化时去服务端查询,即可实现页面数据动态加载了。
  • total-items:总条数,在这里是品牌的总记录数
  • loading:boolean类型,true:代表数据正在加载,会有进度条。false:数据加载完毕。

在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属性。我们从中得到数据,渲染在页面即可。

我们需要做的事情,主要有两件:

  • 给items和totalItems赋值
  • 当pagination变化时,重新获取数据,再次给items和totalItems赋值

3)实现
表格中具体有哪些列要参照品牌表: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>
  • items:指向一个brands变量,等下在js代码中定义
  • total-items:指向了totalBrands变量,等下在js代码中定义
  • template模板中,渲染了四个字段:
    • id:
    • name
    • image,注意,我们不是以文本渲染,而是赋值到一个img标签的src属性中,并且做了非空判断
    • letter

② js中编写数据模型

    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>

4)页面优化
① 编辑和删除按钮
将来要对品牌进行增删改,需要给每一行数据添加修改删除的按钮,在Vuetify官方文档中找一个带有操作按钮的表格,作为参考:
从无到有搭建一个电商项目(九):品牌管理
headers数组中添加一列:

{text: '操作', align: 'center', value: 'id', sortable: false }

然后在模板中添加按钮:

<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>

② 新增按钮
官方文档中找到按钮的用法:
从无到有搭建一个电商项目(九):品牌管理
新增跟某个品牌无关,是独立的,因此我们可以放到表格的外面。
从无到有搭建一个电商项目(九):品牌管理
③ 卡片(card)
为了不让按钮显得过于孤立,我们可以将按新增按钮和表格放到一张卡片(card)中。
官网查看卡片的用法:
从无到有搭建一个电商项目(九):品牌管理
卡片v-card包含四个基本组件:

  • v-card-media:一般放图片或视频
  • v-card-title:卡片的标题,一般位于卡片顶部
  • v-card-text:卡片的文本(主体内容),一般位于卡片正中
  • v-card-action:卡片的按钮,一般位于卡片底部

我们可以把新增的按钮放到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>

④ 添加搜索框
我们还可以在卡片头部添加一个搜索框,其实就是一个文本输入框。
查看官网中,文本框的用法:
从无到有搭建一个电商项目(九):品牌管理

  • name:字段名,表单中会用到
  • label/placeholder:提示文字
  • value:值。可以用v-model代替,实现双向绑定

修改模板,添加输入框:

<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属性可以为 输入框添加后置图标

<v-text-field label="输入关键字搜索" v-model="search" append-icon="search"/>

⑥ 把文本框变紧凑
搜索框看起来高度比较高,页面不够紧凑。这其实是因为默认在文本框下面预留有错误提示空间。通过下面的属性可以取消提示:
从无到有搭建一个电商项目(九):品牌管理

<v-text-field label="输入关键字搜索" v-model="search" append-icon="search" hide-details/>

页面最终效果图:
从无到有搭建一个电商项目(九):品牌管理

编写后台查询接口

1)数据库表
品牌表:

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='商品分类和品牌的中间表,两者是多对多关系';

观察发现,这张表中没有设置外键约束,这与数据库的设计范式不符,为什么这么做?

  • 外键会严重影响数据库读写的效率
  • 数据删除时会比较麻烦

电商行业,性能非常重要。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。

2)创建实体类
venom-item-interface包中创建Brand实体类

@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 略
}

3)编写mapper
通用mapper来简化开发:

public interface BrandMapper extends Mapper<Brand> {
}

4)编写表现层Controller
思考四个问题,这次没有前端代码,需要我们自己来设定:

  • 请求方式:查询,肯定是Get
  • 请求路径:分页查询,/brand/page
  • 请求参数:根据编写的页面,有分页功能,有排序功能,有搜索过滤功能,因此至少要有5个参数:
    • page:当前页,int
    • rows:每页大小,int
    • sortBy:排序字段,String
    • desc:是否为降序,boolean
    • key:搜索关键词,String
  • 响应结果:分页结果一般至少需要两个数据
    • total:总条数
    • items:当前页数据
    • totalPage:有些还需要总页数

我们封装一个类,表示分页结果:PageResult以后可能在其它项目中也有需求,因此我们将其抽取到venom-common模块中,提高复用性
从无到有搭建一个电商项目(九):品牌管理

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;
    }
    // getter setter 略
}

在venom-item-service工程的pom.xml中引入leyou-common的依赖:

        <dependency>
            <groupId>com.venom.common</groupId>
            <artifactId>venom-common</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>

controller

@RestController
@RequestMapping("brand")
public class BrandController {

    @Autowired
    private BrandService brandService;

    @RequestMapping("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> brandPageResult = this.brandService.queryBrandByPage(page,rows,sortBy,desc,key);
        if(brandPageResult == null || brandPageResult.getItems().size() == 0){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(brandPageResult);
    }
}

5)编写业务逻辑层Service

@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);
    }
}

通过Postman测试:http://api.venom.com/api/item/brand/page
从无到有搭建一个电商项目(九):品牌管理
到此,后台接口编写完成!

异步查询工具axios

异步查询数据,通过ajax查询,首先想起的肯定是jQuery,但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。
1)axios入门
Vue官方推荐的ajax请求框架:axios
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){})

注意:POST请求传参,不需要像GET请求那样定义一个对象,在对象的params参数中传参。post()方法的第二个参数对象,就是将来要传递的参数;PUT和DELETE请求与POST请求类似

2)axios的全局配置
项目中,已经引入axios,并且进行了简单的封装,在src下的http.js中,http.js中对axios进行了一些默认配置:

import Vue from 'vue'
import axios from 'axios'
import config from './config'
// config中定义的基础路径是:http://api.venom.com/api
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间

Vue.prototype.$http = axios;// 将axios赋值给Vue原型的$http属性,这样所有vue实例都可使用该对象
  • http.js中导入了config的配置
  • http.js对axios进行了全局配置:baseURL=config.api,即http://api.venom.com/api。因此以后所有用axios发起的请求,都会以这个地址作为前缀。
  • 通过Vue.property.$ http = axios,将axios赋值给了 Vue原型中的$ http。这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios。

3)小测一把
我们在组件MyBrand.vue的getDataFromServer方法,通过$http发起get请求,测试查询品牌的接口,看是否能获取到数据:
从无到有搭建一个电商项目(九):品牌管理
查看控制台结果:可以看到,在请求成功的返回结果response中,有一个data属性,里面就是真正的响应数据
从无到有搭建一个电商项目(九):品牌管理

  • total:总条数,目前是165
  • items:当前页数据
  • totalPage:总页数,我们没有返回

异步加载品牌数据

虽然已经通过ajax请求获取了品牌数据,但是上面测试的请求没有携带任何参数,这样显然不对。我们后端接口需要5个参数:

  • page:当前页,int
  • rows:每页大小,int
  • sortBy:排序字段,String
  • desc:是否为降序,boolean
  • key:搜索关键词,String

页面中分页信息应该是在pagination对象中,我们通过浏览器工具,查看pagination中有哪些属性:

  • descending:是否是降序,对应请求参数的desc
  • page:当前页,对应参数的page
  • rowsPerpage:每页大小,对应参数中的rows
  • sortBy:排序字段,对应参数的sortBy

缺少一个搜索关键词,这个应该是通过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; // 加载完成
        });
      }
    }

完成分页和过滤

1)分页
前面实现了页面加载时的第一次查询,但是点击分页或搜索不会发起新的请求,怎么解决?
虽然点击分页,不会发起请求,但通过浏览器工具查看,会发现pagination对象的属性一直在变化:
从无到有搭建一个电商项目(九):品牌管理
我们可以利用Vue的监视功能:watch,当pagination发生改变时,会调用我们的回调函数,我们在回调函数中进行数据的查询:这样就实现了分页

watch: {
      pagination: { // 监视pagination属性变化
        deep: true, // deep为true,会监视pagination的属性及属性中的对象属性变化
        handler() { // 变化后的回调函数,再次调用getDataFromServer
          this.getDataFromServer();
        }
      }
    }

2)过滤
实现过滤跟分页一样。过滤字段对应的是search属性,我们只要监视这个属性即可:

功能二:实现品牌的新增功能

1)页面实现
① 编写弹窗
分析:当点击新增按钮,应该出现一个弹窗,然后在弹窗中出现一个表格,我们就可以填写品牌信息
查看Vuetify官网,弹窗是如何实现:
从无到有搭建一个电商项目(九):品牌管理
通过文档看到对话框的一些属性:

  • value:控制窗口的可见性,true可见,false,不可见
  • max-width:控制对话框最大宽度
  • with:设置对话框的宽度
  • scrollable :是否可滚动,要配合v-card来使用,默认是false
  • persistent :点击弹窗以外的地方不会关闭弹窗,默认是false

我们在data中定义一个dialog属性,来控制对话框的显示状态:

dialog: false // 通过它控制对话框的显示

在页面增加一个对话框v-dialog

	  <!--弹出的对话框-->
      <v-dialog persistent v-model="dialog" width="500">
        <v-card>
          <!--对话框标题-->
          <v-toolbar dense dark color="primary">
            <v-toolbar-title>新增品牌</v-toolbar-title>
          </v-toolbar>
          <!--对话框内容-->
          <v-card-text class="px-5">
            这是一个表单
          </v-card-text>
        </v-card>
      </v-dialog>

组件说明:
dialog指定了3个属性:

  • width:限制宽度
  • v-model:value值双向绑定到dialog变量,用来控制窗口显示
  • persisitent:控制窗口不会被意外关闭

因为可滚动需要配合v-card使用,因此我们在对话框中加入了一个v-card
在v-card的头部添加了一个 v-toolbar,作为窗口的头部,并且写了标题为:新增品牌

  • dense:紧凑显示
  • dark:黑暗主题
  • color:颜色,primary就是整个网站的主色调,蓝色

在v-card的内容部分,暂时空置,等会写表单

  • class=“px-5":vuetify的内置样式,含义是padding的x轴设置为5,这样表单内容会缩进一些,而不是顶着边框

基本语法:{property}{direction}-{size}

  • property:属性,有两种padding和margin
    • p:对应padding
    • m:对应margin
  • direction:只padding和margin的作用方向,
    • t - 对应margin-top或者padding-top属性
    • b - 对应margin-bottom or padding-bottom
    • l - 对应margin-left or padding-left
    • r - 对应margin-right or padding-right
    • x - 同时对应*-left和*-right属性
    • y - 同时对应*-top和*-bottom属性
  • size:控制空间大小,基于spacerspacer进行倍增,spacer默认是16px
    • 0:将margin或padding的大小设置为0
    • 1:将margin或者padding属性设置为$spacer * .25
    • 2:将margin或者padding属性设置为$spacer * .5
    • 3:将margin或者padding属性设置为$spacer
    • 4:将margin或者padding属性设置为$spacer * 1.5
    • 5:将margin或者padding属性设置为$spacer * 3

② 实现弹窗的可见和关闭
窗口可见:点击新增品牌按钮时,将窗口显示,因此要给新增按钮绑定事件

<v-btn color="primary" @click="addBrand()">新增</v-btn>

定义一个addBrand方法:

addBrand(){
    // 控制弹窗可见:
    this.dialog = true;
}

窗口关闭:我们设置了persistent属性,窗口无法被关闭了。除非把dialog属性设置为false,因此我们需要给窗口添加一个关闭按钮:

<!--对话框的标题-->
<v-toolbar dense dark color="primary">
    <v-toolbar-title>新增品牌</v-toolbar-title>
    <v-spacer/>
    <!--关闭窗口的按钮-->
    <v-btn icon @click="close"><v-icon>close</v-icon></v-btn>
</v-toolbar>

我们还给按钮绑定了点击事件,回调函数为close:

close(){
    // 关闭窗口
    this.dialog = false;
}

③ 表单页
我们编写一个组件,组件内写表单代码。然后在对话框引用组件。优点:

  • 表单代码独立组件,可拔插,方便后期的维护。
  • 代码分离,可读性更好。

新建一个MyBrandForm.vue组件:

<template>
  <div>my brand form</div>
</template>

<script>
    export default {
        name: "MyBrandForm"
    }
</script>

<style scoped>

</style>

将MyBrandForm引入到MyBrand中,这里使用局部组件的语法
在js中先导入自定义组件:

  // 导入自定义的表单组件
  import MyBrandForm from './MyBrandForm'

通过components属性来指定局部组件:

components:{
    MyBrandForm
}

页面中引用:

<v-card-text class="px-5">
    这是一个表单
    <my-brand-form></my-brand-form>
</v-card-text>

创建表单
查看文档,找到关于表单的部分:
从无到有搭建一个电商项目(九):品牌管理
v-form,表单组件,内部可以有许多输入项。v-form有下面的属性:

  • value:true,代表表单验证通过;false,代表表单验证失败。

v-form提供了两个方法:

  • reset:重置表单数据
  • validate:校验整个表单数据,前提是你写好了校验规则。返回Boolean表示校验成功或失败

在data中定义一个valid属性,跟表单的value进行双向绑定,观察表单是否通过校验,同时把等会要跟表单关联的品牌brand对象声明出来:

export default {
    name: "my-brand-form",
    data() {
      return {
        valid:false, // 表单校验结果标记
        brand:{
          name:'', // 品牌名称
          letter:'', // 品牌首字母
          image:'',// 品牌logo
          categories:[], // 品牌所属的商品分类数组
        }
      }
    }
  }

页面先写一个表单:

<v-form v-model="valid">

</v-form>

文本框
品牌总共需要这些字段:名称,首字母,商品分类,LOGO
表单项主要包括文本框、密码框、多选框、单选框、文本域、下拉选框、文件上传等。
品牌需要的表单项:

  • 文本框:品牌名称、品牌首字母都属于文本框
  • 文件上传:品牌需要图片,这个是文件上传框
  • 下拉选框:商品分类提前已经定义好,这里需要通过下拉选框展示,提供给用户选择。

查看文档,v-text-field有以下关键属性:

  • append-icon:文本框后追加图标,需要填写图标名称。无默认值
  • clearable:是否添加一个清空图标,点击会清空文本框。默认是false
  • color:颜色
  • counter:是否添加一个文本计数器,在角落显示文本长度,指定true或允许的最大长度。无默认值
  • dark:是否应用黑暗色调,默认是false
  • disable:是否禁用,默认是false
  • flat:是否移除默认的动画效果,默认是false
  • full-width:指定宽度为全屏,默认是false
  • hide-details:是否隐藏错误提示,默认是false
  • hint:输入框的提示文本
  • label:输入框的标签
  • multi-line:是否转为文本域,默认是false。文本框和文本域可以自由切换
  • placeholder:输入框占位符文本,focus后消失
  • required:是否为必填项,如果是,会在label后加*,不具备校验功能。默认是false
  • rows:文本域的行数,multi-line为true时才有效
  • rules:指定校验规则及错误提示信息,数组结构。默认[]
  • single-line:是否单行文本显示,默认是false
  • suffix:显示后缀

级联下拉选框
分析:商品分类应该是下拉选框,它包含三级。在展示的时候,应该是先由用户选中1级,才显示2级;选择了2级,才显示3级。形成一个多级分类的三级联动效果。Vuetify中并没有提供(它提供的是基本的下拉框),这里已经自定义一个无限级联动的下拉选框,我们直接使用:

    <v-cascader
      url="/item/category/list"
      multiple 
      required
      v-model="brand.categories"
      label="请选择商品分类"/>
  • url:加载商品分类选项的接口路径
  • multiple:是否多选,这里设置为true,因为一个品牌可能有多个分类
  • requried:是否是必须的,这里为true,会在提示上加*,提醒用户
  • v-model:关联我们brand对象的categories属性
  • label:文字说明

文件上传项
Vuetify中,也没有文件上传的组件。已经自定义好了一个文件上传的组件,我们直接用:

<v-layout row>
      <v-flex xs3><label style="color: rgba(0,0,0,.54); font-size: 16px">品牌LOG:</label></v-flex>
      <v-flex>
        <v-upload
          v-model="brand.image"
          url="/upload"
          :multiple="false"
          :pic-width="250"
          :pic-height="90"
        />
      </v-flex>
    </v-layout>

注意:

  • 文件上传组件本身没有提供文字提示。因此我们需要自己添加一段文字说明
  • 我们要实现文字和图片组件左右放置,因此这里使用了v-layout布局组件:
    • layout添加了row属性,代表这是一行,如果是column,代表是多行
    • layout下面有v-flex组件,是这一行的单元,我们有2个单元
      • <v-flex xs3> :显示文字说明,xs3是响应式布局,代表占12格中的3格
      • 剩下的部分就是图片上传组件了
  • v-upload:图片上传组件,包含以下属性:
    • v-model:将上传的结果绑定到brand的image属性
    • url:上传的路径,我们先随便写一个。
    • multiple:是否运行多图片上传,这里是false。因为品牌LOGO只有一个
    • pic-width和pic-height:可以控制图片上传后展示的宽高

按钮
表单的最下面添加两个按钮:提交和重置

    <v-layout row my-3>
      <v-spacer/>
      <v-btn @click="reset">重置</v-btn>
      <v-btn @click="submit" color="primary">提交</v-btn>
    </v-layout>
  • 通过layout来进行布局,my-3增大上下边距
  • v-spacer占用一定空间,将按钮都排挤到页面右侧
  • 两个按钮分别绑定了submit和reset事件

④ 重置表单
因为v-form组件已经提供了reset方法,用来清空表单数据。只要我们拿到表单组件对象,就可以调用方法了,我们可以通过$refs内置对象来获取表单组件
首先,在表单上定义ref属性: