概述

  • 增加电子书管理页面:页面,路由,菜单
  • 电子书列表展示:表格组件,查询表单,后端列表查询接口
  • 前后端分页处理:后端分页插件,前端分页组件,前后端分页参数传递
  • 电子书编辑:模态框组件,表单组件,后端保存接口
  • 电子书新增:界面复用编辑功能,后端复用保存接口,雪花算法
  • 电子书删除:确认组件,后端删除接口
  • 参数校验:集成SpringBoot Validation

增加电子书管理页面

1. 增加电子书页面

因为电子书管理(管理员)页面和电子书访问(用户)页面的访问群体不一样,所以我们在前端项目的src/view当中新建一个admin文件夹,下面全部放管理的页面。

我们在src/view/admin当中创建一个文件叫做:admin-ebook.vue,然后内容我们后面再补充。

2. 增加电子书菜单

然后在src/components/the-header.vue当中使用router-link组件配置跳转到电子书管理的按钮即可:

  1. <a-menu-item key="/">
  2. <router-link to="/">首页</router-link>
  3. </a-menu-item>
  4. <a-menu-item key="/admin/ebook" :style="user.id? {} : {display:'none'}">
  5. <router-link to="/admin/ebook">电子书管理</router-link>
  6. </a-menu-item>
  7. <a-menu-item key="/about">
  8. <router-link to="/about">关于我们</router-link>
  9. </a-menu-item>

3. 增加电子书路由

添加路由非常简单,我们在src/router/index.ts当中引入,然后配置即可:

  1. import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
  2. import AdminEbook from '../views/admin/admin-ebook.vue' // 1. 引入组件
  3. const routes: Array<RouteRecordRaw> = [
  4. // 2.配置路由
  5. {
  6. path: '/admin/ebook',
  7. name: 'AdminEbook',
  8. component: AdminEbook,
  9. },
  10. ]
  11. const router = createRouter({
  12. history: createWebHistory(process.env.BASE_URL),
  13. routes
  14. })
  15. export default router

电子书表格展示

1. Ant Design Vue表格组件介绍

我们可以登录Ant Design Vue的官网进行学习,但是由于我们使用的是vue3,所以Ant Design Vue的版本必须是V2以上。

2. 增加电子书表格展示

