• 重点学习无限极树的管理功能设计&富文本编辑框的使用
  • 文档表设计与代码生成
  • 按照分类管理的代码,复制出一套文档树管理
  • 关于无限极树的增删改查功能开发
  • 文档内容保存于显示:富文本框的使用
  • 首页点击某个电子书时,跳转到文档页面,显示文档树
  • 点击某个文档时,加载文档内容

文档表设计与代码生成

1. 文档表设计

给文档表起名doc,然后sql如下:

  1. drop table if exists `doc`;
  2. create table `doc` (
  3. `id` bigint not null comment 'id',
  4. `ebook_id` bigint not null default 0 comment '电子书id',
  5. `parent` bigint not null default 0 comment '父id',
  6. `name` varchar(50) not null comment '名称',
  7. `sort` int comment '排序',
  8. `view_count` int default 0 comment '阅读数',
  9. `vote_count` int default 0 comment '点赞数',
  10. primary key (`id`)
  11. ) engine=innodb default charset=utf8mb4 comment='文档';
  12. insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (1,1,0,'文档1',1,0,0);
  13. insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (2,1,1,'文档1.1',1,0,0);
  14. insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (3,1,0,'文档2',2,0,0);
  15. insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (4,1,3,'文档2.1',1,0,0);
  16. insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (5,1,3,'文档2.2',2,0,0);
  17. insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (6,1,5,'文档2.2.1',1,0,0);

2. 生成持久层代码

生成持久层代码,依旧去generator-config.xml,将最后一行的table改成doc,然后去执行mybatis-generator这个命令,去生成四个文件,依次是Doc.java,DocExample.java,DocMapper.java,DocMapper.xml。

  1. <table tableName="doc"/>

文档表增删改查

1. 后端改造

