- 重点学习无限极树的管理功能设计&富文本编辑框的使用
- 文档表设计与代码生成
- 按照分类管理的代码,复制出一套文档树管理
- 关于无限极树的增删改查功能开发
- 文档内容保存于显示:富文本框的使用
- 首页点击某个电子书时,跳转到文档页面,显示文档树
- 点击某个文档时,加载文档内容
✿文档表设计与代码生成
1. 文档表设计
给文档表起名doc,然后sql如下:
drop table if exists `doc`;
create table `doc` (
`id` bigint not null comment 'id',
`ebook_id` bigint not null default 0 comment '电子书id',
`parent` bigint not null default 0 comment '父id',
`name` varchar(50) not null comment '名称',
`sort` int comment '排序',
`view_count` int default 0 comment '阅读数',
`vote_count` int default 0 comment '点赞数',
primary key (`id`)
) engine=innodb default charset=utf8mb4 comment='文档';
insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (1,1,0,'文档1',1,0,0);
insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (2,1,1,'文档1.1',1,0,0);
insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (3,1,0,'文档2',2,0,0);
insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (4,1,3,'文档2.1',1,0,0);
insert into `doc` (id, ebook_id, parent,name, sort, view_count, vote_count) values (5,1,3,'文档2.2',2,0,0);
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。
<table tableName="doc"/>
✿文档表增删改查
1. 后端改造
按照分类管理,复制出一套文档管理的代码,每个文件复制过来之后使用ctrl + R将关键字全部改掉即可,代码如下:
package com.taopoppy.wiki.controller;
import com.taopoppy.wiki.req.DocQueryReq;
import com.taopoppy.wiki.req.DocSaveReq;
import com.taopoppy.wiki.resp.DocQueryResp;
import com.taopoppy.wiki.resp.CommonResp;
import com.taopoppy.wiki.resp.PageResp;
import com.taopoppy.wiki.service.DocService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/doc")
public class DocController {
@Resource
private DocService docService;
@GetMapping("/all")
public CommonResp all(@Valid DocQueryReq req) {
// controller层尽量不要出现Doc这个实体类
// 因为实体类是和数据库一一对应的
CommonResp<List<DocQueryResp>> resp = new CommonResp<>();
List<DocQueryResp> list = docService.all(req);
resp.setContent(list);
return resp;
}
@GetMapping("/list")
public CommonResp list(@Valid DocQueryReq req) {
// controller层尽量不要出现Doc这个实体类
// 因为实体类是和数据库一一对应的
CommonResp<PageResp<DocQueryResp>> resp = new CommonResp<>();
PageResp<DocQueryResp> list = docService.list(req);
resp.setContent(list);
return resp;
}
@PostMapping("/save")
public CommonResp save(@Valid @RequestBody DocSaveReq req) {
CommonResp resp = new CommonResp<>();
docService.save(req);
return resp;
}
@DeleteMapping("/delete/{id}")
public CommonResp delete(@PathVariable long id) {
CommonResp resp = new CommonResp<>();
docService.delete(id);
return resp;
}
}
package com.taopoppy.wiki.service;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.taopoppy.wiki.domain.Doc;
import com.taopoppy.wiki.domain.DocExample;
import com.taopoppy.wiki.mapper.DocMapper;
import com.taopoppy.wiki.req.DocQueryReq;
import com.taopoppy.wiki.req.DocSaveReq;
import com.taopoppy.wiki.resp.DocQueryResp;
import com.taopoppy.wiki.resp.PageResp;
import com.taopoppy.wiki.util.CopyUtil;
import com.taopoppy.wiki.util.SnowFlake;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import javax.annotation.Resource;
import java.util.List;
@Service
public class DocService {
// 这个就是用来打印的,在WikiApplication当中也用到了
private static final Logger LOG = LoggerFactory.getLogger(DocService.class);
@Resource
private DocMapper docMapper;
@Resource
private SnowFlake snowFlake;
/**
* 查询分类
* @param req 分类查询参数
* @return 分类分页列表
*/
public List<DocQueryResp> all(DocQueryReq req) {
DocExample docExample = new DocExample();
docExample.setOrderByClause("sort asc"); // 按照sort排序
List<Doc> docsList = docMapper.selectByExample(docExample);
List<DocQueryResp> respList = CopyUtil.copyList(docsList, DocQueryResp.class);
return respList;
}
/**
* 查询分类
* @param req 分类查询参数
* @return 分类分页列表
*/
public PageResp<DocQueryResp> list(DocQueryReq req) {
DocExample docExample = new DocExample();
docExample.setOrderByClause("sort asc"); // 按照sort排序
// createCriteria相当于where条件
DocExample.Criteria criteria = docExample.createCriteria();
// 根据docExample条件查询
PageHelper.startPage(req.getPage(), req.getSize());
List<Doc> docsList = docMapper.selectByExample(docExample);
PageInfo<Doc> pageInfo = new PageInfo<>(docsList);
LOG.info("总行数:{}",pageInfo.getTotal()); // 总行数
LOG.info("总页数:{}",pageInfo.getPages()); // 总页数,一般不需要,前端会根据总数自动计算总页数
List<DocQueryResp> respList = CopyUtil.copyList(docsList, DocQueryResp.class);
PageResp<DocQueryResp> pageResp = new PageResp();
pageResp.setTotal(pageInfo.getTotal());
pageResp.setList(respList);
return pageResp;
}
/**
* 分类保存接口(保存包括编辑保存->更新, 也包括新增保存->新增, 根据req是否有id来判断)
* @param req 分类保存参数
*/
public void save(DocSaveReq req) {
Doc doc = CopyUtil.copy(req, Doc.class);
if(ObjectUtils.isEmpty(req.getId())) {
// 新增
doc.setId(snowFlake.nextId());
docMapper.insert(doc);
} else {
// 更新
// 因为updateByPrimaryKey传递的Doc类型的参数,所以需要将DocSaveReq 转化成Doc
docMapper.updateByPrimaryKey(doc);
}
}
/**
* 分类删除接口(按照id进行删除)
* @param id
*/
public void delete(long id) {
docMapper.deleteByPrimaryKey(id);
}
}
package com.taopoppy.wiki.req;
public class DocQueryReq extends PageReq{
@Override
public String toString() {
return "DocQueryReq{} " + super.toString();
}
}
package com.taopoppy.wiki.req;
import javax.validation.constraints.NotNull;
public class DocSaveReq {
private Long id;
@NotNull(message = "【电子书】不能为空")
private Long ebookId;
@NotNull(message = "【父文档】不能为空")
private Long parent;
@NotNull(message = "【名称】不能为空")
private String name;
@NotNull(message = "【排序】不能为空")
private Integer sort;
private Integer viewCount;
private Integer voteCount;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getEbookId() {
return ebookId;
}
public void setEbookId(Long ebookId) {
this.ebookId = ebookId;
}
public Long getParent() {
return parent;
}
public void setParent(Long parent) {
this.parent = parent;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getSort() {
return sort;
}
public void setSort(Integer sort) {
this.sort = sort;
}
public Integer getViewCount() {
return viewCount;
}
public void setViewCount(Integer viewCount) {
this.viewCount = viewCount;
}
public Integer getVoteCount() {
return voteCount;
}
public void setVoteCount(Integer voteCount) {
this.voteCount = voteCount;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(getClass().getSimpleName());
sb.append(" [");
sb.append("Hash = ").append(hashCode());
sb.append(", id=").append(id);
sb.append(", ebookId=").append(ebookId);
sb.append(", parent=").append(parent);
sb.append(", name=").append(name);
sb.append(", sort=").append(sort);
sb.append(", viewCount=").append(viewCount);
sb.append(", voteCount=").append(voteCount);
sb.append("]");
return sb.toString();
}
}
package com.taopoppy.wiki.resp;
public class DocQueryResp {
private Long id;
private Long ebookId;
private Long parent;
private String name;
private Integer sort;
private Integer viewCount;
private Integer voteCount;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getEbookId() {
return ebookId;
}
public void setEbookId(Long ebookId) {
this.ebookId = ebookId;
}
public Long getParent() {
return parent;
}
public void setParent(Long parent) {
this.parent = parent;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getSort() {
return sort;
}
public void setSort(Integer sort) {
this.sort = sort;
}
public Integer getViewCount() {
return viewCount;
}
public void setViewCount(Integer viewCount) {
this.viewCount = viewCount;
}
public Integer getVoteCount() {
return voteCount;
}
public void setVoteCount(Integer voteCount) {
this.voteCount = voteCount;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(getClass().getSimpleName());
sb.append(" [");
sb.append("Hash = ").append(hashCode());
sb.append(", id=").append(id);
sb.append(", ebookId=").append(ebookId);
sb.append(", parent=").append(parent);
sb.append(", name=").append(name);
sb.append(", sort=").append(sort);
sb.append(", viewCount=").append(viewCount);
sb.append(", voteCount=").append(voteCount);
sb.append("]");
return sb.toString();
}
}
2. 前端代码改造
<template>
<a-layout>
<a-layout-content :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }">
<h3 v-if="level1.length === 0">对不起,找不到相关文档!</h3>
<a-row>
<a-col :span="6">
<a-tree
v-if="level1.length > 0"
:tree-data="level1"
@select="onSelect"
:replaceFields="{title: 'name', key: 'id', value: 'id'}"
:defaultExpandAll="true"
:defaultSelectedKeys="defaultSelectedKeys"
>
</a-tree>
</a-col>
<a-col :span="18">
<div>
<h2>{{doc.name}}</h2>
<div>
<span>阅读数:{{doc.viewCount}}</span>
<span>点赞数:{{doc.voteCount}}</span>
</div>
<a-divider style="height: 2px; background-color: #9999cc"/>
</div>
<div class="wangeditor" :innerHTML="html"></div>
<div class="vote-div">
<a-button type="primary" shape="round" :size="'large'" @click="vote">
<template #icon><LikeOutlined /> 点赞:{{doc.voteCount}} </template>
</a-button>
</div>
</a-col>
</a-row>
</a-layout-content>
</a-layout>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, createVNode } from 'vue';
import axios from 'axios';
import {message} from 'ant-design-vue';
import {Tool} from "@/util/tool";
import {useRoute} from "vue-router";
export default defineComponent({
name: 'Doc',
setup() {
const route = useRoute();
const docs = ref();
const html = ref();
const defaultSelectedKeys = ref();
defaultSelectedKeys.value = [];
// 当前选中的文档
const doc = ref();
doc.value = {};
/**
* 一级文档树,children属性就是二级文档
* [{
* id: "",
* name: "",
* children: [{
* id: "",
* name: "",
* }]
* }]
*/
const level1 = ref(); // 一级文档树,children属性就是二级文档
level1.value = [];
/**
* 内容查询
**/
const handleQueryContent = (id: number) => {
axios.get("/doc/find-content/" + id).then((response) => {
const data = response.data;
if (data.success) {
html.value = data.content;
} else {
message.error(data.message);
}
});
};
/**
* 数据查询
**/
const handleQuery = () => {
axios.get("/doc/all/" + route.query.ebookId).then((response) => {
const data = response.data;
if (data.success) {
docs.value = data.content;
level1.value = [];
level1.value = Tool.array2Tree(docs.value, 0);
if (Tool.isNotEmpty(level1)) {
defaultSelectedKeys.value = [level1.value[0].id];
handleQueryContent(level1.value[0].id);
// 初始显示文档信息
doc.value = level1.value[0];
}
} else {
message.error(data.message);
}
});
};
const onSelect = (selectedKeys: any, info: any) => {
console.log('selected', selectedKeys, info);
if (Tool.isNotEmpty(selectedKeys)) {
// 选中某一节点时,加载该节点的文档信息
doc.value = info.selectedNodes[0].props;
// 加载内容
handleQueryContent(selectedKeys[0]);
}
};
// 点赞
const vote = () => {
axios.get('/doc/vote/' + doc.value.id).then((response) => {
const data = response.data;
if (data.success) {
doc.value.voteCount++;
} else {
message.error(data.message);
}
});
};
onMounted(() => {
handleQuery();
});
return {
level1,
html,
onSelect,
defaultSelectedKeys,
doc,
vote
}
}
});
</script>
<style>
/* 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 */
/* table 样式 */
.wangeditor table {
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
}
.wangeditor table td,
.wangeditor table th {
border-bottom: 1px solid #ccc;
border-right: 1px solid #ccc;
padding: 3px 5px;
}
.wangeditor table th {
border-bottom: 2px solid #ccc;
text-align: center;
}
/* blockquote 样式 */
.wangeditor blockquote {
display: block;
border-left: 8px solid #d0e5f2;
padding: 5px 10px;
margin: 10px 0;
line-height: 1.4;
font-size: 100%;
background-color: #f1f1f1;
}
/* code 样式 */
.wangeditor code {
display: inline-block;
*display: inline;
*zoom: 1;
background-color: #f1f1f1;
border-radius: 3px;
padding: 3px 5px;
margin: 0 3px;
}
.wangeditor pre code {
display: block;
}
/* ul ol 样式 */
.wangeditor ul, ol {
margin: 10px 0 10px 20px;
}
/* 和antdv p冲突,覆盖掉 */
.wangeditor blockquote p {
font-family:"YouYuan";
margin: 20px 10px !important;
font-size: 16px !important;
font-weight:600;
}
/* 点赞 */
.vote-div {
padding: 15px;
text-align: center;
}
/* 图片自适应 */
.wangeditor img {
max-width: 100%;
height: auto;
}
/* 视频自适应 */
.wangeditor iframe {
width: 100%;
height: 400px;
}
</style>
<template>
<a-layout>
<a-layout-content
:style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
>
<a-row :gutter="24">
<a-col :span="8">
<p>
<a-form layout="inline" :model="param">
<a-form-item>
<a-button type="primary" @click="handleQuery()">
查询
</a-button>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="add()">
新增
</a-button>
</a-form-item>
</a-form>
</p>
<a-table
v-if="level1.length > 0"
:columns="columns"
:row-key="record => record.id"
:data-source="level1"
:loading="loading"
:pagination="false"
size="small"
:defaultExpandAllRows="true"
>
<template #name="{ text, record }">
{{record.sort}} {{text}}
</template>
<template v-slot:action="{ text, record }">
<a-space size="small">
<a-button type="primary" @click="edit(record)" size="small">
编辑
</a-button>
<a-popconfirm
title="删除后不可恢复,确认删除?"
ok-text="是"
cancel-text="否"
@confirm="handleDelete(record.id)"
>
<a-button type="danger" size="small">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-col>
<a-col :span="16">
<p>
<a-form layout="inline" :model="param">
<a-form-item>
<a-button type="primary" @click="handleSave()">
保存
</a-button>
</a-form-item>
</a-form>
</p>
<a-form :model="doc" layout="vertical">
<a-form-item>
<a-input v-model:value="doc.name" placeholder="名称"/>
</a-form-item>
<a-form-item>
<a-tree-select
v-model:value="doc.parent"
style="width: 100%"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
:tree-data="treeSelectData"
placeholder="请选择父文档"
tree-default-expand-all
:replaceFields="{title: 'name', key: 'id', value: 'id'}"
>
</a-tree-select>
</a-form-item>
<a-form-item>
<a-input v-model:value="doc.sort" placeholder="顺序"/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handlePreviewContent()">
<EyeOutlined /> 内容预览
</a-button>
</a-form-item>
<a-form-item>
<div id="content"></div>
</a-form-item>
</a-form>
</a-col>
</a-row>
<a-drawer width="900" placement="right" :closable="false" :visible="drawerVisible" @close="onDrawerClose">
<div class="wangeditor" :innerHTML="previewHtml"></div>
</a-drawer>
</a-layout-content>
</a-layout>
<!--<a-modal-->
<!-- title="文档表单"-->
<!-- v-model:visible="modalVisible"-->
<!-- :confirm-loading="modalLoading"-->
<!-- @ok="handleModalOk"-->
<!-->-->
<!-- -->
<!--</a-modal>-->
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, createVNode } from 'vue';
import axios from 'axios';
import {message, Modal} from 'ant-design-vue';
import {Tool} from "@/util/tool";
import {useRoute} from "vue-router";
import ExclamationCircleOutlined from "@ant-design/icons-vue/ExclamationCircleOutlined";
import E from 'wangeditor'
export default defineComponent({
name: 'AdminDoc',
setup() {
const route = useRoute();
console.log("路由:", route);
console.log("route.path:", route.path);
console.log("route.query:", route.query);
console.log("route.param:", route.params);
console.log("route.fullPath:", route.fullPath);
console.log("route.name:", route.name);
console.log("route.meta:", route.meta);
const param = ref();
param.value = {};
const docs = ref();
const loading = ref(false);
// 因为树选择组件的属性状态,会随当前编辑的节点而变化,所以单独声明一个响应式变量
const treeSelectData = ref();
treeSelectData.value = [];
const columns = [
{
title: '名称',
dataIndex: 'name',
slots: { customRender: 'name' }
},
{
title: 'Action',
key: 'action',
slots: { customRender: 'action' }
}
];
/**
* 一级文档树,children属性就是二级文档
* [{
* id: "",
* name: "",
* children: [{
* id: "",
* name: "",
* }]
* }]
*/
const level1 = ref(); // 一级文档树,children属性就是二级文档
level1.value = [];
/**
* 数据查询
**/
const handleQuery = () => {
loading.value = true;
// 如果不清空现有数据,则编辑保存重新加载数据后,再点编辑,则列表显示的还是编辑前的数据
level1.value = [];
axios.get("/doc/all/" + route.query.ebookId).then((response) => {
loading.value = false;
const data = response.data;
if (data.success) {
docs.value = data.content;
console.log("原始数组:", docs.value);
level1.value = [];
level1.value = Tool.array2Tree(docs.value, 0);
console.log("树形结构:", level1);
// 父文档下拉框初始化,相当于点击新增
treeSelectData.value = Tool.copy(level1.value) || [];
// 为选择树添加一个"无"
treeSelectData.value.unshift({id: 0, name: '无'});
} else {
message.error(data.message);
}
});
};
// -------- 表单 ---------
const doc = ref();
doc.value = {
ebookId: route.query.ebookId
};
const modalVisible = ref(false);
const modalLoading = ref(false);
const editor = new E('#content');
editor.config.zIndex = 0;
// 显示上传图片按钮,转成Base64存储,同时也支持拖拽图片
// 上传图片文档: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
// 上传视频文档: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
editor.config.uploadImgShowBase64 = true;
const handleSave = () => {
modalLoading.value = true;
doc.value.content = editor.txt.html();
axios.post("/doc/save", doc.value).then((response) => {
modalLoading.value = false;
const data = response.data; // data = commonResp
if (data.success) {
// modalVisible.value = false;
message.success("保存成功!");
// 重新加载列表
handleQuery();
} else {
message.error(data.message);
}
});
};
/**
* 将某节点及其子孙节点全部置为disabled
*/
const setDisable = (treeSelectData: any, id: any) => {
// console.log(treeSelectData, id);
// 遍历数组,即遍历某一层节点
for (let i = 0; i < treeSelectData.length; i++) {
const node = treeSelectData[i];
if (node.id === id) {
// 如果当前节点就是目标节点
console.log("disabled", node);
// 将目标节点设置为disabled
node.disabled = true;
// 遍历所有子节点,将所有子节点全部都加上disabled
const children = node.children;
if (Tool.isNotEmpty(children)) {
for (let j = 0; j < children.length; j++) {
setDisable(children, children[j].id)
}
}
} else {
// 如果当前节点不是目标节点,则到其子节点再找找看。
const children = node.children;
if (Tool.isNotEmpty(children)) {
setDisable(children, id);
}
}
}
};
const deleteIds: Array<string> = [];
const deleteNames: Array<string> = [];
/**
* 查找整根树枝
*/
const getDeleteIds = (treeSelectData: any, id: any) => {
// console.log(treeSelectData, id);
// 遍历数组,即遍历某一层节点
for (let i = 0; i < treeSelectData.length; i++) {
const node = treeSelectData[i];
if (node.id === id) {
// 如果当前节点就是目标节点
console.log("delete", node);
// 将目标ID放入结果集ids
// node.disabled = true;
deleteIds.push(id);
deleteNames.push(node.name);
// 遍历所有子节点
const children = node.children;
if (Tool.isNotEmpty(children)) {
for (let j = 0; j < children.length; j++) {
getDeleteIds(children, children[j].id)
}
}
} else {
// 如果当前节点不是目标节点,则到其子节点再找找看。
const children = node.children;
if (Tool.isNotEmpty(children)) {
getDeleteIds(children, id);
}
}
}
};
/**
* 内容查询
**/
const handleQueryContent = () => {
axios.get("/doc/find-content/" + doc.value.id).then((response) => {
const data = response.data;
if (data.success) {
editor.txt.html(data.content)
} else {
message.error(data.message);
}
});
};
/**
* 编辑
*/
const edit = (record: any) => {
// 清空富文本框
editor.txt.html("");
modalVisible.value = true;
doc.value = Tool.copy(record);
handleQueryContent();
// 不能选择当前节点及其所有子孙节点,作为父节点,会使树断开
treeSelectData.value = Tool.copy(level1.value);
setDisable(treeSelectData.value, record.id);
// 为选择树添加一个"无"
treeSelectData.value.unshift({id: 0, name: '无'});
};
/**
* 新增
*/
const add = () => {
// 清空富文本框
editor.txt.html("");
modalVisible.value = true;
doc.value = {
ebookId: route.query.ebookId
};
treeSelectData.value = Tool.copy(level1.value) || [];
// 为选择树添加一个"无"
treeSelectData.value.unshift({id: 0, name: '无'});
};
const handleDelete = (id: number) => {
// console.log(level1, level1.value, id)
// 清空数组,否则多次删除时,数组会一直增加
deleteIds.length = 0;
deleteNames.length = 0;
getDeleteIds(level1.value, id);
Modal.confirm({
title: '重要提醒',
icon: createVNode(ExclamationCircleOutlined),
content: '将删除:【' + deleteNames.join(",") + "】删除后不可恢复,确认删除?",
onOk() {
// console.log(ids)
axios.delete("/doc/delete/" + deleteIds.join(",")).then((response) => {
const data = response.data; // data = commonResp
if (data.success) {
// 重新加载列表
handleQuery();
} else {
message.error(data.message);
}
});
},
});
};
// ----------------富文本预览--------------
const drawerVisible = ref(false);
const previewHtml = ref();
const handlePreviewContent = () => {
const html = editor.txt.html();
previewHtml.value = html;
drawerVisible.value = true;
};
const onDrawerClose = () => {
drawerVisible.value = false;
};
onMounted(() => {
handleQuery();
editor.create();
});
return {
param,
// docs,
level1,
columns,
loading,
handleQuery,
edit,
add,
doc,
modalVisible,
modalLoading,
handleSave,
handleDelete,
treeSelectData,
drawerVisible,
previewHtml,
handlePreviewContent,
onDrawerClose,
}
}
});
</script>
<style scoped>
img {
width: 50px;
height: 50px;
}
</style>
树形选择组件
因为是个无限极树形,然后我们需要使用a-tree-select组件去作为父节点的选择组件,前端代码在上面有。
Vue页面参数传递
在电子书管理页面点击【文档管理】,跳到文档管理页面时,带上当前电子书id参数ebookID,在admin-ebook当中有这么一段代码就是跳转路由并且携带ID:
<router-link :to="'/admin/doc?ebookId=' + record.id">
<a-button type="primary">
文档管理
</a-button>
</router-link>
在admin-doc.vue当中就可以通过route拿到信息:
const route = useRoute();
console.log("路由:", route);
console.log("route.path:", route.path);
console.log("route.query:", route.query); // admin/doc?ebookid=1
console.log("route.param:", route.params);// admin/doc/1
console.log("route.fullPath:", route.fullPath);
console.log("route.name:", route.name);
console.log("route.meta:", route.meta);
setup() {
axios.get("/doc/all/" + route.query.ebookId).then((response) => {});
}
✿删除文档功能
删除某个文档的时候,其下所有的文档也应该删除,也就是删掉文档2的时候,下面的文档2.1、文档2.2、包括文档2.2.1都要删掉。
由于这个功能需要递归,所以我们需要将其书写在前端,这样可以减少服务器的压力,就是递归将所有的id拿出来,然后传个后端,让后端删除。
1. 后端的代码展示
前端传入后端的是类似于这样的字符串:”1,2,3,4,5”, 后端要将其转换成[1,2,3,4,5]这样的数组。
@DeleteMapping("/delete/{idsStr}")
public CommonResp delete(@PathVariable String idsStr) {
CommonResp resp = new CommonResp<>();
// String 转成List<Long>的集合
List<String> list = Arrays.asList(idsStr.split(","));
docService.delete(list);
return resp;
}
/**
* 分类删除接口(按照id进行删除)
* @param ids
*/
public void delete(List<String> ids) {
DocExample docExample = new DocExample();
DocExample.Criteria criteria = docExample.createCriteria();
List<Long> result = new ArrayList<>();
ids.forEach(item -> {
result.add(Long.parseLong(item));
});
// 数组直接传入查询条件
criteria.andIdIn(result);
docMapper.deleteByExample(docExample);
}
2. 前端代码展示
const deleteIds: Array<string> = [];
const deleteNames: Array<string> = [];
/**
* 查找整根树枝
*/
const getDeleteIds = (treeSelectData: any, id: any) => {
// console.log(treeSelectData, id);
// 遍历数组,即遍历某一层节点
for (let i = 0; i < treeSelectData.length; i++) {
const node = treeSelectData[i];
if (node.id === id) {
// 如果当前节点就是目标节点
console.log("delete", node);
// 将目标ID放入结果集ids
// node.disabled = true;
deleteIds.push(id);
deleteNames.push(node.name);
// 遍历所有子节点
const children = node.children;
if (Tool.isNotEmpty(children)) {
for (let j = 0; j < children.length; j++) {
getDeleteIds(children, children[j].id)
}
}
} else {
// 如果当前节点不是目标节点,则到其子节点再找找看。
const children = node.children;
if (Tool.isNotEmpty(children)) {
getDeleteIds(children, id);
}
}
}
};
const handleDelete = (id: number) => {
// console.log(level1, level1.value, id)
// 清空数组,否则多次删除时,数组会一直增加
deleteIds.length = 0;
deleteNames.length = 0;
getDeleteIds(level1.value, id);
Modal.confirm({
title: '重要提醒',
icon: createVNode(ExclamationCircleOutlined),
content: '将删除:【' + deleteNames.join(",") + "】删除后不可恢复,确认删除?",
onOk() {
// console.log(ids)
axios.delete("/doc/delete/" + deleteIds.join(",")).then((response) => {
const data = response.data; // data = commonResp
if (data.success) {
// 重新加载列表
handleQuery();
} else {
message.error(data.message);
}
});
},
});
};
✿集成富文本插件wangEditor
wangEditor是Typescript开发的富文本编辑器,官网地址是https://www.wangeditor.com/,这个比较重要,因为这个是开源免费的,使用下面的方式来下载:
npm i wangeditor --save
下面我们将在admin-doc.vue当中相关的wangeditor的代码都粘出来:
<template>
<a-drawer width="900" placement="right" :closable="false" :visible="drawerVisible" @close="onDrawerClose">
<div class="wangeditor" :innerHTML="previewHtml"></div>
</a-drawer>
</template>
<script lang="ts">
import E from 'wangeditor'
export default defineComponent({
name: 'AdminDoc',
setup() {
const editor = new E('#content');
editor.config.zIndex = 0;
// 显示上传图片按钮,转成Base64存储,同时也支持拖拽图片
// 上传图片文档: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
// 上传视频文档: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
editor.config.uploadImgShowBase64 = true;
/**
* 编辑
*/
const edit = (record: any) => {
// 清空富文本框
editor.txt.html("");
modalVisible.value = true;
doc.value = Tool.copy(record);
handleQueryContent();
// 不能选择当前节点及其所有子孙节点,作为父节点,会使树断开
treeSelectData.value = Tool.copy(level1.value);
setDisable(treeSelectData.value, record.id);
// 为选择树添加一个"无"
treeSelectData.value.unshift({id: 0, name: '无'});
};
/**
* 新增
*/
const add = () => {
// 清空富文本框
editor.txt.html("");
modalVisible.value = true;
doc.value = {
ebookId: route.query.ebookId
};
treeSelectData.value = Tool.copy(level1.value) || [];
// 为选择树添加一个"无"
treeSelectData.value.unshift({id: 0, name: '无'});
};
const handleDelete = (id: number) => {
// console.log(level1, level1.value, id)
// 清空数组,否则多次删除时,数组会一直增加
deleteIds.length = 0;
deleteNames.length = 0;
getDeleteIds(level1.value, id);
Modal.confirm({
title: '重要提醒',
icon: createVNode(ExclamationCircleOutlined),
content: '将删除:【' + deleteNames.join(",") + "】删除后不可恢复,确认删除?",
onOk() {
// console.log(ids)
axios.delete("/doc/delete/" + deleteIds.join(",")).then((response) => {
const data = response.data; // data = commonResp
if (data.success) {
// 重新加载列表
handleQuery();
} else {
message.error(data.message);
}
});
},
});
};
// ----------------富文本预览--------------
const drawerVisible = ref(false);
const previewHtml = ref();
const handlePreviewContent = () => {
const html = editor.txt.html();
previewHtml.value = html;
drawerVisible.value = true;
};
const onDrawerClose = () => {
drawerVisible.value = false;
};
onMounted(() => {
handleQuery();
editor.create();
});
}
});
</script>
<style scoped>
img {
width: 50px;
height: 50px;
}
</style>
✿文档内容表设计与代码生成
1. 文档表设计
这个表的设计是这样,这个content原本是doc表当中的一个字段,但是由于内容比较大,如果从数据库一下查出来很多doc内容,那么content也会随之查询出来,这样数据库压力就很大,我们设计的content的id和doc的id是一样的。这种叫做纵向分表,如果是一月份一张表,二月份一张表,这个随着业务的增加,这个就是横向的分表
drop table if exists `content`;
create table `content` (
`id` bigint not null comment '文档id',
`content` mediumtext not null comment '内容',
primary key (`id`)
) engine=innodb default charset=utf8mb4 comment='文档内容';
2. 生成持久层的代码
生成持久层代码,依旧去generator-config.xml,将最后一行的table改成doc,然后去执行mybatis-generator这个命令,去生成四个文件,依次是Content.java,DocExample.java,DocMapper.java,DocMapper.xml。
<table tableName="content"/>
文档管理页面布局修改
之前编辑是使用了弹窗的形式,我们现在使用左右布局的形式,这种形式适合字段不多的列表,如果列表显示的字段非常多的话,就不适用了。 使用栅格区域,在ant design vue当中是Grid组件。
✿文档内容的保存与显示
文档的内容保存的是一个html的字符串,这个字符串可以以html的方式显示在富文本框当中。
1. 后端代码展示
(1)文档内容的保存
在保存我们编辑的文档的时候,文档的信息要保存在文档doc表中,而文档的文字内容要保存在content表当中,所以我们在保存的时候要分开保存这些字段:
public class DocSaveReq {
@NotNull(message = "【内容】不能为空")
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
public void save(DocSaveReq req) {
Doc doc = CopyUtil.copy(req, Doc.class);
Content content = CopyUtil.copy(req,Content.class);
if(ObjectUtils.isEmpty(req.getId())) {
// 新增文档doc
doc.setId(snowFlake.nextId());
docMapper.insert(doc);
// 同时新增内容content(保证id和doc是一致的)
content.setId(doc.getId());
contentMapper.insert(content);
} else {
// 更新
docMapper.updateByPrimaryKey(doc);
// BLOB方法包含大字段的操作
int count = contentMapper.updateByPrimaryKeyWithBLOBs(content);
if(count == 0) {
// 如果content当中没有该id的记录,就要插入
contentMapper.insert(content);
}
}
}
- 特别要主要生成器当中有updateByPrimaryKeyWithBLOBs方法,BLOBs相关的方法都是包含修改大字段的方法,富文本生成的html内容就是大的字段,所以需要使用这个方法。
(4)文档内容的显示
文档内容的显示需要增加单独获取内容的接口,前端得到html字符串后,放入富文本框当中 ```java
/**
- 根据id去content表当中查找内容
- @param id
- @return
*/
public String findContent(Long id) {
Content content = contentMapper.selectByPrimaryKey(id);
return content.getContent();
}
@GetMapping(“/find-content/{id}”)
public CommonResp findContent(@PathVariable Long id) {
CommonResp
<a name="VoNYJ"></a>
### 2. 前端代码的显示
前端代码我们前面已经给过完整展示。
<a name="dli4E"></a>
## **✿**文档页面功能开发
关于文档页面开发我们还优化了很多东西,我们来看看
<a name="lQ2aT"></a>
### 1. 后端代码展示
我们在查文档的时候,有个all接口没有写动态参数,我们不应该查所有的文档,我们只能查一个电子书下面的所有文档,所以我们需要修改一下all的接口:
```java
@GetMapping("/all/{ebookId}")
public CommonResp all(@PathVariable Long ebookId) {
CommonResp<List<DocQueryResp>> resp = new CommonResp<>();
List<DocQueryResp> list = docService.all(ebookId);
resp.setContent(list);
return resp;
}
public List<DocQueryResp> all(Long ebookId) {
DocExample docExample = new DocExample();
// 添加一个动态查询
DocExample.Criteria criteria = docExample.createCriteria();
criteria.andEbookIdEqualTo(ebookId);
docExample.setOrderByClause("sort asc"); // 按照sort排序
List<Doc> docsList = docMapper.selectByExample(docExample);
List<DocQueryResp> respList = CopyUtil.copyList(docsList, DocQueryResp.class);
return respList;
}
另外我们前面书写的根据id查询content内容的时候,没有做非空判断:
public String findContent(Long id) {
Content content = contentMapper.selectByPrimaryKey(id);
if(ObjectUtils.isEmpty(content)) {
return "";
} else {
return content.getContent();
}
}
2. 前端代码展示
前端代码前面有完整的展示。