我们直接来阅读admin-ebook.vue的代码内容:

  1. <template>
  2. <a-layout>
  3. <a-layout-content
  4. :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
  5. >
  6. <a-table
  7. :columns="columns" // 表头
  8. :row-key="record => record.id" // 每行的id做为key值
  9. :data-source="ebooks" // 数据来源
  10. :pagination="pagination" // 分页
  11. :loading="loading" // 有载入的效果
  12. @change="handleTableChange"
  13. >
  14. <template #cover="{ text: cover }"> // 1-2 对封面特殊展示成图片,cover就是这行的值,在数据库里是一个路径
  15. <img v-if="cover" :src="cover" alt="avatar" />
  16. </template>
  17. <template v-slot:action="{ text, record }"> // 2-2 对操作特殊展示成为两个按钮
  18. <a-space size="small">
  19. <a-button type="primary" @click="edit(record)">
  20. 编辑
  21. </a-button>
  22. <a-popconfirm
  23. title="删除后不可恢复,确认删除?"
  24. ok-text="是"
  25. cancel-text="否"
  26. @confirm="handleDelete(record.id)"
  27. >
  28. <a-button type="danger">
  29. 删除
  30. </a-button>
  31. </a-popconfirm>
  32. </a-space>
  33. </template>
  34. </a-table>
  35. </a-layout-content>
  36. </a-layout>
  37. </template>
  38. <script lang="ts">
  39. import { defineComponent, onMounted, ref } from 'vue';
  40. import axios from 'axios';
  41. import { message } from 'ant-design-vue';
  42. import {Tool} from "@/util/tool";
  43. export default defineComponent({
  44. name: 'AdminEbook',
  45. setup() {
  46. const ebooks = ref(); // 定义数据源
  47. const pagination = ref({ // 定义分页
  48. current: 1,
  49. pageSize: 10,
  50. total: 0
  51. });
  52. const loading = ref(false); // 定义载入状态
  53. // 表格表头
  54. const columns = [
  55. {
  56. title: '封面',
  57. dataIndex: 'cover',
  58. slots: { customRender: 'cover' } // 1-1 对封面做特殊展示
  59. },
  60. {
  61. title: '名称',
  62. dataIndex: 'name'
  63. },
  64. {
  65. title: '分类',
  66. slots: { customRender: 'category' }
  67. },
  68. {
  69. title: '文档数',
  70. dataIndex: 'docCount'
  71. },
  72. {
  73. title: '阅读数',
  74. dataIndex: 'viewCount'
  75. },
  76. {
  77. title: '点赞数',
  78. dataIndex: 'voteCount'
  79. },
  80. {
  81. title: 'Action',
  82. key: 'action',
  83. slots: { customRender: 'action' } // 2-1 对操作做特殊展示
  84. }
  85. ];
  86. /**
  87. * 数据查询
  88. **/
  89. const handleQuery = (params: any) => {
  90. loading.value = true;
  91. // 如果不清空现有数据,则编辑保存重新加载数据后,再点编辑,则列表显示的还是编辑前的数据
  92. ebooks.value = [];
  93. axios.get("/ebook/list", {
  94. params: {
  95. page: params.page,
  96. size: params.size,
  97. name: param.value.name
  98. }
  99. }).then((response) => {
  100. loading.value = false;
  101. const data = response.data;
  102. if (data.success) {
  103. ebooks.value = data.content.list;
  104. // 重置分页按钮
  105. pagination.value.current = params.page;
  106. pagination.value.total = data.content.total;
  107. } else {
  108. message.error(data.message);
  109. }
  110. });
  111. };
  112. /**
  113. * 表格点击页码时触发
  114. */
  115. const handleTableChange = (pagination: any) => {
  116. console.log("看看自带的分页参数都有啥:" + pagination);
  117. handleQuery({
  118. page: pagination.current,
  119. size: pagination.pageSize
  120. });
  121. };
  122. onMounted(() => {
  123. handleQueryCategory();
  124. });
  125. return {
  126. ebooks,
  127. pagination,
  128. columns,
  129. loading,
  130. handleTableChange,
  131. //handleQuery没有在html当中使用,所以不需要返回
  132. }
  133. }
  134. });
  135. </script>
  136. <style scoped>
  137. img {
  138. width: 50px;
  139. height: 50px;
  140. }
  141. </style>

PageHelper实现后端分页

1. 集成PageHelper插件

先在pom.xml当中添加下面的依赖(可以直接上maven仓库上拷贝过来):

  1. <dependency>
  2. <groupId>com.github.pagehelper</groupId>
  3. <artifactId>pagehelper-spring-boot-starter</artifactId>
  4. <version>1.2.13</version>
  5. </dependency>

下载好依赖之后可以直接使用,不需要添加任何配置,当然如果我们想看sql查询的日志和结果,我们可以在resources/application.properties当中最后添加下面这么一段配置:

  1. # 打印所有sql日志,sql,参数和结果
  2. logging.level.com.taopoppy.wiki.mapper=trace

这一个配置就是将mapper里面的日志级别变为最低,意思就是所有都打印出来。然后我们说明一下这个分页的原理,就是最基本的话要查两次,一次是总数据,一次是当前页的列表数据

2. 修改电子书列表接口

下面我们在EbookService当中使用PageHelper:

  1. public List<EbookResp> list(EbookReq req) {
  2. EbookExample ebookExample = new EbookExample();
  3. EbookExample.Criteria criteria = ebookExample.createCriteria();
  4. if(!ObjectUtils.isEmpty(req.getName())) {
  5. criteria.andNameLike("%" + req.getName() + "%");
  6. }
  7. PageHelper.startPage(1, 3); // 在selectByExample前面使用PageHelper
  8. List<Ebook> ebooksList = ebookMapper.selectByExample(ebookExample);
  9. List<EbookResp> respList = CopyUtil.copyList(ebooksList, EbookResp.class);
  10. return respList;
  11. }

特别注意,我们最好是将PageHelper的代码写在ebookMapper.selectByExample前面,因为PageHelper只作用在后面代码的第一次查询当中,也就是说如果PageHelper和ebookMapper.selectByExample中间如果还有别的查询,比如用户查询等等,PageHelper就不会作用在ebookMapper.selectByExample了,而是作用在离他最近的一次查询。

3. PageInfo类