按照分类管理,复制出一套文档管理的代码,每个文件复制过来之后使用ctrl + R将关键字全部改掉即可,代码如下:

  1. package com.taopoppy.wiki.controller;
  2. import com.taopoppy.wiki.req.DocQueryReq;
  3. import com.taopoppy.wiki.req.DocSaveReq;
  4. import com.taopoppy.wiki.resp.DocQueryResp;
  5. import com.taopoppy.wiki.resp.CommonResp;
  6. import com.taopoppy.wiki.resp.PageResp;
  7. import com.taopoppy.wiki.service.DocService;
  8. import org.springframework.web.bind.annotation.*;
  9. import javax.annotation.Resource;
  10. import javax.validation.Valid;
  11. import java.util.List;
  12. @RestController
  13. @RequestMapping("/doc")
  14. public class DocController {
  15. @Resource
  16. private DocService docService;
  17. @GetMapping("/all")
  18. public CommonResp all(@Valid DocQueryReq req) {
  19. // controller层尽量不要出现Doc这个实体类
  20. // 因为实体类是和数据库一一对应的
  21. CommonResp<List<DocQueryResp>> resp = new CommonResp<>();
  22. List<DocQueryResp> list = docService.all(req);
  23. resp.setContent(list);
  24. return resp;
  25. }
  26. @GetMapping("/list")
  27. public CommonResp list(@Valid DocQueryReq req) {
  28. // controller层尽量不要出现Doc这个实体类
  29. // 因为实体类是和数据库一一对应的
  30. CommonResp<PageResp<DocQueryResp>> resp = new CommonResp<>();
  31. PageResp<DocQueryResp> list = docService.list(req);
  32. resp.setContent(list);
  33. return resp;
  34. }
  35. @PostMapping("/save")
  36. public CommonResp save(@Valid @RequestBody DocSaveReq req) {
  37. CommonResp resp = new CommonResp<>();
  38. docService.save(req);
  39. return resp;
  40. }
  41. @DeleteMapping("/delete/{id}")
  42. public CommonResp delete(@PathVariable long id) {
  43. CommonResp resp = new CommonResp<>();
  44. docService.delete(id);
  45. return resp;
  46. }
  47. }
  1. package com.taopoppy.wiki.service;
  2. import com.github.pagehelper.PageHelper;
  3. import com.github.pagehelper.PageInfo;
  4. import com.taopoppy.wiki.domain.Doc;
  5. import com.taopoppy.wiki.domain.DocExample;
  6. import com.taopoppy.wiki.mapper.DocMapper;
  7. import com.taopoppy.wiki.req.DocQueryReq;
  8. import com.taopoppy.wiki.req.DocSaveReq;
  9. import com.taopoppy.wiki.resp.DocQueryResp;
  10. import com.taopoppy.wiki.resp.PageResp;
  11. import com.taopoppy.wiki.util.CopyUtil;
  12. import com.taopoppy.wiki.util.SnowFlake;
  13. import org.slf4j.Logger;
  14. import org.slf4j.LoggerFactory;
  15. import org.springframework.stereotype.Service;
  16. import org.springframework.util.ObjectUtils;
  17. import javax.annotation.Resource;
  18. import java.util.List;
  19. @Service
  20. public class DocService {
  21. // 这个就是用来打印的,在WikiApplication当中也用到了
  22. private static final Logger LOG = LoggerFactory.getLogger(DocService.class);
  23. @Resource
  24. private DocMapper docMapper;
  25. @Resource
  26. private SnowFlake snowFlake;
  27. /**
  28. * 查询分类
  29. * @param req 分类查询参数
  30. * @return 分类分页列表
  31. */
  32. public List<DocQueryResp> all(DocQueryReq req) {
  33. DocExample docExample = new DocExample();
  34. docExample.setOrderByClause("sort asc"); // 按照sort排序
  35. List<Doc> docsList = docMapper.selectByExample(docExample);
  36. List<DocQueryResp> respList = CopyUtil.copyList(docsList, DocQueryResp.class);
  37. return respList;
  38. }
  39. /**
  40. * 查询分类
  41. * @param req 分类查询参数
  42. * @return 分类分页列表
  43. */
  44. public PageResp<DocQueryResp> list(DocQueryReq req) {
  45. DocExample docExample = new DocExample();
  46. docExample.setOrderByClause("sort asc"); // 按照sort排序
  47. // createCriteria相当于where条件
  48. DocExample.Criteria criteria = docExample.createCriteria();
  49. // 根据docExample条件查询
  50. PageHelper.startPage(req.getPage(), req.getSize());
  51. List<Doc> docsList = docMapper.selectByExample(docExample);
  52. PageInfo<Doc> pageInfo = new PageInfo<>(docsList);
  53. LOG.info("总行数:{}",pageInfo.getTotal()); // 总行数
  54. LOG.info("总页数:{}",pageInfo.getPages()); // 总页数,一般不需要,前端会根据总数自动计算总页数
  55. List<DocQueryResp> respList = CopyUtil.copyList(docsList, DocQueryResp.class);
  56. PageResp<DocQueryResp> pageResp = new PageResp();
  57. pageResp.setTotal(pageInfo.getTotal());
  58. pageResp.setList(respList);
  59. return pageResp;
  60. }
  61. /**
  62. * 分类保存接口(保存包括编辑保存->更新, 也包括新增保存->新增, 根据req是否有id来判断)
  63. * @param req 分类保存参数
  64. */
  65. public void save(DocSaveReq req) {
  66. Doc doc = CopyUtil.copy(req, Doc.class);
  67. if(ObjectUtils.isEmpty(req.getId())) {
  68. // 新增
  69. doc.setId(snowFlake.nextId());
  70. docMapper.insert(doc);
  71. } else {
  72. // 更新
  73. // 因为updateByPrimaryKey传递的Doc类型的参数,所以需要将DocSaveReq 转化成Doc
  74. docMapper.updateByPrimaryKey(doc);
  75. }
  76. }
  77. /**
  78. * 分类删除接口(按照id进行删除)
  79. * @param id
  80. */
  81. public void delete(long id) {
  82. docMapper.deleteByPrimaryKey(id);
  83. }
  84. }
  1. package com.taopoppy.wiki.req;
  2. public class DocQueryReq extends PageReq{
  3. @Override
  4. public String toString() {
  5. return "DocQueryReq{} " + super.toString();
  6. }
  7. }
  1. package com.taopoppy.wiki.req;
  2. import javax.validation.constraints.NotNull;
  3. public class DocSaveReq {
  4. private Long id;
  5. @NotNull(message = "【电子书】不能为空")
  6. private Long ebookId;
  7. @NotNull(message = "【父文档】不能为空")
  8. private Long parent;
  9. @NotNull(message = "【名称】不能为空")
  10. private String name;
  11. @NotNull(message = "【排序】不能为空")
  12. private Integer sort;
  13. private Integer viewCount;
  14. private Integer voteCount;
  15. public Long getId() {
  16. return id;
  17. }
  18. public void setId(Long id) {
  19. this.id = id;
  20. }
  21. public Long getEbookId() {
  22. return ebookId;
  23. }
  24. public void setEbookId(Long ebookId) {
  25. this.ebookId = ebookId;
  26. }
  27. public Long getParent() {
  28. return parent;
  29. }
  30. public void setParent(Long parent) {
  31. this.parent = parent;
  32. }
  33. public String getName() {
  34. return name;
  35. }
  36. public void setName(String name) {
  37. this.name = name;
  38. }
  39. public Integer getSort() {
  40. return sort;
  41. }
  42. public void setSort(Integer sort) {
  43. this.sort = sort;
  44. }
  45. public Integer getViewCount() {
  46. return viewCount;
  47. }
  48. public void setViewCount(Integer viewCount) {
  49. this.viewCount = viewCount;
  50. }
  51. public Integer getVoteCount() {
  52. return voteCount;
  53. }
  54. public void setVoteCount(Integer voteCount) {
  55. this.voteCount = voteCount;
  56. }
  57. @Override
  58. public String toString() {
  59. StringBuilder sb = new StringBuilder();
  60. sb.append(getClass().getSimpleName());
  61. sb.append(" [");
  62. sb.append("Hash = ").append(hashCode());
  63. sb.append(", id=").append(id);
  64. sb.append(", ebookId=").append(ebookId);
  65. sb.append(", parent=").append(parent);
  66. sb.append(", name=").append(name);
  67. sb.append(", sort=").append(sort);
  68. sb.append(", viewCount=").append(viewCount);
  69. sb.append(", voteCount=").append(voteCount);
  70. sb.append("]");
  71. return sb.toString();
  72. }
  73. }
  1. package com.taopoppy.wiki.resp;
  2. public class DocQueryResp {
  3. private Long id;
  4. private Long ebookId;
  5. private Long parent;
  6. private String name;
  7. private Integer sort;
  8. private Integer viewCount;
  9. private Integer voteCount;
  10. public Long getId() {
  11. return id;
  12. }
  13. public void setId(Long id) {
  14. this.id = id;
  15. }
  16. public Long getEbookId() {
  17. return ebookId;
  18. }
  19. public void setEbookId(Long ebookId) {
  20. this.ebookId = ebookId;
  21. }
  22. public Long getParent() {
  23. return parent;
  24. }
  25. public void setParent(Long parent) {
  26. this.parent = parent;
  27. }
  28. public String getName() {
  29. return name;
  30. }
  31. public void setName(String name) {
  32. this.name = name;
  33. }
  34. public Integer getSort() {
  35. return sort;
  36. }
  37. public void setSort(Integer sort) {
  38. this.sort = sort;
  39. }
  40. public Integer getViewCount() {
  41. return viewCount;
  42. }
  43. public void setViewCount(Integer viewCount) {
  44. this.viewCount = viewCount;
  45. }
  46. public Integer getVoteCount() {
  47. return voteCount;
  48. }
  49. public void setVoteCount(Integer voteCount) {
  50. this.voteCount = voteCount;
  51. }
  52. @Override
  53. public String toString() {
  54. StringBuilder sb = new StringBuilder();
  55. sb.append(getClass().getSimpleName());
  56. sb.append(" [");
  57. sb.append("Hash = ").append(hashCode());
  58. sb.append(", id=").append(id);
  59. sb.append(", ebookId=").append(ebookId);
  60. sb.append(", parent=").append(parent);
  61. sb.append(", name=").append(name);
  62. sb.append(", sort=").append(sort);
  63. sb.append(", viewCount=").append(viewCount);
  64. sb.append(", voteCount=").append(voteCount);
  65. sb.append("]");
  66. return sb.toString();
  67. }
  68. }

