公众号:【可乐前端】,每天3分钟学习一个优秀的开源项目,分享web面试与实战知识。
前言
在上一期我们已经实现了个人信息模块,这一期来实现文章发布与管理。涉及到如下功能:
- 草稿创建/修改
- 文章发布
- 文章删除
- 获取我发布的文章
看起来像是文章的增删改查功能,其实还是有不少值得思考的地方,我们一点点往下看。
后端实现
我们写文章很多时候都不是一口气写完的,大多数都是写了一部分之后,下次再来接着写,所以草稿功能是很有必要的。
那么草稿跟正式发布的文章,我们应不应该保存在同一张表里面?
如果草稿有一张单独的表,会有如下的优缺点:
优点:
-
将草稿和文章主表分开存储,可以使数据结构更加清晰,易于理解和维护,查询时心智负担小,数据的边界清晰,很难出现草稿出现在已发布中的文章这种
bug
。 -
草稿和文章主表可以有独立的表和索引,提高查询效率。
-
可以针对草稿和文章主表分别进行数据管理,例如数据备份、恢复、迁移等操作。
缺点:
- 草稿发布时需同步两张表的数据,维护一致性。
- 相对草稿跟已发布的文章在一张表里工作量增多
而如果草稿跟已发布的文章在同一张表中,也会有如下的优缺点:
优点:
- 将草稿和文章主表保存在同一张表中,可以简化数据模型,减少表之间的关联,使用
status
状态来区分是草稿还是已发布的文章 - 可以使用简单的
SQL
语句在同一张表中查询草稿和文章主表的数据。 - 工作量较小
缺点:
- 需要管理状态字段的取值和含义,可能会增加数据管理的复杂性。
- 性能问题:如果草稿和文章主表的数据量较大,可能会影响查询性能。
- 查询文章时需要额外关注查询条件,不然容易出现已发布的文章混入草稿文章
如果数据量较大,且需要频繁进行草稿和文章主表之间的查询和数据同步,那么分开存储可能是更好的选择。
如果数据量较小,且查询和数据同步的需求较简单,那么使用状态字段来区分可能更加简单和方便。
最终我们选择的是第二种方案:即草稿与已发布的文章存在同一张表里,使用状态字段区分,
表设计
文章表 articles
的建表 DDL
语句如下:
sql
CREATE TABLE `articles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(100) DEFAULT NULL,
`content` mediumtext,
`introduction` varchar(100) DEFAULT NULL,
`views` int(11) DEFAULT '0',
`likes` int(11) DEFAULT '0',
`favorites` int(11) DEFAULT '0',
`creator_id` int(11) DEFAULT NULL,
`creator_name` varchar(100) DEFAULT NULL,
`category_id` int(4) DEFAULT NULL,
`is_deleted` tinyint(4) DEFAULT '0',
`status` int(4) DEFAULT '0',
`created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `articles_creator_id_IDX` (`creator_id`,`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
介绍一下上面各个字段的含义:
id
:主键id
title
:文章标题content
:文章内容introduction
:文章简介views
:阅读量likes
:点赞数favorites
:收藏数creator_id
:创建人id
creator_name
:创建人的用户名is_deleted
:是否删除status
:文章状态category_id
:文章标签id
created_time
:创建时间updated_time
:更新时间
这里建了一个 creator_id
的二级索引,旨在加速查询"我发布的文章"这个列表。
创建/修改文章
这里我把创建跟修改文章做成一个接口,如果带了 id
就是修改,不带 id
就是创建。
ts
export class CreateArticleDto {
id?: number;
title: string;
content: string;
}
然后新建一个 createOrUpdate
路由,做一下简单的参数校验之后,具体的业务逻辑会交给 service
。
ts
@Post('createOrUpdate')
async createOrUpdate(
@User('id') userId: number,
@Body() createArticleDto: CreateArticleDto,
) {
if (!createArticleDto.title && !createArticleDto.content) {
throw Error('标题和内容不能同时为空');
}
const res = await this.articleService.createOrUpdate(
createArticleDto,
userId,
);
return res;
}
在 service
中
-
如果是修改文章:
- 先用
id
查询出文章的创建人信息,如果创建人id
跟当前的登录人id
不一致,则没有权限修改 - 执行更新逻辑
- 先用
-
如果是创建文章:
- 先使用当前登录人
id
查询出当前登录人的用户名,这里我在文章表冗余了创建人的用户名,好处是展示文章信息的时候不需要再去查询一遍用户表,需要注意的点是,当用户名修改时,需要同步修改这些冗余的字段 - 执行创建逻辑
- 先使用当前登录人
ts
//service
async createOrUpdate(
createArticleDto: CreateArticleDto,
creatorId: number,
): Promise<number> {
const userInfo = await this.userRepository.findOne({
where: { id: creatorId },
select: ['username', 'id'],
});
if (createArticleDto?.id) {
if (creatorId !== userInfo.id) {
throw Error('无权限修改');
}
await this.articleRepository.update(
{ id: createArticleDto.id },
createArticleDto,
);
return createArticleDto.id;
} else {
const res = await this.articleRepository.save(
Object.assign({}, createArticleDto, {
creatorName: userInfo.username,
creatorId: userInfo.id,
}),
);
return res.id;
}
}
发布文章
由于文章表的 status
字段有默认值 0
,所以当文章创建的时候,它本身就是草稿状态。在发布文章的时候我们需要做如下的交互:
- 选择文章分类
- 填写文章简介
- 修改文章状态
所以发布文章的 DTO
如下:
ts
export class PublishArticleDto {
@IsNotEmpty()
id: number;
@IsNotEmpty()
introduction: string;
@IsNotEmpty()
categoryId: number;
}
删除文章
做删除的时候最好做软删除,就是在表里有一个 is_deleted
字段,默认值是 0
,删除的时候就把这个字段值改成 1
。
这样做可以很轻松的拓展类似于回收站这样的功能,如果真的把某一条数据从表里删除了,指不定某一天某个用户想恢复这条数据,那就得找你们公司的DBA团队了,一般情况下最好不要做硬删除。
所以删除接口的逻辑其实是一个更新逻辑, service
的实现如下:
ts
//service
async deleteArticle(articleId: number, creatorId: number) {
const article = await this.articleRepository.findOne({
where: { id: articleId, creatorId },
});
if (!article) {
throw Error('删除失败');
}
await this.articleRepository.update({ id: articleId }, { isDeleted: 1 });
return true;
}
我的文章列表
我们上面把文章分成了草稿和已发布两种状态,获取的时候可以把他们一起拿出来,但是不要把 is_deleted
为 1
的数据也查出来了。
这里还有一个优化点,我们这个接口需要提供给前端,前端用来展示我的文章列表,既然是列表,那一般不会把文章的内容展示在列表上。而文章的内容大小是不可估量的,对于这种字段来说,完全没有必要取出来。
所以平时开发的时候多注意一下按需取字段,一方面是减少传输的体积,另一方面也可以走到覆盖索引。这个意识希望大家都有
ts
async getMyArticle(creatorId: number) {
const list =
(await this.articleRepository.find({
where: { creatorId, isDeleted: 0 },
select: ['createdTime', 'updatedTime', 'id', 'title', 'status'],
})) || [];
return {
published: list.filter((item) => item.status === 1),
draft: list.filter((item) => item.status === 0),
};
}
前端实现
接口实现了之后,就可以来实现前端的逻辑了。这里前端需要做两个页面:
- 编辑页
- 我的文章列表页
样式可能有些简陋,大家看的时候多担待。
编辑器接入
这里我使用的 markdown
编辑器用的是掘金开源的 markdown
编辑器。它也具有插件化的生态,所以需要用什么插件的话就自己选择。
ts
import { Editor as BEditor } from "@bytemd/react";
<BEditor
uploadImages={handleUpload}
mode="split"
locale={zh}
value={value}
plugins={plugins}
onChange={(v) => {
setValue(v);
}}
/>
接入的话相当简单,就像使用一个 input
组件一样简单,上传图片时需要实现一个 uploadImages
方法。
我们在之前已经实现了通用的上传逻辑接口,而且是支持多文件上传的,所以这里只需要调用一下就能完美契合。
ts
const handleUpload = async (files: File[]) => {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const res = await upload(formData);
return res.data.map((item: any) => ({ url: item.url }));
};
保存与发布
保存的话就是在标题或者内容发生变化的时候,调用一下更新内容的接口就好了,这里我做了一个 1s
的防抖处理。
ts
const updateArticle = useCallback(
debounce((id: number, title: string, content: string) => {
createOrUpdateArticle({
id,
title,
content,
});
}, 1000),
[]
);
发布的时候会弹出一个 popover
来继续填写发布后的字段,之后的一些拓展逻辑也可以在这个 popover
里面加,例如定时发布等等。
我的文章列表
文章列表这里用了 2
个 tab
,来区分草稿与已经发布的文章。调用获取我的文章列表,根据对应的 tabKey
把列表渲染出来即可。
对于每一项都有一个编辑跟删除操作,编辑的话就是跳转到编辑页就行,删除的话可以做一个二次确认,然后调用软删除的接口,调用完之后刷新一下列表即可。
ts
<Popconfirm
title="确认删除吗?"
onConfirm={async () => {
await deleteArticle({ id: item.id });
await getData();
message.success("删除成功");
}}
>
<a
style={{ marginRight: 12 }}
onClick={() => navigate(`/editor?id=${item.id}`)}
>
编辑
</a>
<a>删除</a>
</Popconfirm>
最后
以上就是本文的全部内容,主要介绍了社区平台中的文章发布与管理的实战运用。如果你觉得有意思的话,点点关注点点赞吧~