除了PageHelper之外,还提供了另一个类PageInfo,可以通过它来获取总行数,总页数等信息:

  1. public class EbookService {
  2. // 这个就是用来打印的,在WikiApplication当中也用到了
  3. private static final Logger LOG = LoggerFactory.getLogger(EbookService.class);
  4. @Resource
  5. private EbookMapper ebookMapper;
  6. public List<EbookResp> list(EbookReq req) {
  7. EbookExample ebookExample = new EbookExample();
  8. EbookExample.Criteria criteria = ebookExample.createCriteria();
  9. if(!ObjectUtils.isEmpty(req.getName())) {
  10. criteria.andNameLike("%" + req.getName() + "%");
  11. }
  12. PageHelper.startPage(1, 3);
  13. List<Ebook> ebooksList = ebookMapper.selectByExample(ebookExample);
  14. PageInfo<Ebook> pageInfo = new PageInfo<>(ebooksList); // PageInfo
  15. LOG.info("总行数:{}",pageInfo.getTotal()); // 总行数
  16. LOG.info("总页数:{}",pageInfo.getPages()); // 总页数,一般不需要,前端会根据总数自动计算总页数
  17. List<EbookResp> respList = CopyUtil.copyList(ebooksList, EbookResp.class);
  18. return respList;
  19. }
  20. }

根据上面的代码我们基本就可以确切的获得分页的四个要素:

  • 前端传入的两个参数:第几页(page),每页多少条数据(pageSize)
  • 后端返回的两个参数:数据列表(respList),总函数(total)

封装分页的请求和返回参数

根据上面的分页的四个要素,我们就来封装请求参数(PageReq)和返回参数(PageResp):

1. 请求参数的封装

我们现在req文件夹当中创建PageReq:

  1. package com.taopoppy.wiki.req;
  2. public class PageReq {
  3. private int page;
  4. private int size;
  5. public int getPage() {
  6. return page;
  7. }
  8. public void setPage(int page) {
  9. this.page = page;
  10. }
  11. public int getSize() {
  12. return size;
  13. }
  14. public void setSize(int size) {
  15. this.size = size;
  16. }
  17. @Override
  18. public String toString() {
  19. final StringBuffer sb = new StringBuffer("PageReq{");
  20. sb.append("page=").append(page);
  21. sb.append(", size=").append(size);
  22. sb.append('}');
  23. return sb.toString();
  24. }
  25. }

但是我们现在进入到后端的是EbookReq,所以如果后端也要能收到分页信息,我们将EbookReq类去继承PageReq,其他页面类型需要分类的也可以去继承PageReq:

  1. public class EbookReq extends PageReq // 继承PageReq

这个时候我们在EbookService和EbookController当中的代码都不需要大改动,只需要把我们前面获取page和size书写成为动态化即可,在EbookService当中:

  1. public List<EbookResp> list(EbookReq req) {
  2. EbookExample ebookExample = new EbookExample();
  3. EbookExample.Criteria criteria = ebookExample.createCriteria();
  4. if(!ObjectUtils.isEmpty(req.getName())) {
  5. criteria.andNameLike("%" + req.getName() + "%");
  6. }
  7. // page和size都从req当中可以获取到
  8. PageHelper.startPage(req.getPage(), req.getSize());
  9. List<Ebook> ebooksList = ebookMapper.selectByExample(ebookExample);
  10. PageInfo<Ebook> pageInfo = new PageInfo<>(ebooksList);
  11. LOG.info("总行数:{}",pageInfo.getTotal());
  12. LOG.info("总页数:{}",pageInfo.getPages());
  13. return respList;
  14. }

2. 返回参数的封装

我们首先在resp文件夹下面创建一个PageResp.java,内容如下:

  1. package com.taopoppy.wiki.resp;
  2. import java.util.List;
  3. public class PageResp<T> {
  4. private long total; // 返回总数
  5. private List<T> list; // 数据列表,需要泛型支撑
  6. public long getTotal() {
  7. return total;
  8. }
  9. public void setTotal(long total) {
  10. this.total = total;
  11. }
  12. public List<T> getList() {
  13. return list;
  14. }
  15. public void setList(List<T> list) {
  16. this.list = list;
  17. }
  18. @Override
  19. public String toString() {
  20. final StringBuffer sb = new StringBuffer("PageResp{");
  21. sb.append("total=").append(total);
  22. sb.append(", list=").append(list);
  23. sb.append('}');
  24. return sb.toString();
  25. }
  26. }