2. 前端代码改造

  1. <template>
  2. <a-layout>
  3. <a-layout-content :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }">
  4. <h3 v-if="level1.length === 0">对不起,找不到相关文档!</h3>
  5. <a-row>
  6. <a-col :span="6">
  7. <a-tree
  8. v-if="level1.length > 0"
  9. :tree-data="level1"
  10. @select="onSelect"
  11. :replaceFields="{title: 'name', key: 'id', value: 'id'}"
  12. :defaultExpandAll="true"
  13. :defaultSelectedKeys="defaultSelectedKeys"
  14. >
  15. </a-tree>
  16. </a-col>
  17. <a-col :span="18">
  18. <div>
  19. <h2>{{doc.name}}</h2>
  20. <div>
  21. <span>阅读数:{{doc.viewCount}}</span> &nbsp; &nbsp;
  22. <span>点赞数:{{doc.voteCount}}</span>
  23. </div>
  24. <a-divider style="height: 2px; background-color: #9999cc"/>
  25. </div>
  26. <div class="wangeditor" :innerHTML="html"></div>
  27. <div class="vote-div">
  28. <a-button type="primary" shape="round" :size="'large'" @click="vote">
  29. <template #icon><LikeOutlined /> &nbsp;点赞:{{doc.voteCount}} </template>
  30. </a-button>
  31. </div>
  32. </a-col>
  33. </a-row>
  34. </a-layout-content>
  35. </a-layout>
  36. </template>
  37. <script lang="ts">
  38. import { defineComponent, onMounted, ref, createVNode } from 'vue';
  39. import axios from 'axios';
  40. import {message} from 'ant-design-vue';
  41. import {Tool} from "@/util/tool";
  42. import {useRoute} from "vue-router";
  43. export default defineComponent({
  44. name: 'Doc',
  45. setup() {
  46. const route = useRoute();
  47. const docs = ref();
  48. const html = ref();
  49. const defaultSelectedKeys = ref();
  50. defaultSelectedKeys.value = [];
  51. // 当前选中的文档
  52. const doc = ref();
  53. doc.value = {};
  54. /**
  55. * 一级文档树,children属性就是二级文档
  56. * [{
  57. * id: "",
  58. * name: "",
  59. * children: [{
  60. * id: "",
  61. * name: "",
  62. * }]
  63. * }]
  64. */
  65. const level1 = ref(); // 一级文档树,children属性就是二级文档
  66. level1.value = [];
  67. /**
  68. * 内容查询
  69. **/
  70. const handleQueryContent = (id: number) => {
  71. axios.get("/doc/find-content/" + id).then((response) => {
  72. const data = response.data;
  73. if (data.success) {
  74. html.value = data.content;
  75. } else {
  76. message.error(data.message);
  77. }
  78. });
  79. };
  80. /**
  81. * 数据查询
  82. **/
  83. const handleQuery = () => {
  84. axios.get("/doc/all/" + route.query.ebookId).then((response) => {
  85. const data = response.data;
  86. if (data.success) {
  87. docs.value = data.content;
  88. level1.value = [];
  89. level1.value = Tool.array2Tree(docs.value, 0);
  90. if (Tool.isNotEmpty(level1)) {
  91. defaultSelectedKeys.value = [level1.value[0].id];
  92. handleQueryContent(level1.value[0].id);
  93. // 初始显示文档信息
  94. doc.value = level1.value[0];
  95. }
  96. } else {
  97. message.error(data.message);
  98. }
  99. });
  100. };
  101. const onSelect = (selectedKeys: any, info: any) => {
  102. console.log('selected', selectedKeys, info);
  103. if (Tool.isNotEmpty(selectedKeys)) {
  104. // 选中某一节点时,加载该节点的文档信息
  105. doc.value = info.selectedNodes[0].props;
  106. // 加载内容
  107. handleQueryContent(selectedKeys[0]);
  108. }
  109. };
  110. // 点赞
  111. const vote = () => {
  112. axios.get('/doc/vote/' + doc.value.id).then((response) => {
  113. const data = response.data;
  114. if (data.success) {
  115. doc.value.voteCount++;
  116. } else {
  117. message.error(data.message);
  118. }
  119. });
  120. };
  121. onMounted(() => {
  122. handleQuery();
  123. });
  124. return {
  125. level1,
  126. html,
  127. onSelect,
  128. defaultSelectedKeys,
  129. doc,
  130. vote
  131. }
  132. }
  133. });
  134. </script>
  135. <style>
  136. /* wangeditor默认样式, 参照: http://www.wangeditor.com/doc/pages/02-%E5%86%85%E5%AE%B9%E5%A4%84%E7%90%86/03-%E8%8E%B7%E5%8F%96html.html */
  137. /* table 样式 */
  138. .wangeditor table {
  139. border-top: 1px solid #ccc;
  140. border-left: 1px solid #ccc;
  141. }
  142. .wangeditor table td,
  143. .wangeditor table th {
  144. border-bottom: 1px solid #ccc;
  145. border-right: 1px solid #ccc;
  146. padding: 3px 5px;
  147. }
  148. .wangeditor table th {
  149. border-bottom: 2px solid #ccc;
  150. text-align: center;
  151. }
  152. /* blockquote 样式 */
  153. .wangeditor blockquote {
  154. display: block;
  155. border-left: 8px solid #d0e5f2;
  156. padding: 5px 10px;
  157. margin: 10px 0;
  158. line-height: 1.4;
  159. font-size: 100%;
  160. background-color: #f1f1f1;
  161. }
  162. /* code 样式 */
  163. .wangeditor code {
  164. display: inline-block;
  165. *display: inline;
  166. *zoom: 1;
  167. background-color: #f1f1f1;
  168. border-radius: 3px;
  169. padding: 3px 5px;
  170. margin: 0 3px;
  171. }
  172. .wangeditor pre code {
  173. display: block;
  174. }
  175. /* ul ol 样式 */
  176. .wangeditor ul, ol {
  177. margin: 10px 0 10px 20px;
  178. }
  179. /* 和antdv p冲突,覆盖掉 */
  180. .wangeditor blockquote p {
  181. font-family:"YouYuan";
  182. margin: 20px 10px !important;
  183. font-size: 16px !important;
  184. font-weight:600;
  185. }
  186. /* 点赞 */
  187. .vote-div {
  188. padding: 15px;
  189. text-align: center;
  190. }
  191. /* 图片自适应 */
  192. .wangeditor img {
  193. max-width: 100%;
  194. height: auto;
  195. }
  196. /* 视频自适应 */
  197. .wangeditor iframe {
  198. width: 100%;
  199. height: 400px;
  200. }
  201. </style>
  1. <template>
  2. <a-layout>
  3. <a-layout-content
  4. :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
  5. >
  6. <a-row :gutter="24">
  7. <a-col :span="8">
  8. <p>
  9. <a-form layout="inline" :model="param">
  10. <a-form-item>
  11. <a-button type="primary" @click="handleQuery()">
  12. 查询
  13. </a-button>
  14. </a-form-item>
  15. <a-form-item>
  16. <a-button type="primary" @click="add()">
  17. 新增
  18. </a-button>
  19. </a-form-item>
  20. </a-form>
  21. </p>
  22. <a-table
  23. v-if="level1.length > 0"
  24. :columns="columns"
  25. :row-key="record => record.id"
  26. :data-source="level1"
  27. :loading="loading"
  28. :pagination="false"
  29. size="small"
  30. :defaultExpandAllRows="true"
  31. >
  32. <template #name="{ text, record }">
  33. {{record.sort}} {{text}}
  34. </template>
  35. <template v-slot:action="{ text, record }">
  36. <a-space size="small">
  37. <a-button type="primary" @click="edit(record)" size="small">
  38. 编辑
  39. </a-button>
  40. <a-popconfirm
  41. title="删除后不可恢复,确认删除?"
  42. ok-text="是"
  43. cancel-text="否"
  44. @confirm="handleDelete(record.id)"
  45. >
  46. <a-button type="danger" size="small">
  47. 删除
  48. </a-button>
  49. </a-popconfirm>
  50. </a-space>
  51. </template>
  52. </a-table>
  53. </a-col>
  54. <a-col :span="16">
  55. <p>
  56. <a-form layout="inline" :model="param">
  57. <a-form-item>
  58. <a-button type="primary" @click="handleSave()">
  59. 保存
  60. </a-button>
  61. </a-form-item>
  62. </a-form>
  63. </p>
  64. <a-form :model="doc" layout="vertical">
  65. <a-form-item>
  66. <a-input v-model:value="doc.name" placeholder="名称"/>
  67. </a-form-item>
  68. <a-form-item>
  69. <a-tree-select
  70. v-model:value="doc.parent"
  71. style="width: 100%"
  72. :dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
  73. :tree-data="treeSelectData"
  74. placeholder="请选择父文档"
  75. tree-default-expand-all
  76. :replaceFields="{title: 'name', key: 'id', value: 'id'}"
  77. >
  78. </a-tree-select>
  79. </a-form-item>
  80. <a-form-item>
  81. <a-input v-model:value="doc.sort" placeholder="顺序"/>
  82. </a-form-item>
  83. <a-form-item>
  84. <a-button type="primary" @click="handlePreviewContent()">
  85. <EyeOutlined /> 内容预览
  86. </a-button>
  87. </a-form-item>
  88. <a-form-item>
  89. <div id="content"></div>
  90. </a-form-item>
  91. </a-form>
  92. </a-col>
  93. </a-row>
  94. <a-drawer width="900" placement="right" :closable="false" :visible="drawerVisible" @close="onDrawerClose">
  95. <div class="wangeditor" :innerHTML="previewHtml"></div>
  96. </a-drawer>
  97. </a-layout-content>
  98. </a-layout>
  99. <!--<a-modal-->
  100. <!-- title="文档表单"-->
  101. <!-- v-model:visible="modalVisible"-->
  102. <!-- :confirm-loading="modalLoading"-->
  103. <!-- @ok="handleModalOk"-->
  104. <!--&gt;-->
  105. <!-- -->
  106. <!--</a-modal>-->
  107. </template>
  108. <script lang="ts">
  109. import { defineComponent, onMounted, ref, createVNode } from 'vue';
  110. import axios from 'axios';
  111. import {message, Modal} from 'ant-design-vue';
  112. import {Tool} from "@/util/tool";
  113. import {useRoute} from "vue-router";
  114. import ExclamationCircleOutlined from "@ant-design/icons-vue/ExclamationCircleOutlined";
  115. import E from 'wangeditor'
  116. export default defineComponent({
  117. name: 'AdminDoc',
  118. setup() {
  119. const route = useRoute();
  120. console.log("路由:", route);
  121. console.log("route.path:", route.path);
  122. console.log("route.query:", route.query);
  123. console.log("route.param:", route.params);
  124. console.log("route.fullPath:", route.fullPath);
  125. console.log("route.name:", route.name);
  126. console.log("route.meta:", route.meta);
  127. const param = ref();
  128. param.value = {};
  129. const docs = ref();
  130. const loading = ref(false);
  131. // 因为树选择组件的属性状态,会随当前编辑的节点而变化,所以单独声明一个响应式变量
  132. const treeSelectData = ref();
  133. treeSelectData.value = [];
  134. const columns = [
  135. {
  136. title: '名称',
  137. dataIndex: 'name',
  138. slots: { customRender: 'name' }
  139. },
  140. {
  141. title: 'Action',
  142. key: 'action',
  143. slots: { customRender: 'action' }
  144. }
  145. ];
  146. /**
  147. * 一级文档树,children属性就是二级文档
  148. * [{
  149. * id: "",
  150. * name: "",
  151. * children: [{
  152. * id: "",
  153. * name: "",
  154. * }]
  155. * }]
  156. */
  157. const level1 = ref(); // 一级文档树,children属性就是二级文档
  158. level1.value = [];
  159. /**
  160. * 数据查询
  161. **/
  162. const handleQuery = () => {
  163. loading.value = true;
  164. // 如果不清空现有数据,则编辑保存重新加载数据后,再点编辑,则列表显示的还是编辑前的数据
  165. level1.value = [];
  166. axios.get("/doc/all/" + route.query.ebookId).then((response) => {
  167. loading.value = false;
  168. const data = response.data;
  169. if (data.success) {
  170. docs.value = data.content;
  171. console.log("原始数组:", docs.value);
  172. level1.value = [];
  173. level1.value = Tool.array2Tree(docs.value, 0);
  174. console.log("树形结构:", level1);
  175. // 父文档下拉框初始化,相当于点击新增
  176. treeSelectData.value = Tool.copy(level1.value) || [];
  177. // 为选择树添加一个"无"
  178. treeSelectData.value.unshift({id: 0, name: '无'});
  179. } else {
  180. message.error(data.message);
  181. }
  182. });
  183. };
  184. // -------- 表单 ---------
  185. const doc = ref();
  186. doc.value = {
  187. ebookId: route.query.ebookId
  188. };
  189. const modalVisible = ref(false);
  190. const modalLoading = ref(false);
  191. const editor = new E('#content');
  192. editor.config.zIndex = 0;
  193. // 显示上传图片按钮,转成Base64存储,同时也支持拖拽图片
  194. // 上传图片文档:https://doc.wangeditor.com/pages/07-%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87/01-%E9%85%8D%E7%BD%AE%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8E%A5%E5%8F%A3.html
  195. // 上传视频文档:https://doc.wangeditor.com/pages/07-%E4%B8%8A%E4%BC%A0%E8%A7%86%E9%A2%91/01-%E9%85%8D%E7%BD%AE%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8E%A5%E5%8F%A3.html
  196. editor.config.uploadImgShowBase64 = true;
  197. const handleSave = () => {
  198. modalLoading.value = true;
  199. doc.value.content = editor.txt.html();
  200. axios.post("/doc/save", doc.value).then((response) => {
  201. modalLoading.value = false;
  202. const data = response.data; // data = commonResp
  203. if (data.success) {
  204. // modalVisible.value = false;
  205. message.success("保存成功!");
  206. // 重新加载列表
  207. handleQuery();
  208. } else {
  209. message.error(data.message);
  210. }
  211. });
  212. };
  213. /**
  214. * 将某节点及其子孙节点全部置为disabled
  215. */
  216. const setDisable = (treeSelectData: any, id: any) => {
  217. // console.log(treeSelectData, id);
  218. // 遍历数组,即遍历某一层节点
  219. for (let i = 0; i < treeSelectData.length; i++) {
  220. const node = treeSelectData[i];
  221. if (node.id === id) {
  222. // 如果当前节点就是目标节点
  223. console.log("disabled", node);
  224. // 将目标节点设置为disabled
  225. node.disabled = true;
  226. // 遍历所有子节点,将所有子节点全部都加上disabled
  227. const children = node.children;
  228. if (Tool.isNotEmpty(children)) {
  229. for (let j = 0; j < children.length; j++) {
  230. setDisable(children, children[j].id)
  231. }
  232. }
  233. } else {
  234. // 如果当前节点不是目标节点,则到其子节点再找找看。
  235. const children = node.children;
  236. if (Tool.isNotEmpty(children)) {
  237. setDisable(children, id);
  238. }
  239. }
  240. }
  241. };
  242. const deleteIds: Array<string> = [];
  243. const deleteNames: Array<string> = [];
  244. /**
  245. * 查找整根树枝
  246. */
  247. const getDeleteIds = (treeSelectData: any, id: any) => {
  248. // console.log(treeSelectData, id);
  249. // 遍历数组,即遍历某一层节点
  250. for (let i = 0; i < treeSelectData.length; i++) {
  251. const node = treeSelectData[i];
  252. if (node.id === id) {
  253. // 如果当前节点就是目标节点
  254. console.log("delete", node);
  255. // 将目标ID放入结果集ids
  256. // node.disabled = true;
  257. deleteIds.push(id);
  258. deleteNames.push(node.name);
  259. // 遍历所有子节点
  260. const children = node.children;
  261. if (Tool.isNotEmpty(children)) {
  262. for (let j = 0; j < children.length; j++) {
  263. getDeleteIds(children, children[j].id)
  264. }
  265. }
  266. } else {
  267. // 如果当前节点不是目标节点,则到其子节点再找找看。
  268. const children = node.children;
  269. if (Tool.isNotEmpty(children)) {
  270. getDeleteIds(children, id);
  271. }
  272. }
  273. }
  274. };
  275. /**
  276. * 内容查询
  277. **/
  278. const handleQueryContent = () => {
  279. axios.get("/doc/find-content/" + doc.value.id).then((response) => {
  280. const data = response.data;
  281. if (data.success) {
  282. editor.txt.html(data.content)
  283. } else {
  284. message.error(data.message);
  285. }
  286. });
  287. };
  288. /**
  289. * 编辑
  290. */
  291. const edit = (record: any) => {
  292. // 清空富文本框
  293. editor.txt.html("");
  294. modalVisible.value = true;
  295. doc.value = Tool.copy(record);
  296. handleQueryContent();
  297. // 不能选择当前节点及其所有子孙节点,作为父节点,会使树断开
  298. treeSelectData.value = Tool.copy(level1.value);
  299. setDisable(treeSelectData.value, record.id);
  300. // 为选择树添加一个"无"
  301. treeSelectData.value.unshift({id: 0, name: '无'});
  302. };
  303. /**
  304. * 新增
  305. */
  306. const add = () => {
  307. // 清空富文本框
  308. editor.txt.html("");
  309. modalVisible.value = true;
  310. doc.value = {
  311. ebookId: route.query.ebookId
  312. };
  313. treeSelectData.value = Tool.copy(level1.value) || [];
  314. // 为选择树添加一个"无"
  315. treeSelectData.value.unshift({id: 0, name: '无'});
  316. };
  317. const handleDelete = (id: number) => {
  318. // console.log(level1, level1.value, id)
  319. // 清空数组,否则多次删除时,数组会一直增加
  320. deleteIds.length = 0;
  321. deleteNames.length = 0;
  322. getDeleteIds(level1.value, id);
  323. Modal.confirm({
  324. title: '重要提醒',
  325. icon: createVNode(ExclamationCircleOutlined),
  326. content: '将删除:【' + deleteNames.join(",") + "】删除后不可恢复,确认删除?",
  327. onOk() {
  328. // console.log(ids)
  329. axios.delete("/doc/delete/" + deleteIds.join(",")).then((response) => {
  330. const data = response.data; // data = commonResp
  331. if (data.success) {
  332. // 重新加载列表
  333. handleQuery();
  334. } else {
  335. message.error(data.message);
  336. }
  337. });
  338. },
  339. });
  340. };
  341. // ----------------富文本预览--------------
  342. const drawerVisible = ref(false);
  343. const previewHtml = ref();
  344. const handlePreviewContent = () => {
  345. const html = editor.txt.html();
  346. previewHtml.value = html;
  347. drawerVisible.value = true;
  348. };
  349. const onDrawerClose = () => {
  350. drawerVisible.value = false;
  351. };
  352. onMounted(() => {
  353. handleQuery();
  354. editor.create();
  355. });
  356. return {
  357. param,
  358. // docs,
  359. level1,
  360. columns,
  361. loading,
  362. handleQuery,
  363. edit,
  364. add,
  365. doc,
  366. modalVisible,
  367. modalLoading,
  368. handleSave,
  369. handleDelete,
  370. treeSelectData,
  371. drawerVisible,
  372. previewHtml,
  373. handlePreviewContent,
  374. onDrawerClose,
  375. }
  376. }
  377. });
  378. </script>
  379. <style scoped>
  380. img {
  381. width: 50px;
  382. height: 50px;
  383. }
  384. </style>

树形选择组件

因为是个无限极树形,然后我们需要使用a-tree-select组件去作为父节点的选择组件,前端代码在上面有。

Vue页面参数传递

在电子书管理页面点击【文档管理】,跳到文档管理页面时,带上当前电子书id参数ebookID,在admin-ebook当中有这么一段代码就是跳转路由并且携带ID:

  1. <router-link :to="'/admin/doc?ebookId=' + record.id">
  2. <a-button type="primary">
  3. 文档管理
  4. </a-button>
  5. </router-link>

在admin-doc.vue当中就可以通过route拿到信息:

  1. const route = useRoute();
  2. console.log("路由:", route);
  3. console.log("route.path:", route.path);
  4. console.log("route.query:", route.query); // admin/doc?ebookid=1
  5. console.log("route.param:", route.params);// admin/doc/1
  6. console.log("route.fullPath:", route.fullPath);
  7. console.log("route.name:", route.name);
  8. console.log("route.meta:", route.meta);
  9. setup() {
  10. axios.get("/doc/all/" + route.query.ebookId).then((response) => {});
  11. }

删除文档功能

删除某个文档的时候,其下所有的文档也应该删除,也就是删掉文档2的时候,下面的文档2.1、文档2.2、包括文档2.2.1都要删掉。

由于这个功能需要递归,所以我们需要将其书写在前端,这样可以减少服务器的压力,就是递归将所有的id拿出来,然后传个后端,让后端删除。