然后我们修改EbookService.java当中的内容:

  1. public PageResp<EbookResp> list(EbookReq req) {
  2. EbookExample ebookExample = new EbookExample();
  3. EbookExample.Criteria criteria = ebookExample.createCriteria();
  4. if(!ObjectUtils.isEmpty(req.getName())) {
  5. criteria.andNameLike("%" + req.getName() + "%");
  6. }
  7. PageHelper.startPage(req.getPage(), req.getSize());
  8. List<Ebook> ebooksList = ebookMapper.selectByExample(ebookExample);
  9. PageInfo<Ebook> pageInfo = new PageInfo<>(ebooksList);
  10. LOG.info("总行数:{}",pageInfo.getTotal());
  11. LOG.info("总页数:{}",pageInfo.getPages());
  12. List<EbookResp> respList = CopyUtil.copyList(ebooksList, EbookResp.class);
  13. // 把返回数据类型改成PageResp<EbookResp>
  14. PageResp<EbookResp> pageResp = new PageResp();
  15. pageResp.setTotal(pageInfo.getTotal());
  16. pageResp.setList(respList);
  17. return pageResp;
  18. }

改完之后我们还需要修改EbookController当中的部分代码:

  1. @GetMapping("/list")
  2. public CommonResp list(EbookReq req) {
  3. // CommonResp<list<EbookResp>> 改为 CommonResp<PageResp<EbookResp>>
  4. CommonResp<PageResp<EbookResp>> resp = new CommonResp<>();
  5. PageResp<EbookResp> list = ebookService.list(req);
  6. resp.setContent(list);
  7. return resp;
  8. }

前后端分页功能整合

上面我们处理了后端分页的功能,现在我们来处理前端,以此达到前后端整合的效果,因为我们之前将管理员管理的页面也加入进来,所以对于电子书列表我们需要改动两个页面,一个是views/home.vue,另一个就是views/admin/admin-ebook.vue:

  1. /**
  2. * 数据查询
  3. **/
  4. const handleQuery = (params: any) => {
  5. loading.value = true;
  6. // 如果不清空现有数据,则编辑保存重新加载数据后,再点编辑,则列表显示的还是编辑前的数据
  7. ebooks.value = [];
  8. axios.get("/ebook/list", {
  9. params: {
  10. page: params.page,
  11. size: params.size,
  12. name: param.value.name
  13. }
  14. }).then((response) => {
  15. loading.value = false;
  16. const data = response.data;
  17. if (data.success) {
  18. ebooks.value = data.content.list;
  19. // 重置分页按钮
  20. pagination.value.current = params.page;
  21. pagination.value.total = data.content.total;
  22. } else {
  23. message.error(data.message);
  24. }
  25. });
  26. };
  27. onMounted(() => {
  28. handleQuery();
  29. });
  1. /**
  2. * 查询数据
  3. */
  4. const handleQueryEbook = () => {
  5. axios.get("/ebook/list", {
  6. params: {
  7. page: 1,
  8. size: 1000,
  9. categoryId2: categoryId2
  10. }
  11. }).then((response) => {
  12. const data = response.data;
  13. ebooks.value = data.content.list;
  14. // ebooks1.books = data.content;
  15. });
  16. };

制作电子书表单

本小节的功能就是点击每一行的编辑按钮,弹出编辑框,然后编辑框显示电子书表单,对应的就是ant design vue当中的一个model框而已,这个在官网看一下就会了