1. 后端的代码展示

前端传入后端的是类似于这样的字符串:”1,2,3,4,5”, 后端要将其转换成[1,2,3,4,5]这样的数组。

  1. @DeleteMapping("/delete/{idsStr}")
  2. public CommonResp delete(@PathVariable String idsStr) {
  3. CommonResp resp = new CommonResp<>();
  4. // String 转成List<Long>的集合
  5. List<String> list = Arrays.asList(idsStr.split(","));
  6. docService.delete(list);
  7. return resp;
  8. }
  1. /**
  2. * 分类删除接口(按照id进行删除)
  3. * @param ids
  4. */
  5. public void delete(List<String> ids) {
  6. DocExample docExample = new DocExample();
  7. DocExample.Criteria criteria = docExample.createCriteria();
  8. List<Long> result = new ArrayList<>();
  9. ids.forEach(item -> {
  10. result.add(Long.parseLong(item));
  11. });
  12. // 数组直接传入查询条件
  13. criteria.andIdIn(result);
  14. docMapper.deleteByExample(docExample);
  15. }

2. 前端代码展示

  1. const deleteIds: Array<string> = [];
  2. const deleteNames: Array<string> = [];
  3. /**
  4. * 查找整根树枝
  5. */
  6. const getDeleteIds = (treeSelectData: any, id: any) => {
  7. // console.log(treeSelectData, id);
  8. // 遍历数组,即遍历某一层节点
  9. for (let i = 0; i < treeSelectData.length; i++) {
  10. const node = treeSelectData[i];
  11. if (node.id === id) {
  12. // 如果当前节点就是目标节点
  13. console.log("delete", node);
  14. // 将目标ID放入结果集ids
  15. // node.disabled = true;
  16. deleteIds.push(id);
  17. deleteNames.push(node.name);
  18. // 遍历所有子节点
  19. const children = node.children;
  20. if (Tool.isNotEmpty(children)) {
  21. for (let j = 0; j < children.length; j++) {
  22. getDeleteIds(children, children[j].id)
  23. }
  24. }
  25. } else {
  26. // 如果当前节点不是目标节点,则到其子节点再找找看。
  27. const children = node.children;
  28. if (Tool.isNotEmpty(children)) {
  29. getDeleteIds(children, id);
  30. }
  31. }
  32. }
  33. };
  34. const handleDelete = (id: number) => {
  35. // console.log(level1, level1.value, id)
  36. // 清空数组,否则多次删除时,数组会一直增加
  37. deleteIds.length = 0;
  38. deleteNames.length = 0;
  39. getDeleteIds(level1.value, id);
  40. Modal.confirm({
  41. title: '重要提醒',
  42. icon: createVNode(ExclamationCircleOutlined),
  43. content: '将删除:【' + deleteNames.join(",") + "】删除后不可恢复,确认删除?",
  44. onOk() {
  45. // console.log(ids)
  46. axios.delete("/doc/delete/" + deleteIds.join(",")).then((response) => {
  47. const data = response.data; // data = commonResp
  48. if (data.success) {
  49. // 重新加载列表
  50. handleQuery();
  51. } else {
  52. message.error(data.message);
  53. }
  54. });
  55. },
  56. });
  57. };