1. 前端代码展示

  1. <template>
  2. <a-layout>
  3. <a-layout-content
  4. :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
  5. >
  6. <p>
  7. <a-form layout="inline" :model="param">
  8. <a-form-item>
  9. <a-input v-model:value="param.name" placeholder="名称">
  10. </a-input>
  11. </a-form-item>
  12. <a-form-item>
  13. <a-button type="primary" @click="handleQuery({page: 1, size: pagination.pageSize})">
  14. 查询
  15. </a-button>
  16. </a-form-item>
  17. <a-form-item>
  18. <a-button type="primary" @click="add()">
  19. 新增
  20. </a-button>
  21. </a-form-item>
  22. </a-form>
  23. </p>
  24. <a-table
  25. :columns="columns"
  26. :row-key="record => record.id"
  27. :data-source="ebooks"
  28. :pagination="pagination"
  29. :loading="loading"
  30. @change="handleTableChange"
  31. >
  32. <template #cover="{ text: cover }">
  33. <img v-if="cover" :src="cover" alt="avatar" />
  34. </template>
  35. <template v-slot:category="{ text, record }">
  36. <span>{{ getCategoryName(record.category1Id) }} / {{ getCategoryName(record.category2Id) }}</span>
  37. </template>
  38. <template v-slot:action="{ text, record }">
  39. <a-space size="small">
  40. <router-link :to="'/admin/doc?ebookId=' + record.id">
  41. <a-button type="primary">
  42. 文档管理
  43. </a-button>
  44. </router-link>
  45. <a-button type="primary" @click="edit(record)">
  46. 编辑
  47. </a-button>
  48. <a-popconfirm
  49. title="删除后不可恢复,确认删除?"
  50. ok-text="是"
  51. cancel-text="否"
  52. @confirm="handleDelete(record.id)"
  53. >
  54. <a-button type="danger">
  55. 删除
  56. </a-button>
  57. </a-popconfirm>
  58. </a-space>
  59. </template>
  60. </a-table>
  61. </a-layout-content>
  62. </a-layout>
  63. <a-modal
  64. title="电子书表单"
  65. v-model:visible="modalVisible"
  66. :confirm-loading="modalLoading"
  67. @ok="handleModalOk"
  68. >
  69. <a-form :model="ebook" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
  70. <a-form-item label="封面">
  71. <a-input v-model:value="ebook.cover" />
  72. </a-form-item>
  73. <a-form-item label="名称">
  74. <a-input v-model:value="ebook.name" />
  75. </a-form-item>
  76. <a-form-item label="分类">
  77. <a-cascader
  78. v-model:value="categoryIds"
  79. :field-names="{ label: 'name', value: 'id', children: 'children' }"
  80. :options="level1"
  81. />
  82. </a-form-item>
  83. <a-form-item label="描述">
  84. <a-input v-model:value="ebook.description" type="textarea" />
  85. </a-form-item>
  86. </a-form>
  87. </a-modal>
  88. </template>
  89. <script lang="ts">
  90. import { defineComponent, onMounted, ref } from 'vue';
  91. import axios from 'axios';
  92. import { message } from 'ant-design-vue';
  93. import {Tool} from "@/util/tool";
  94. export default defineComponent({
  95. name: 'AdminEbook',
  96. setup() {
  97. // -------- 表单 ---------
  98. /**
  99. * 数组,[100, 101]对应:前端开发 / Vue
  100. */
  101. const categoryIds = ref();
  102. const ebook = ref();
  103. const modalVisible = ref(false);
  104. const modalLoading = ref(false);
  105. const handleModalOk = () => {
  106. modalLoading.value = true;
  107. ebook.value.category1Id = categoryIds.value[0];
  108. ebook.value.category2Id = categoryIds.value[1];
  109. axios.post("/ebook/save", ebook.value).then((response) => {
  110. modalLoading.value = false;
  111. const data = response.data; // data = commonResp
  112. if (data.success) {
  113. modalVisible.value = false;
  114. // 重新加载列表
  115. handleQuery({
  116. page: pagination.value.current,
  117. size: pagination.value.pageSize,
  118. });
  119. } else {
  120. message.error(data.message);
  121. }
  122. });
  123. };
  124. /**
  125. * 编辑
  126. */
  127. const edit = (record: any) => {
  128. modalVisible.value = true;
  129. ebook.value = Tool.copy(record);
  130. categoryIds.value = [ebook.value.category1Id, ebook.value.category2Id]
  131. };
  132. }
  133. });
  134. </script>

上面的代码非常简单,只不过我们在点击编辑弹出表单后,我们修改完毕提交表单,要请求一个ebook的保存接口,接口我们后面来在spring boot当中添加。

2. 后端新增修改接口

实际上保存的接口,我们不需要返回什么,返回成功的标志信息就可以,在CommonResp类当中的content不需要放值:

  1. @GetMapping("/list")
  2. public CommonResp list(EbookQueryReq req) {
  3. // controller层尽量不要出现Ebook这个实体类
  4. // 因为实体类是和数据库一一对应的
  5. CommonResp<PageResp<EbookResp>> resp = new CommonResp<>();
  6. PageResp<EbookResp> list = ebookService.list(req);
  7. resp.setContent(list);
  8. return resp;
  9. }
  10. @PostMapping("/save")
  11. public CommonResp save(@RequestBody EbookSaveReq req) {
  12. CommonResp resp = new CommonResp<>();
  13. ebookService.save(req);
  14. return resp;
  15. }

可以看到我们为了区分两个接口要传进来的参数类,我们分为了EbookQueryReq和EbookSaveReq两个类,前者是之前EbookReq重命名的(shift + F6重命名就会自动重构所有相关大代码), 后者就是完全拷贝domain当中的内容。同时也记得将EbookResp重命名为EbookQueryResp即可。

另外,我们前端是使用Content-Type: application/json方式提交的post,所以后端需要使用@RequestBody这个注解参数。

接下来我们要去书写ebookService当中的save方法:

  1. /**
  2. * 电子书保存接口(保存包括编辑保存->更新, 也包括新增保存->新增, 根据req是否有id来判断)
  3. * @param req 电子书保存参数
  4. */
  5. public void save(EbookSaveReq req) {
  6. Ebook ebook = CopyUtil.copy(req, Ebook.class);
  7. if(ObjectUtils.isEmpty(req.getId())) {
  8. // 新增
  9. ebookMapper.insert(ebook);
  10. } else {
  11. // 更新
  12. // 因为updateByPrimaryKey传递的Ebook类型的参数,所以需要将EbookSaveReq 转化成Ebook
  13. ebookMapper.updateByPrimaryKey(ebook);
  14. }
  15. }

雪花算法和新增功能

1. 时间戳概念

时间戳是一串数字,比如1609805322846这种长整型,而1970-01-01 08:00:00这种不是时间戳,是能算日期格式化。

时间戳是怎么计算的呢,就是按照当前时间和1970-01-01 08:00:00的时间差值做当前时间的时间戳,下面是两种得到当前时间的时间戳的方法:

  1. System.currentTimeMillis(); // 1609805322846
  2. new Date().getTime(); // 1609805322847

2. 雪花算法工具类

工具类,是用来生成数据库id的,雪花算法其实就是一个时间戳加上一些机器码,再加递增的序列号,按照下面的代码,我们是计算的是从2021年起始的时间,然后一毫秒内可以计算出2的12次方个时间戳,使用方法也很简单,使用snowFlake.nextId()即可

  1. package com.taopoppy.wiki.util;
  2. import org.springframework.stereotype.Component;
  3. import java.text.ParseException;
  4. /**
  5. * Twitter的分布式自增ID雪花算法
  6. **/
  7. @Component
  8. public class SnowFlake {
  9. /**
  10. * 起始的时间戳
  11. */
  12. private final static long START_STMP = 1609459200000L; // 2021-01-01 00:00:00
  13. /**
  14. * 每一部分占用的位数
  15. */
  16. private final static long SEQUENCE_BIT = 12; //序列号占用的位数
  17. private final static long MACHINE_BIT = 5; //机器标识占用的位数
  18. private final static long DATACENTER_BIT = 5;//数据中心占用的位数
  19. /**
  20. * 每一部分的最大值
  21. */
  22. private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);
  23. private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
  24. private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
  25. /**
  26. * 每一部分向左的位移
  27. */
  28. private final static long MACHINE_LEFT = SEQUENCE_BIT;
  29. private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
  30. private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;
  31. private long datacenterId = 1; //数据中心
  32. private long machineId = 1; //机器标识
  33. private long sequence = 0L; //序列号
  34. private long lastStmp = -1L;//上一次时间戳
  35. public SnowFlake() {
  36. }
  37. public SnowFlake(long datacenterId, long machineId) {
  38. if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
  39. throw new IllegalArgumentException("datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
  40. }
  41. if (machineId > MAX_MACHINE_NUM || machineId < 0) {
  42. throw new IllegalArgumentException("machineId can't be greater than MAX_MACHINE_NUM or less than 0");
  43. }
  44. this.datacenterId = datacenterId;
  45. this.machineId = machineId;
  46. }
  47. /**
  48. * 产生下一个ID
  49. *
  50. * @return
  51. */
  52. public synchronized long nextId() {
  53. long currStmp = getNewstmp();
  54. if (currStmp < lastStmp) {
  55. throw new RuntimeException("Clock moved backwards. Refusing to generate id");
  56. }
  57. if (currStmp == lastStmp) {
  58. //相同毫秒内,序列号自增
  59. sequence = (sequence + 1) & MAX_SEQUENCE;
  60. //同一毫秒的序列数已经达到最大
  61. if (sequence == 0L) {
  62. currStmp = getNextMill();
  63. }
  64. } else {
  65. //不同毫秒内,序列号置为0
  66. sequence = 0L;
  67. }
  68. lastStmp = currStmp;
  69. return (currStmp - START_STMP) << TIMESTMP_LEFT //时间戳部分
  70. | datacenterId << DATACENTER_LEFT //数据中心部分
  71. | machineId << MACHINE_LEFT //机器标识部分
  72. | sequence; //序列号部分
  73. }
  74. private long getNextMill() {
  75. long mill = getNewstmp();
  76. while (mill <= lastStmp) {
  77. mill = getNewstmp();
  78. }
  79. return mill;
  80. }
  81. private long getNewstmp() {
  82. return System.currentTimeMillis();
  83. }
  84. public static void main(String[] args) throws ParseException {
  85. // 时间戳
  86. // System.out.println(System.currentTimeMillis());
  87. // System.out.println(new Date().getTime());
  88. //
  89. // String dateTime = "2021-01-01 08:00:00";
  90. // SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
  91. // System.out.println(sdf.parse(dateTime).getTime());
  92. SnowFlake snowFlake = new SnowFlake(1, 1);
  93. long start = System.currentTimeMillis();
  94. for (int i = 0; i < 10; i++) {
  95. System.out.println(snowFlake.nextId());
  96. System.out.println(System.currentTimeMillis() - start);
  97. }
  98. }
  99. }