集成富文本插件wangEditor

wangEditor是Typescript开发的富文本编辑器,官网地址是https://www.wangeditor.com/,这个比较重要,因为这个是开源免费的,使用下面的方式来下载:

  1. npm i wangeditor --save

下面我们将在admin-doc.vue当中相关的wangeditor的代码都粘出来:

  1. <template>
  2. <a-drawer width="900" placement="right" :closable="false" :visible="drawerVisible" @close="onDrawerClose">
  3. <div class="wangeditor" :innerHTML="previewHtml"></div>
  4. </a-drawer>
  5. </template>
  6. <script lang="ts">
  7. import E from 'wangeditor'
  8. export default defineComponent({
  9. name: 'AdminDoc',
  10. setup() {
  11. const editor = new E('#content');
  12. editor.config.zIndex = 0;
  13. // 显示上传图片按钮,转成Base64存储,同时也支持拖拽图片
  14. // 上传图片文档:https://doc.wangeditor.com/pages/07-%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87/01-%E9%85%8D%E7%BD%AE%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8E%A5%E5%8F%A3.html
  15. // 上传视频文档:https://doc.wangeditor.com/pages/07-%E4%B8%8A%E4%BC%A0%E8%A7%86%E9%A2%91/01-%E9%85%8D%E7%BD%AE%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8E%A5%E5%8F%A3.html
  16. editor.config.uploadImgShowBase64 = true;
  17. /**
  18. * 编辑
  19. */
  20. const edit = (record: any) => {
  21. // 清空富文本框
  22. editor.txt.html("");
  23. modalVisible.value = true;
  24. doc.value = Tool.copy(record);
  25. handleQueryContent();
  26. // 不能选择当前节点及其所有子孙节点,作为父节点,会使树断开
  27. treeSelectData.value = Tool.copy(level1.value);
  28. setDisable(treeSelectData.value, record.id);
  29. // 为选择树添加一个"无"
  30. treeSelectData.value.unshift({id: 0, name: '无'});
  31. };
  32. /**
  33. * 新增
  34. */
  35. const add = () => {
  36. // 清空富文本框
  37. editor.txt.html("");
  38. modalVisible.value = true;
  39. doc.value = {
  40. ebookId: route.query.ebookId
  41. };
  42. treeSelectData.value = Tool.copy(level1.value) || [];
  43. // 为选择树添加一个"无"
  44. treeSelectData.value.unshift({id: 0, name: '无'});
  45. };
  46. const handleDelete = (id: number) => {
  47. // console.log(level1, level1.value, id)
  48. // 清空数组,否则多次删除时,数组会一直增加
  49. deleteIds.length = 0;
  50. deleteNames.length = 0;
  51. getDeleteIds(level1.value, id);
  52. Modal.confirm({
  53. title: '重要提醒',
  54. icon: createVNode(ExclamationCircleOutlined),
  55. content: '将删除:【' + deleteNames.join(",") + "】删除后不可恢复,确认删除?",
  56. onOk() {
  57. // console.log(ids)
  58. axios.delete("/doc/delete/" + deleteIds.join(",")).then((response) => {
  59. const data = response.data; // data = commonResp
  60. if (data.success) {
  61. // 重新加载列表
  62. handleQuery();
  63. } else {
  64. message.error(data.message);
  65. }
  66. });
  67. },
  68. });
  69. };
  70. // ----------------富文本预览--------------
  71. const drawerVisible = ref(false);
  72. const previewHtml = ref();
  73. const handlePreviewContent = () => {
  74. const html = editor.txt.html();
  75. previewHtml.value = html;
  76. drawerVisible.value = true;
  77. };
  78. const onDrawerClose = () => {
  79. drawerVisible.value = false;
  80. };
  81. onMounted(() => {
  82. handleQuery();
  83. editor.create();
  84. });
  85. }
  86. });
  87. </script>
  88. <style scoped>
  89. img {
  90. width: 50px;
  91. height: 50px;
  92. }
  93. </style>

文档内容表设计与代码生成

1. 文档表设计

这个表的设计是这样,这个content原本是doc表当中的一个字段,但是由于内容比较大,如果从数据库一下查出来很多doc内容,那么content也会随之查询出来,这样数据库压力就很大,我们设计的content的id和doc的id是一样的。这种叫做纵向分表,如果是一月份一张表,二月份一张表,这个随着业务的增加,这个就是横向的分表

  1. drop table if exists `content`;
  2. create table `content` (
  3. `id` bigint not null comment '文档id',
  4. `content` mediumtext not null comment '内容',
  5. primary key (`id`)
  6. ) engine=innodb default charset=utf8mb4 comment='文档内容';

2. 生成持久层的代码

生成持久层代码,依旧去generator-config.xml,将最后一行的table改成doc,然后去执行mybatis-generator这个命令,去生成四个文件,依次是Content.java,DocExample.java,DocMapper.java,DocMapper.xml。

  1. <table tableName="content"/>

文档管理页面布局修改

之前编辑是使用了弹窗的形式,我们现在使用左右布局的形式,这种形式适合字段不多的列表,如果列表显示的字段非常多的话,就不适用了。 使用栅格区域,在ant design vue当中是Grid组件。

文档内容的保存与显示

文档的内容保存的是一个html的字符串,这个字符串可以以html的方式显示在富文本框当中。