上面在main函数当中书写了一些测试代码,是通过new方法,但是我们希望通过注入的方式来使用,所以使用了@Component进行标注,使用的时候可以写@Resource,也可以写@Autowired,@Resource是jdk自带的,@Autowired是Spring自带的。

3. 完成新增功能

可以看到我们下面在新增的时候使用了id,id有几种算法,一种是最简单的自增,还有一种是uuid,还有一种就是雪花算法。三种算法的区别可以在网络上查一下。

  1. @Service
  2. public class EbookService {
  3. private static final Logger LOG = LoggerFactory.getLogger(EbookService.class);
  4. @Resource
  5. private EbookMapper ebookMapper;
  6. @Resource
  7. private SnowFlake snowFlake;
  8. public void save(EbookSaveReq req) {
  9. Ebook ebook = CopyUtil.copy(req, Ebook.class);
  10. if(ObjectUtils.isEmpty(req.getId())) {
  11. // 新增, 给新增的数据添加雪花id
  12. ebook.setId(snowFlake.nextId());
  13. ebookMapper.insert(ebook);
  14. } else {
  15. ebookMapper.updateByPrimaryKey(ebook);
  16. }
  17. }
  18. }

而前端只需要自己添加一个新增的按钮,然后给新增的按钮添加@click=add()即可:

  1. // 新增
  2. const add = () => {
  3. modalVisible.value = true;
  4. ebook.value = {};
  5. };

电子书删除功能

1. 后端代码展示

删除一般都是按照id来删除,因为id是主键,我们就按照id来删除:

  1. @DeleteMapping("/delete/{id}")
  2. public CommonResp delete(@PathVariable long id) {
  3. CommonResp resp = new CommonResp<>();
  4. ebookService.delete(id);
  5. return resp;
  6. }
  1. /**
  2. * 电子书删除接口(按照id进行删除)
  3. * @param id
  4. */
  5. public void delete(long id) {
  6. ebookMapper.deleteByPrimaryKey(id);
  7. }

注意我们上面是使用了method为delete方法,这些都是restful风格方法当中的一个。下面我们回到前端看看点击删除按钮如何请求前端的删除接口:

2. 前端代码展示

  1. const handleDelete = (id: number) => {
  2. axios.delete("/ebook/delete/" + id).then((response) => {
  3. const data = response.data; // data = commonResp
  4. if (data.success) {
  5. // 重新加载列表
  6. handleQuery({
  7. page: pagination.value.current,
  8. size: pagination.value.pageSize,
  9. });
  10. } else {
  11. message.error(data.message);
  12. }
  13. });
  14. };

集成Validation参数校验

这里的校验主要针对于电子书的查询和保存参数校验,使用到spring-boot-starter-validation这个插件,校验不通过的时候,前端弹出错误提示:

1. 集成validation

在pom.xml集成下面的代码,这个依赖是SpringBoot内置的,所以不需要加版本号

  1. <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation -->
  2. <dependency>
  3. <groupId>org.springframework.boot</groupId>
  4. <artifactId>spring-boot-starter-validation</artifactId>
  5. </dependency>

2. 后端集成校验

集成完毕我们不需要像nodejs那么去自己书写校验规则,而是直接到请求的参数类当中去添加注解,因为我们查询电子书的接口请求的参数类是EbookQueryReq,而且是继承了PageReq,由于考虑到查询必须要传递请求的页数和每页的条数,所以我们在PageReq当中给参数直接做注解

  1. public class PageReq {
  2. @NotNull(message = "【页码】不能为空")
  3. private int page;
  4. @NotNull(message = "【每页条数】不能为空")
  5. @Max(value = 1000, message = "【每页条数】不能超过100")
  6. private int size;
  7. }

然后我们需要在校验的参数要添加@Valid的注解,表明这个参数是需要校验的,校验的规则就在EbookQueryReq或者EbookQueryReq继承的父类当中:

  1. @GetMapping("/list")
  2. public CommonResp list(@Valid EbookQueryReq req) {
  3. }

这样配置好了之后,如果你传递的参数有错,就会报错,报org.springframework.validation.BindException的错误,但是参数如果校验错误,后端就无法正常的返回,直接返回400错误,而我们需要后端返回的是CommonResp,然后CommonResp当中的success为false即可,所以我们在controller当中创建一个ControllerExceptionHandler.java文件,然后内容如下

  1. /**
  2. * 统一异常处理、数据预处理等
  3. */
  4. @ControllerAdvice
  5. public class ControllerExceptionHandler {
  6. private static final Logger LOG = LoggerFactory.getLogger(ControllerExceptionHandler.class);
  7. /**
  8. * 校验异常统一处理
  9. * @param e
  10. * @return
  11. */
  12. @ExceptionHandler(value = BindException.class)
  13. @ResponseBody
  14. public CommonResp validExceptionHandler(BindException e) {
  15. CommonResp commonResp = new CommonResp();
  16. LOG.warn("参数校验失败:{}", e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
  17. commonResp.setSuccess(false);
  18. commonResp.setMessage(e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
  19. return commonResp;
  20. }
  21. }

这个文件会被springboot自动识别,因为有@ControllerAdvice的注解,上面的这个代码专门对BindException类型错误进行处理,如果要对其他类型错误进行处理,再写一个类似的函数。可以看到它返回的是CommonResp,设置一些属性,这样前端就能正确接收CommonResp,CommonResp当中包含着错误信息,而不是前端直接受到BindException错误。

管理功能优化

1. 增加名字查询

这个功能我们在后端已经实现过了,就是那个动态sql的实现,而前端我们去代码当中看一下vue3当中怎么书写的即可,这里不做过多的说明,去admin-ebook.vue当中查看查询的按钮即可。

2. 编辑时复制对象

在admin-ebook.vue当中,有编辑的功能,点击编辑会弹出一个框,这个表单框和列表数据是同一个数据,修改的时候就会去同步修改列表的数据,我们希望表单框的数据是复制一份出来的,所以我们需要深拷贝:

  1. /**
  2. * 编辑
  3. */
  4. const edit = (record: any) => {
  5. modalVisible.value = true;
  6. ebook.value = Tool.copy(record);
  7. categoryIds.value = [ebook.value.category1Id, ebook.value.category2Id]
  8. };
  1. export class Tool {
  2. /**
  3. * 空校验 null或""都返回true
  4. */
  5. public static isEmpty (obj: any) {
  6. if ((typeof obj === 'string')) {
  7. return !obj || obj.replace(/\s+/g, "") === ""
  8. } else {
  9. return (!obj || JSON.stringify(obj) === "{}" || obj.length === 0);
  10. }
  11. }
  12. /**
  13. * 非空校验
  14. */
  15. public static isNotEmpty (obj: any) {
  16. return !this.isEmpty(obj);
  17. }
  18. /**
  19. * 对象复制
  20. * @param obj
  21. */
  22. public static copy (obj: object) {
  23. if (Tool.isNotEmpty(obj)) {
  24. return JSON.parse(JSON.stringify(obj));
  25. }
  26. }
  27. }