1. 后端代码展示

(1)文档内容的保存

在保存我们编辑的文档的时候,文档的信息要保存在文档doc表中,而文档的文字内容要保存在content表当中,所以我们在保存的时候要分开保存这些字段:

  1. public class DocSaveReq {
  2. @NotNull(message = "【内容】不能为空")
  3. private String content;
  4. public String getContent() {
  5. return content;
  6. }
  7. public void setContent(String content) {
  8. this.content = content;
  9. }
  10. }
  1. public void save(DocSaveReq req) {
  2. Doc doc = CopyUtil.copy(req, Doc.class);
  3. Content content = CopyUtil.copy(req,Content.class);
  4. if(ObjectUtils.isEmpty(req.getId())) {
  5. // 新增文档doc
  6. doc.setId(snowFlake.nextId());
  7. docMapper.insert(doc);
  8. // 同时新增内容content(保证id和doc是一致的)
  9. content.setId(doc.getId());
  10. contentMapper.insert(content);
  11. } else {
  12. // 更新
  13. docMapper.updateByPrimaryKey(doc);
  14. // BLOB方法包含大字段的操作
  15. int count = contentMapper.updateByPrimaryKeyWithBLOBs(content);
  16. if(count == 0) {
  17. // 如果content当中没有该id的记录,就要插入
  18. contentMapper.insert(content);
  19. }
  20. }
  21. }
  • 特别要主要生成器当中有updateByPrimaryKeyWithBLOBs方法,BLOBs相关的方法都是包含修改大字段的方法,富文本生成的html内容就是大的字段,所以需要使用这个方法

    (4)文档内容的显示

    文档内容的显示需要增加单独获取内容的接口,前端得到html字符串后,放入富文本框当中 ```java

/**

  • 根据id去content表当中查找内容
  • @param id
  • @return */ public String findContent(Long id) { Content content = contentMapper.selectByPrimaryKey(id); return content.getContent(); } java

@GetMapping(“/find-content/{id}”) public CommonResp findContent(@PathVariable Long id) { CommonResp resp = new CommonResp<>(); String content = docService.findContent(id); resp.setContent(content); return resp; }

  1. <a name="VoNYJ"></a>
  2. ### 2. 前端代码的显示
  3. 前端代码我们前面已经给过完整展示。
  4. <a name="dli4E"></a>
  5. ## **✿**文档页面功能开发
  6. 关于文档页面开发我们还优化了很多东西,我们来看看
  7. <a name="lQ2aT"></a>
  8. ### 1. 后端代码展示
  9. 我们在查文档的时候,有个all接口没有写动态参数,我们不应该查所有的文档,我们只能查一个电子书下面的所有文档,所以我们需要修改一下all的接口:
  10. ```java
  11. @GetMapping("/all/{ebookId}")
  12. public CommonResp all(@PathVariable Long ebookId) {
  13. CommonResp<List<DocQueryResp>> resp = new CommonResp<>();
  14. List<DocQueryResp> list = docService.all(ebookId);
  15. resp.setContent(list);
  16. return resp;
  17. }
  1. public List<DocQueryResp> all(Long ebookId) {
  2. DocExample docExample = new DocExample();
  3. // 添加一个动态查询
  4. DocExample.Criteria criteria = docExample.createCriteria();
  5. criteria.andEbookIdEqualTo(ebookId);
  6. docExample.setOrderByClause("sort asc"); // 按照sort排序
  7. List<Doc> docsList = docMapper.selectByExample(docExample);
  8. List<DocQueryResp> respList = CopyUtil.copyList(docsList, DocQueryResp.class);
  9. return respList;
  10. }

另外我们前面书写的根据id查询content内容的时候,没有做非空判断:

  1. public String findContent(Long id) {
  2. Content content = contentMapper.selectByPrimaryKey(id);
  3. if(ObjectUtils.isEmpty(content)) {
  4. return "";
  5. } else {
  6. return content.getContent();
  7. }
  8. }

2. 前端代码展示

前端代码前面有完整的展示。