一个完整的网站都是有前台和管理后台组成的,前台用来给真正的用户浏览和使用,后台用来给管理员管理网站内容,配置各种功能和数据等。博客的管理后台就是用来承载创建博客,发布博客,查看留言,管理博客用户这些功能的子系统。
大家好,我是落霞孤鹜
,上一篇我们已经实现了用户注册,登录,登出的功能,这一章我们开始搭建博客的管理后台,实现对博客网站的管理功能。我会同样按照一个完整的功能,从需求分析到代码编写来阐述如何实现。
一、需求分析
作为一个完整的博客系统,管理后台是内容管理核心部分,在Python和PHP的世界里面,有很多做内容管理的库和开源项目,功能也是丰富多彩。这里我们从实际需要出发,整理了如下需求要点:
- Dashboard: 主要展示整个博客网站的访问情况,包括浏览量,点赞量,评论量,留言量等内容。
- 分类管理:主要用来组织文章的分类,通过分类帮助用户更好的浏览整个博客网站。
- 标签管理:主要用来管理文章的标签,标注文章的类型,帮助用户更好的识别文档的类型。
- 文章管理:主要用来完成文章的新增,修改,发布,删除等,考虑到文章发布的方便,需要支持
Markdown
语法。 - 评论管理:主要用来查看文章的评论信息,如果存在敏感内容,可以通过后台进行删除。
- 用户管理:主要用来管理博客网站注册的用户信息,可以禁用用户等。
以上功能也算是一套2B端产品的核心功能框架。
二、后端接口开发
后端承担业务逻辑处理和数据持久化的责任,基于需求分析中涉及的业务对象,我们需要先进行模型设计,映射到 Django
中,就是先建立 Model
。
2.1 Model
层代码实现
2.1.1 物理模型说明
基于需求分析,通过对业务模型到物理模型的转换,这里主要有一下物理模型:
- 分类表:存储文章分类,与文章表的关系是一对多,即:一个分类可以关联多篇文章,一篇文章只能属于一个分类
- 标签表:存储文章的标签,与文章表的关系是多对多,即:一个标签可以属于多个文章,一篇文章可以管理多个标签
- 文章表:存储文章信息,需要记录文章的标题,摘要,正文,封面,浏览量,评论量,点赞量等
- 评论表:存储文章的评论信息,与文章表是多对一关系,即一篇评论只能关联一篇文章,但是一篇文章可以对应多篇评论
- 点赞表:记录用户点赞信息,与文章表是多对一关系,即一则点赞对应一篇文章,一篇文章对应多则点赞。
- 用户表:记录用户信息,包含博客的查看者,也包含网站的管理员
2.1.2 代码实现
2.1.2.1 安装依赖
在分类表的设计中,我们经常采用的是邻接表的方式,通过一个parent_id
自关联自己,实现父级和子级的关联,形成树形结构。这种设计在新增和修改的时候,非常方便,只需要一次查询即可完成,但是在父查子,子查父,删除等操作时却需要较多的IO损耗。
而实际中,查询要比修改多,因此这里我们采用一种新的数据结构 MPTT
,预排序遍历树,一种更高效的查询和管理树形数据的数据结构。因此需要安装依赖
pip install django-mptt==0.12.0
然后在 requirements.txt
中增加依赖信息
django-mptt==0.12.0
2.1.2.2 管理常量
后端在处理各类业务时,会遇到各类枚举类型,比如用户的身份,性别,文章状态等等,在代码的世界里面,尽量不要用 Magic number
,而是通过常量的方式进行管理。
在 common
下新增文件 constants.py
,编写代码如下:
class Constant(object):
ARTICLE_STATUS = (
('Draft', '草稿'),
('Published', '已发布'),
('Deleted', '已删除')
)
ARTICLE_STATUS_DELETED = 'Deleted'
ARTICLE_STATUS_PUBLISHED = 'Published'
ARTICLE_STATUS_DRAFT = 'Draft'
GENDERS = (
('Male', '男'),
('Female', '女'),
('Unknown', '未知'),
)
GENDERS_UNKNOWN = 'Unknown'
2.1.2.3 Model部分
这里需要说明几个点:
- 在各个表的定义中,通过内部类
Meta
可以定义模型类的元信息,比如表名db_table
,排序方式ordering
,-
表示倒序。 - MPTT 模型有一个单独的内部类
MPTTMeta
,可以定义parent
和排序字段order_insertion_by
。 - 对于多对多的关系,
Django
提供了ManyToMany
的字段类型,这种会自动生成一张中间表用来记录两个表的多对多数据。 - 外键关联在定义的时候,需要指定唯一的
related_name
,以方便表与表的联合检索。 - 外键管理中
on_delete
,需要依据实际情况来确定是级联删除还是不做处理。
在blog/models.py
下编写如下代码:
import mptt.models
from django.db import models
from common.constants import Constant
from common.models import AbstractBaseModel, User
class Tag(AbstractBaseModel):
name = models.CharField('标签名称', max_length=50, unique=True, null=False, blank=False)
class Meta:
db_table = 'blog_tag'
def __str__(self):
return self.name
class Catalog(mptt.models.MPTTModel, AbstractBaseModel):
name = models.CharField('分类名称', max_length=50, unique=True, null=False, blank=False)
parent = mptt.models.TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True,
related_name='children')
class Meta:
db_table = 'blog_catalog'
class MPTTMeta:
order_insertion_by = ['name']
def __str__(self):
return self.name
class Article(AbstractBaseModel):
title = models.CharField('文章标题', max_length=100, unique=True, null=False, blank=False)
cover = models.TextField('封面', max_length=1000, null=False, blank=False)
excerpt = models.CharField('摘要', max_length=200, blank=True)
keyword = models.CharField('关键词', max_length=200, blank=True)
markdown = models.TextField('正文', max_length=100000, null=False, blank=False)
status = models.CharField('文章状态', max_length=30, choices=Constant.ARTICLE_STATUS,
default=Constant.ARTICLE_STATUS_DRAFT)
catalog = models.ForeignKey(Catalog, verbose_name='所属分类', null=False, blank=False,
on_delete=models.DO_NOTHING, related_name='cls_articles')
tags = models.ManyToManyField(Tag, verbose_name='文章标签', blank=True, related_name='tag_articles')
author = models.ForeignKey(User, verbose_name='作者', on_delete=models.DO_NOTHING, null=False, blank=False)
views = models.PositiveIntegerField('浏览量', default=0, editable=False)
comments = models.PositiveIntegerField('评论数量', default=0, editable=False)
likes = models.PositiveIntegerField('点赞量', default=0, editable=False)
words = models.PositiveIntegerField('字数', default=0, editable=False)
class Meta:
db_table = 'blog_article'
ordering = ["-created_at"]
def __str__(self):
return self.title
class Like(AbstractBaseModel):
article = models.ForeignKey(Article, on_delete=models.DO_NOTHING, related_name='article_likes')
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, related_name='like_users')
class Meta:
db_table = 'blog_like'
class Comment(AbstractBaseModel):
article = models.ForeignKey(Article, verbose_name='评论文章', on_delete=models.DO_NOTHING,
related_name='article_comments')
user = models.ForeignKey(User, verbose_name='评论者', on_delete=models.DO_NOTHING, related_name='comment_users')
reply = models.ForeignKey('self', verbose_name='评论回复', on_delete=models.CASCADE, related_name='comment_reply',
null=True, blank=True)
content = models.TextField('评论', max_length=10000, null=False, blank=False)
class Meta:
db_table = 'blog_comment'
class Message(AbstractBaseModel):
email = models.EmailField('邮箱', max_length=100, null=False, blank=False)
content = models.TextField('内容', max_length=10000, null=False, blank=False)
phone = models.CharField('手机', max_length=20, null=True, blank=True)
name = models.CharField('姓名', max_length=30, null=True, blank=True)
class Meta:
db_table = 'blog_message'
2.2 Serializer 层代码实现
2.2.1 整理说明
在使用Rest Framework
框架的时候,定义Serializer是使用这个框架最核心的内容,有几个点需要处理:
- 对一个模型,哪一些字段需要在API中作为入参,哪一些字段作为出参(通过
fields
定义) - 对于接口,哪一些字段只读,哪一些字段可写
- 对于外键字段,如何序列化和反序列化,可以具体指定每一个字段的序列化方式
- 如何增加模型中没有出现的字段
Rest Framework 本身提供了较多的支持方案,包括基Model
的自动序列化方案和基于类的序列化方案,更多细节可以查看官网资料Serializers - Django REST framework
2.2.2 代码实现
对文章部分的定义,考虑到文章是整个博客的核心,所以对其序列化的方案,这里实现了三个版本ArticleListSerializer
、ArticleSerializer
、ArticleChangeStatusSerializer
:
ArticleListSerializer
:用来对应列表查询,完成界面上的展示,可以更好的隔离读和写的权限ArticleSerializer
:用来完成新增,修改,删除,详情查看,通过集成ArticleListSerializer
实现ArticleChangeStatusSerializer
:用来完成上线,下线操作,这两个接口只需要有限字段的入参和出参
博客 App serializer
部分代码编写在blog/serializers.py
文件中,具体代码如下:
from rest_framework import serializers
from blog.models import Catalog, Tag, Article, Like, Message, Comment
class CatalogSerializer(serializers.ModelSerializer):
class Meta:
model = Catalog
fields = ['id', 'name', 'parent']
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'created_at', 'modified_at']
extra_kwargs = {
'created_at': {'read_only': True},
'modified_at': {'read_only': True},
}
class ArticleListSerializer(serializers.ModelSerializer):
tags_info = serializers.SerializerMethodField(read_only=True)
catalog_info = serializers.SerializerMethodField(read_only=True)
status = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Article
fields = ['id', 'title', 'excerpt', 'cover', 'created_at', 'modified_at', 'tags',
'tags_info', 'catalog', 'catalog_info', 'views', 'comments', 'words', 'likes', 'status', ]
extra_kwargs = {
'tags': {'write_only': True},
'catalog': {'write_only': True},
'views': {'read_only': True},
'comments': {'read_only': True},
'words': {'read_only': True},
'likes': {'read_only': True},
'created_at': {'read_only': True},
'modified_at': {'read_only': True},
}
@staticmethod
def get_tags_info(obj: Article) -> list:
if not obj.title:
article = Article.objects.get(id=obj.id)
tags = article.tags.all()
else:
tags = obj.tags.all()
return [{'id': tag.id, 'name': tag.name} for tag in tags]
@staticmethod
def get_catalog_info(obj: Article) -> dict:
if not obj.catalog:
book = Article.objects.get(id=obj.id)
catalog = book.catalog
else:
catalog = obj.catalog
return {
'id': catalog.id,
'name': catalog.name,
'parents': [c.id for c in catalog.get_ancestors(include_self=True)]
}
@staticmethod
def get_status(obj: Article) -> list:
return obj.get_status_display()
class ArticleSerializer(ArticleListSerializer):
tags_info = serializers.SerializerMethodField(read_only=True)
catalog_info = serializers.SerializerMethodField(read_only=True)
class Meta(ArticleListSerializer.Meta):
fields = ['markdown', 'keyword']
fields.extend(ArticleListSerializer.Meta.fields)
class ArticleChangeStatusSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ['id', 'status', ]
extra_kwargs = {
'status': {'read_only': True},
}
class LikeSerializer(serializers.ModelSerializer):
user_info = serializers.SerializerMethodField(read_only=True)
article_info = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Like
fields = ['user', 'user_info', 'article', 'article_info', 'created_at']
extra_kwargs = {
'created_at': {'read_only': True},
}
@staticmethod
def get_user_info(obj: Like) -> dict:
if not obj.user:
return {}
else:
user = obj.user
return {'id': user.id, 'name': user.nickname or user.username, 'avatar': user.avatar}
@staticmethod
def get_article_info(obj: Like) -> dict:
if not obj.article:
return {}
else:
article = obj.article
return {'id': article.id, 'title': article.title}
class CommentSerializer(serializers.ModelSerializer):
user_info = serializers.SerializerMethodField(read_only=True)
article_info = serializers.SerializerMethodField(read_only=True)
comment_replies = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Comment
fields = ['id', 'user', 'user_info', 'article', 'article_info', 'created_at', 'reply', 'content',
'comment_replies']
extra_kwargs = {
'created_at': {'read_only': True},
}
@staticmethod
def get_user_info(obj: Comment) -> dict:
if not obj.user:
return {}
else:
user = obj.user
return {'id': user.id, 'name': user.nickname or user.username, 'avatar': user.avatar}
@staticmethod
def get_article_info(obj: Comment) -> dict:
if not obj.article:
return {}
else:
article = obj.article
return {'id': article.id, 'title': article.title}
@staticmethod
def get_comment_replies(obj: Comment):
if not obj.comment_reply:
return []
else:
replies = obj.comment_reply.all()
return [{
'id': reply.id,
'content': reply.content,
'user_info': {
'id': reply.user.id,
'name': reply.user.nickname or reply.user.username,
'avatar': reply.user.avatar,
'role': reply.user.role,
},
'created_at': reply.created_at
} for reply in replies]
class MessageSerializer(serializers.ModelSerializer):
class Meta:
model = Message
fields = ['email', 'phone', 'name', 'content', 'created_at']
extra_kwargs = {
'created_at': {'read_only': True},
}
2.3 工具方法
为了更好的复用代码逻辑,我们一般会抽象一些工具方法,主要是时间处理方法和上传相关的路径处理,在common/utils.py
中编写如下代码:
import os
import random
import string
import time
from datetime import datetime
from django.conf import settings
from django.template.defaultfilters import slugify
def get_upload_file_path(upload_name):
# Generate date based path to put uploaded file.
date_path = datetime.now().strftime('%Y/%m/%d')
# Complete upload path (upload_path + date_path).
upload_path = os.path.join(settings.UPLOAD_URL, date_path)
full_path = os.path.join(settings.BASE_DIR, upload_path)
make_sure_path_exist(full_path)
file_name = slugify_filename(upload_name)
return os.path.join(full_path, file_name).replace('\\', '/'), os.path.join('/', upload_path, file_name).replace('\\', '/')
def slugify_filename(filename):
""" Slugify filename """
name, ext = os.path.splitext(filename)
slugified = get_slugified_name(name)
return slugified + ext
def get_slugified_name(filename):
slugified = slugify(filename)
return slugified or get_random_string()
def get_random_string():
return ''.join(random.sample(string.ascii_lowercase * 6, 6))
def make_sure_path_exist(path):
if os.path.exists(path):
return
os.makedirs(path, exist_ok=True)
def format_time(dt: datetime, fmt: str = ''):
fmt_str = fmt or '%Y-%m-%d %H:%M:%S'
return dt.strftime(fmt_str)
def get_year(dt: datetime) -> int:
return dt.year
def get_now() -> str:
return format_time(datetime.now())
def format_time_from_str(date_time_str: str, fmt: str = ''):
fmt_str = fmt or '%Y-%m-%d %H:%M:%S'
return datetime.strptime(date_time_str, fmt_str)
def transform_time_to_str(t: int):
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t))
2.4 ViewSet
层代码实现
2.4.1 安装依赖
为了更好的实现在列表查询时的搜索条件识别和校验,我们安装一个新的库:django-filter
pip install django-filter==2.4.0
在requirements.txt
中增加依赖信息
django-filter==2.4.0
2.4.2 通用 ViewSet
定义
在处理接口层定义的时候,我们需要考虑接口的访问权限,分页,查询过滤条件,新增和修改时的操作人,用户角色判断等,这些处理是在每一个接口中都需要处理的,因此我们这里将这些逻辑统一抽象到一个基础类中完成,然后通过Python的多继承完成子类的能力扩充。
- 定义了
BaseError
,用于在出现各类业务校验不通过时抛出异常。 - 定义了
BasePagination
,用于列表查询接口的分页。 - 定义了
BaseViewSetMixin
类,作为常规ViewSet的基类,将分页、过滤条件、权限、操作者填充、用户身份判断等。 - 定义了
ConstantViewSet
类,用于将后端使用的常量提供给前端,用做前端需要判断枚举值之用。 - 定义了
ImageUploadViewSet
类,用于在新增文章时上传封面之用。
在common/views.py
中增加如下代码:
import logging
import django.conf
from django.contrib.auth import authenticate, login, logout as auth_logout
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import AnonymousUser
from django.core.mail import send_mail
from django.db.models import QuerySet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, permissions, status
from rest_framework.exceptions import ValidationError
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from rest_framework.views import APIView
from common.constants import Constant
from common.models import User
from common.serializers import UserSerializer, UserLoginSerializer, UserPasswordSerializer
from common.utils import get_upload_file_path
def get_random_password():
import random
import string
return ''.join(random.sample(string.ascii_letters + string.digits + string.punctuation, 8))
class BaseError(ValidationError):
def __init__(self, detail=None, code=None):
super(BaseError, self).__init__(detail={'detail': detail})
class BasePagination(PageNumberPagination):
"""
customer pagination
"""
# default page size
page_size = 10
# page size param in page size
page_size_query_param = 'page_size'
# page param in api
page_query_param = 'page'
# max page size
max_page_size = 100
class BaseViewSetMixin(object):
pagination_class = BasePagination
filter_backends = [DjangoFilterBackend]
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def __init__(self, **kwargs):
super(BaseViewSetMixin, self).__init__(**kwargs)
self.filterset_fields = []
self.init_filter_field()
def init_filter_field(self):
"""
Init filter field by the fields' intersection in model and serializer
e.g. `book/?id=1&authors=2`
:return: None
"""
serializer = self.get_serializer_class()
if not hasattr(serializer, 'Meta'):
return
meta = serializer.Meta
if not hasattr(meta, 'model'):
return
model = meta.model
if not hasattr(meta, 'fields'):
ser_fields = []
else:
ser_fields = meta.fields
for field in ser_fields:
if not hasattr(model, field):
continue
self.filterset_fields.append(field)
def perform_update(self, serializer):
user = self.fill_user(serializer, 'update')
return serializer.save(**user)
def perform_create(self, serializer):
user = self.fill_user(serializer, 'create')
return serializer.save(**user)
@staticmethod
def fill_user(serializer, mode):
"""
before save, fill user info into para from session
:param serializer: Model's serializer
:param mode: create or update
:return: None
"""
request = serializer.context['request']
user_id = request.user.id
ret = {'modifier': user_id}
if mode == 'create':
ret['creator'] = user_id
return ret
def get_pk(self):
if hasattr(self, 'kwargs'):
return self.kwargs.get('pk')
def is_reader(self):
return isinstance(self.request.user, AnonymousUser) or not self.request.user.is_superuser
class BaseModelViewSet(BaseViewSetMixin, viewsets.ModelViewSet):
pass
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('username')
serializer_class = UserSerializer
permission_classes = [permissions.AllowAny]
class UserLoginViewSet(GenericAPIView):
permission_classes = [permissions.AllowAny]
serializer_class = UserLoginSerializer
queryset = User.objects.all()
def post(self, request, *args, **kwargs):
username = request.data.get('username', '')
password = request.data.get('password', '')
user = authenticate(username=username, password=password)
if user is not None and user.is_active:
login(request, user)
serializer = UserSerializer(user)
return Response(serializer.data, status=200)
else:
ret = {'detail': 'Username or password is wrong'}
return Response(ret, status=403)
class UserLogoutViewSet(GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = UserLoginSerializer
def get(self, request, *args, **kwargs):
auth_logout(request)
return Response({'detail': 'logout successful !'})
class PasswordUpdateViewSet(GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = UserPasswordSerializer
queryset = User.objects.all()
def post(self, request, *args, **kwargs):
user_id = request.user.id
password = request.data.get('password', '')
new_password = request.data.get('new_password', '')
user = User.objects.get(id=user_id)
if not user.check_password(password):
ret = {'detail': 'old password is wrong !'}
return Response(ret, status=403)
user.set_password(new_password)
user.save()
return Response({
'detail': 'password changed successful !'
})
def put(self, request, *args, **kwargs):
"""
Parameter: username->user's username who forget old password
"""
username = request.data.get('username', '')
users = User.objects.filter(username=username)
user: User = users[0] if users else None
if user is not None and user.is_active:
password = get_random_password()
try:
send_mail(subject="New password for Blog site",
message="Hi: Your new password is: \n{}".format(password),
from_email=django.conf.settings.EMAIL_HOST_USER,
recipient_list=[user.email],
fail_silently=False)
user.password = make_password(password)
user.save()
return Response({
'detail': 'New password will send to your email!'
})
except Exception as e:
print(e)
return Response({
'detail': 'Send New email failed, Please check your email address!'
})
else:
ret = {'detail': 'User does not exist(Account is incorrect !'}
return Response(ret, status=403)
class ConstantViewSet(GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = UserPasswordSerializer
queryset = QuerySet()
def get(self, request, *args, **kwargs):
ret = {}
for key in dir(Constant):
if not key.startswith("_"):
ret[key] = getattr(Constant, key)
return Response(ret)
class ImageUploadViewSet(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request, *args, **kwargs):
try:
if request.method == 'POST' and request.FILES:
uploaded_file = request.FILES['file']
full_file_path, file_path = get_upload_file_path(uploaded_file.name)
self.handle_uploaded_file(uploaded_file, full_file_path)
response = {
'url': file_path
}
return Response(response)
except Exception as e:
logging.getLogger('default').error(e, exc_info=True)
raise BaseError(detail='Upload failed', code=status.HTTP_500_INTERNAL_SERVER_ERROR)
@staticmethod
def handle_uploaded_file(f, file_path):
destination = open(file_path, 'wb+')
for chunk in f.chunks():
destination.write(chunk)
destination.close()
2.4.3 Blog 相关的 ViewSet
定义
这里的ViewSet
类通过继承框架提供的基类或者我们自己封装的BaseViewSet类,来实现对应的业务接口,如果是非常传统的 CURD 接口,在ViewSet
里面可能仅仅只需要定义 queryset
的属性就可以完成新增,修改,删除,详情查询,列表查询的接口。
可以看到我们的 Article 对象相关的ViewSet
有 7 个,主要是兼顾了文章在各种维度下的查询和管理需求,比如我们需要按照时间对文章进行查询,需要通过浏览量倒序展示文章列表,需要上线,下架文章,需要不登录就能浏览文章,需要登录管理员才能管理文章等各种需求。
具体代码如下:
import datetime
from common.utils import get_year
from django.db.models import QuerySet, Sum, Count
from rest_framework import mixins
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from blog.models import Article, Comment, Message, Tag, Catalog, Like
from blog.serializers import ArticleSerializer, CommentSerializer, MessageSerializer, TagSerializer, \
ArticleListSerializer, CatalogSerializer, ArticleChangeStatusSerializer, LikeSerializer
from common.constants import Constant
from common.views import BaseModelViewSet, BaseViewSetMixin
class ArticleArchiveListViewSet(BaseViewSetMixin, mixins.ListModelMixin, GenericViewSet):
queryset = Article.objects.all()
serializer_class = ArticleListSerializer
def filter_queryset(self, queryset) -> QuerySet:
queryset = super(ArticleArchiveListViewSet, self).filter_queryset(queryset)
if self.is_reader():
queryset = queryset.exclude(status=Constant.ARTICLE_STATUS_DRAFT)
return queryset.exclude(status=Constant.ARTICLE_STATUS_DELETED)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
total = len(queryset)
page_size, page_number = self.get_page_info()
start_year, end_year = self.get_datetime_range(page_size, page_number)
queryset = queryset.filter(created_at__gte=start_year).filter(created_at__lt=end_year)
ret = {
"count": total,
"next": None,
"previous": None,
'results': []
}
years = {}
for article in queryset.all():
year = article.created_at.year
articles = years.get(year)
if not articles:
articles = []
years[year] = articles
serializer = self.get_serializer(article)
articles.append(serializer.data)
for key, value in years.items():
ret['results'].append({
'year': key,
'list': value
})
ret['results'].sort(key=lambda i: i['year'], reverse=True)
return Response(ret)
def get_page_info(self):
page_size = self.paginator.get_page_size(self.request)
page_number = self.request.query_params.get(self.paginator.page_query_param, 1)
return page_size, int(page_number)
@staticmethod
def get_datetime_range(page_size, page_number):
current_year = get_year(datetime.datetime.now())
start_year = current_year - page_size * page_number + 1
start_datetime = '{:d}-01-01 00:00:00'.format(start_year)
end_datetime = '{:d}-01-01 00:00:00'.format(start_year + page_size)
return start_datetime, end_datetime
class ArticleListViewSet(BaseViewSetMixin, mixins.ListModelMixin,
GenericViewSet):
queryset = Article.objects.all().select_related('catalog', 'author')
serializer_class = ArticleListSerializer
def filter_queryset(self, queryset):
self.filterset_fields.remove('catalog')
queryset = super(ArticleListViewSet, self).filter_queryset(queryset)
if self.is_reader():
queryset = queryset.exclude(status=Constant.ARTICLE_STATUS_DRAFT)
params = self.request.query_params
if 'catalog' in params:
catalog_id = params.get('catalog', 1)
catalog = Catalog.objects.get(id=catalog_id)
catalogs = catalog.get_descendants(include_self=True)
queryset = queryset.filter(catalog__in=[c.id for c in catalogs])
return queryset.exclude(status=Constant.ARTICLE_STATUS_DELETED)
class ArticleViewSet(BaseViewSetMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
GenericViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
def perform_create(self, serializer):
extra_infos = self.fill_user(serializer, 'create')
extra_infos['author'] = self.request.user
serializer.save(**extra_infos)
def filter_queryset(self, queryset):
queryset = super(ArticleViewSet, self).filter_queryset(queryset)
if self.is_reader():
queryset = queryset.exclude(status=Constant.ARTICLE_STATUS_DRAFT).exclude(
status=Constant.ARTICLE_STATUS_DELETED)
return queryset
def perform_destroy(self, instance: Article):
instance.status = Constant.ARTICLE_STATUS_DELETED
instance.save()
def retrieve(self, request, *args, **kwargs):
instance: Article = self.get_object()
serializer = self.get_serializer(instance)
if self.is_reader():
instance.views += 1
instance.save()
return Response(serializer.data)
class ArticlePublishViewSet(BaseViewSetMixin,
mixins.UpdateModelMixin,
GenericViewSet):
queryset = Article.objects.all()
serializer_class = ArticleChangeStatusSerializer
def filter_queryset(self, queryset):
queryset = super(ArticlePublishViewSet, self).filter_queryset(queryset)
return queryset.exclude(status=Constant.ARTICLE_STATUS_DELETED)
def perform_update(self, serializer):
extra_infos = self.fill_user(serializer, 'update')
extra_infos['status'] = Constant.ARTICLE_STATUS_PUBLISHED
serializer.save(**extra_infos)
class ArticleOfflineViewSet(ArticlePublishViewSet):
def perform_update(self, serializer):
extra_infos = self.fill_user(serializer, 'update')
extra_infos['status'] = Constant.ARTICLE_STATUS_DRAFT
serializer.save(**extra_infos)
class CommentViewSet(BaseModelViewSet):
queryset = Comment.objects.all()
serializer_class = CommentSerializer
def filter_queryset(self, queryset):
queryset = super(CommentViewSet, self).filter_queryset(queryset)
return queryset.filter(reply__isnull=True)
def perform_create(self, serializer):
super(CommentViewSet, self).perform_create(serializer)
article: Article = serializer.validated_data['article']
article.comments += 1
article.save()
class LikeViewSet(BaseModelViewSet):
queryset = Like.objects.all()
serializer_class = LikeSerializer
def perform_create(self, serializer):
super(LikeViewSet, self).perform_create(serializer)
article: Article = serializer.validated_data['article']
article.likes += 1
article.save()
class MessageViewSet(BaseModelViewSet):
queryset = Message.objects.all()
serializer_class = MessageSerializer
class TagViewSet(BaseModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
class CatalogViewSet(BaseModelViewSet):
queryset = Catalog.objects.all()
serializer_class = CatalogSerializer
def list(self, request, *args, **kwargs):
ret = []
roots = Catalog.objects.filter(id=1).filter(parent__isnull=True)
if not roots:
return Response(ret)
root: Catalog = roots[0]
root_dict = CatalogSerializer(root).data
root_dict['children'] = []
ret.append(root_dict)
parent_dict = {root.id: root_dict}
for cls in root.get_descendants():
data = CatalogSerializer(cls).data
parent_id = data.get('parent')
parent = parent_dict.get(parent_id)
parent['children'].append(data)
if not cls.is_leaf_node() and cls.id not in parent_dict:
data['children'] = []
parent_dict[cls.id] = data
return Response(ret)
class NumberViewSet(BaseViewSetMixin,
mixins.ListModelMixin,
GenericViewSet):
queryset = Article.objects.all()
serializer_class = ArticleListSerializer
def list(self, request, *args, **kwargs):
queryset = self.get_queryset().aggregate(Sum('views'), Sum('likes'), Sum('comments'))
messages = Message.objects.aggregate(Count('id'))
return Response({
'views': queryset['views__sum'],
'likes': queryset['likes__sum'],
'comments': queryset['comments__sum'],
'messages': messages['id__count']
})
class TopArticleViewSet(NumberViewSet):
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset()).order_by('-views')[:10]
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
2.5 定义URL
2.5.1 安装API文档自动生成依赖
通过工具,可以自动生成基于Restful的接口说明。
pip install drf-yasg==1.20.0
修改requirements.txt
,增加如下:
drf-yasg==1.20.0
2.5.2 common
下的 urls.py
修改common/urls.py
,最终代码如下代码:
from django.conf.urls import url
from django.urls import include, path
from rest_framework import routers
from common import views
from common.views import ImageUploadViewSet
router = routers.DefaultRouter()
router.register('user', views.UserViewSet)
app_name = 'common'
urlpatterns = [
path('', include(router.urls)),
url(r'^user/login', views.UserLoginViewSet.as_view()),
url(r'^user/logout', views.UserLogoutViewSet.as_view()),
url(r'^user/pwd', views.PasswordUpdateViewSet.as_view()),
url(r'^dict', views.ConstantViewSet.as_view()),
url(r'upload/$', ImageUploadViewSet.as_view()),
]
2.5.3 blog
下的 urls.py
在blog/urls.py
中编写如下代码:
from django.urls import include, path
from rest_framework import routers
from blog import views
router = routers.DefaultRouter()
router.register('article', views.ArticleViewSet)
router.register('list', views.ArticleListViewSet)
router.register('publish', views.ArticlePublishViewSet)
router.register('offline', views.ArticleOfflineViewSet)
router.register('archive', views.ArticleArchiveListViewSet)
router.register('tag', views.TagViewSet)
router.register('catalog', views.CatalogViewSet)
router.register('comment', views.CommentViewSet)
router.register('like', views.LikeViewSet)
router.register('message', views.MessageViewSet)
router.register('number', views.NumberViewSet)
router.register('top', views.TopArticleViewSet)
app_name = 'blog'
urlpatterns = [
path('', include(router.urls)),
]
2.5.4 project
下的urls.py
这里我们使用drf_yasg提供的方法,自动生成接口说明文档,在实际的前后端分类的项目中,这是非常有用的一个工具,可以让前端和后端基于接口约定并行开发,保证前端和后端的开发效率。
修改project/urls.py
,最终代码如下:
from django.conf import settings
from django.conf.urls import url
from django.urls import path, re_path, include
from django.views.generic import RedirectView
from django.views.static import serve
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
schema_view = get_schema_view(
openapi.Info(
title="Blog System API",
description="Blog site ",
default_version='v1',
terms_of_service="",
contact=openapi.Contact(email="XXXX@163.com"),
license=openapi.License(name="GPLv3 License"),
),
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [
path('', include('blog.urls', namespace='blog')),
path('', include('common.urls', namespace='common')),
url(r'^favicon.ico$', RedirectView.as_view(url=r'static/img/favicon.ico')),
url(r'upload/(?P<path>.*)', serve, {'document_root': settings.MEDIA_ROOT}),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
re_path(
r"api/swagger(?P<format>\.json|\.yaml)",
schema_view.without_ui(cache_timeout=0),
name="schema-json",
),
path(
"swagger/",
schema_view.with_ui("swagger", cache_timeout=0),
name="schema-swagger-ui",
),
path("docs/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
]
2.6 配置调整
2.6.1 调整project/setting.py
在project/setting.py
中 INSTALLED_APPS
中增加blog
、drf_yasg
、django_filters
,如果不添加,则会出现模板路径找不到的问题
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'drf_yasg',
'django_filters',
'common',
'blog'
]
在project/setting.py
中 TEMPLATES
调整为:
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
在project/setting.py
增加关于媒体文件和上传路径的配置
MEDIA_ROOT = os.path.join(BASE_DIR, 'upload')
MEDIA_URL = "/upload/"
UPLOAD_URL = 'upload'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'upload'),
)
2.6.2 执行模型迁移
python manage.py makemigrations
python manage.py migrate
到此为止个人博客后端部分开发完成。这里面实际也包含了访客在博客网站上能够访问网站所需要的接口。
三、前端界面开发
一个管理后台的功能,一般都需要从最基础的业务对象的管理开始,在我们的博客网站上,业务对象间的依赖依次是用户、标签、分类、文章、评论、点赞、留言、首页统计。
基于这个依赖关系,我们的后台管理功能也按照这样的逻辑顺序进行构建。然后在构建每一个业务对象的管理页面时,按照Type
、API
、Component
、View
、Route
顺序进行组织和代码编写。
在src/views
下创建两个文件夹admin
和client
,并把上一个章节中创建的Login.vue
移动到admin
文件夹,把Home.vue
文件移动到client
下。
3.1 菜单管理
3.1.1 Admin.vue
管理后台的功能需要一个独立的菜单导航功能,因此在src/views/admin
下新增Admin.vue
文件,用于完成左侧的菜单导航,代码如下:
<template>
<div class="body">
<div class="menu">
<el-menu :default-active="state.activePath" :router="true">
<el-menu-item index="AdminDashboard" route="/admin/dashboard"><i class="el-icon-s-home"></i> Dashboard
</el-menu-item>
<el-menu-item index="ArticleManagement" route="/admin/article"><i class="el-icon-s-order"></i> 文章
</el-menu-item>
<el-menu-item index="TagManagement" route="/admin/tag"><i class="el-icon-collection-tag"></i> 标签</el-menu-item>
<el-menu-item index="CommentManagement" route="/admin/comment"><i class="el-icon-chat-line-round"></i> 评论
</el-menu-item>
<el-menu-item index="UserManagement" route="/admin/user"><i class="el-icon-user"></i> 用户</el-menu-item>
</el-menu>
</div>
<div class="view">
<router-view/>
</div>
</div>
</template>
<script>
import {defineComponent, reactive} from "vue";
import {useRoute} from "vue-router";
export default defineComponent({
name: "Admin",
setup() {
const state = reactive({
activePath: '',
});
const route = useRoute()
if (route.name === 'Dashboard') {
state.activePath = 'AdminDashboard'
} else {
state.activePath = route.name;
}
return {
state,
}
},
});
</script>
<style lang="less" scoped>
.body {
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
}
.user {
font-size: 20px;
}
.menu {
width: 200px;
}
.view {
width: calc(100% - 200px);
padding: 24px;
}
.el-menu {
height: 100%;
}
</style>
3.1.2 Dashboard.vue
为了接下来的开发能很好的开展,我们先处理管理后台的默认页面Dashboard
, 在src/views/admin
下创建文件Dashboard.vue
,编写代码:
<template>
<h3>Dashboard</h3>
</template>
<script lang="ts">
import { defineComponent, reactive } from "vue";
export default defineComponent({
name: 'Dashboard',
})
</script>
3.1.3 添加路由
在src/router/index.ts
下调整代码如下:
import {createRouter, createWebHistory, RouteRecordRaw} from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import("../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import("../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
3.2 用户管理
3.2.1 Type
层
在我们处理登录和注册的时候,已经完成了用户的类型定义,也即User
的interface定义,这里增加所有返回结果的定义,用于管理接口返回的数据结构。在src/types/index.ts
文件中代码如下:
export interface User {
id: number,
username: string,
email: string,
avatar: string | any,
nickname: string | any,
is_active?: any,
is_superuser?: boolean,
created_at?: string,
}
export interface ResponseData {
count: number;
results?: any;
detail?: string;
}
3.2.2 API
层
这里要编写用户管理相关的接口,列表查询、启用、禁用、详情查看。在src/api/service.ts
编写如下代码:
import { User, ResponseData } from "../types"
export function getUserDetail(userId: number) {
return request({
url: '/user/' + userId + '/',
method: 'get',
}) as unknown as User
}
export function saveUser(method: string, data: User) {
// @ts-ignore
return request({
url: '/user/' + data.id + '/',
method,
data,
}) as unknown as ResponseData
}
3.2.3 Component
层
在查看用户详情时,我们需要一个抽屉,展示用户的详细信息,因此在src/components
下创建文件UserDetail.vue
,编写代码如下:
<template>
<el-drawer
v-model="state.visible"
:before-close="handleClose"
direction="rtl"
size="500px"
title="用户详情"
@opened="handleSearch"
>
<el-descriptions :column="1" border class="detail" >
<el-descriptions-item label="用户名">{{ state.user.username }}</el-descriptions-item>
<el-descriptions-item label="角色">{{ state.user.role }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ state.user.is_active }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ state.user.email }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ state.user.created_at }}</el-descriptions-item>
<el-descriptions-item label="最后登录时间">{{ state.user.last_login }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {User} from "../types";
import {getUserDetail} from "../api/service";
export default defineComponent({
name: "UserDetail",
props: {
visible: {
type: Boolean,
require: true,
},
userId: {
type: Number,
require: true,
},
loading: {
type: Boolean,
require: true,
}
},
emits: ["close",],
watch: {
'$props.visible': {
async handler(val: Boolean, oldVal: Boolean) {
if (val !== oldVal) {
this.state.visible = val
}
}
}
},
setup(props) {
const state = reactive({
visible: props.visible as Boolean,
user: {} as User,
});
return {
state,
}
},
methods: {
handleClose(isOk: Boolean) {
this.$emit("close", {
user: this.state.user,
isOk,
})
},
async handleSearch() {
this.state.user = await getUserDetail(this.$props.userId)
}
}
})
</script>
<style scoped>
.detail {
padding: 24px;
margin-top: -12px;
border-top: #eeeeee 1px solid;
}
</style>
3.2.4 View
层
在用户管理中,我们通过一个表格,分页展示所有的用户信息,并通过表格的操作列,提供查看详情、启用、禁用功能。
在src/utils/index.ts
下增加方法timestampToTime
export function timestampToTime(timestamp: Date | any, dayMinSecFlag: boolean) {
const date = new Date(timestamp);
const Y = date.getFullYear() + "-";
const M =
(date.getMonth() + 1 < 10
? "0" + (date.getMonth() + 1)
: date.getMonth() + 1) + "-";
const D =
date.getDate() < 10 ? "0" + date.getDate() + " " : date.getDate() + " ";
const h =
date.getHours() < 10 ? "0" + date.getHours() + ":" : date.getHours() + ":";
const m =
date.getMinutes() < 10
? "0" + date.getMinutes() + ":"
: date.getMinutes() + ":";
const s =
date.getSeconds() < 10 ? "0" + date.getSeconds() : date.getSeconds();
if (!dayMinSecFlag) {
return Y + M + D;
}
return Y + M + D + h + m + s;
}
在src/views/admin
下新增文件User.vue
,引用UserDetail
组件,具体代码如下:
<template>
<div>
<div>
<el-form :inline="true" :model="state.params" class="demo-form-inline">
<el-form-item label="名称">
<el-input v-model="state.params.name" placeholder="账号"/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="state.params.is_active" placeholder="请选择">
<el-option :value="1" label="生效"/>
<el-option :value="0" label="禁用"/>
</el-select>
</el-form-item>
<el-form-item>
<el-button :loading="state.isLoading" type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<div>
<el-table ref="userTable" :data="state.userList" :header-cell-style="{background:'#eef1f6',color:'#606266'}"
stripe>
<el-table-column type="selection" width="55"/>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="账号" prop="username" width="200"/>
<el-table-column label="昵称" prop="nickname" width="200"/>
<el-table-column label="状态" prop="is_active"/>
<el-table-column :formatter="datetimeFormatter" label="注册时间" prop="created_at"/>
<el-table-column label="操作">
<template #default="scope">
<el-popconfirm v-if="scope.row.is_active" cancelButtonText='取消' confirmButtonText='禁用' icon="el-icon-info"
iconColor="red" title="确定禁用该用户吗?" @confirm="disableUser(scope.$index,scope.row)">
<template #reference>
<el-button size="small" type="text">
禁用
</el-button>
</template>
</el-popconfirm>
<el-button v-if="!scope.row.is_active" size="small" type="text"
@click.native.prevent="enableUser(scope.$index, scope.row)">
启用
</el-button>
<el-button size="small" type="text"
@click.native.prevent="showUserDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination :page-size="10" :total="state.total" background
layout="prev, pager, next"></el-pagination>
</div>
</div>
<UserDetail
:user-id="state.userId"
:visible="state.showDialog"
@close="state.showDialog = false"
/>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {ResponseData, User} from "../../types";
import {ElMessage} from "element-plus";
import {timestampToTime} from "../../utils";
import {getUserList, saveUser} from "../../api/service";
import UserDetail from "../../components/UserDetail.vue";
export default defineComponent({
name: "User",
components: {UserDetail},
setup: function () {
const state = reactive({
userList: [] as Array<User>,
params: {
name: '',
role: 'Reader',
is_active: undefined,
page: 1,
page_size: 10,
},
isLoading: false,
total: 0,
showDialog: false,
userId: 0,
saveLoading: false,
});
const handleSearch = async (): Promise<void> => {
state.isLoading = true;
try {
const data: ResponseData = await getUserList(state.params);
state.isLoading = false;
state.userList = data.results;
state.total = data.count
} catch (e) {
console.error(e)
state.isLoading = false;
}
};
const disableUser = async (index: number, row: User) => {
await saveUser('patch', {id: row.id, is_active: false} as User);
ElMessage({
message: "禁用成功!",
type: "success",
});
await handleSearch()
}
const enableUser = async (index: number, row: User) => {
await saveUser('patch', {id: row.id, is_active: true} as User);
ElMessage({
message: "启用成功!",
type: "success",
});
await handleSearch()
}
const datetimeFormatter = (row: User, column: number, cellValue: string, index: number) => {
return timestampToTime(cellValue, true);
}
handleSearch()
return {
state,
handleSearch,
datetimeFormatter,
disableUser,
enableUser,
}
},
methods: {
showUserDetail(row: User) {
this.state.userId = row.id
this.state.showDialog = true;
},
}
})
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
3.2.5 Router
层
有了一个新的页面,我们需要定义route
来完成路由跳转。在src/route/index.ts
文件中编写如下代码:
import {createRouter, createWebHistory, RouteRecordRaw} from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import("../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import("../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/user',
name: 'UserManagement',
component: () => import("../views/admin/User.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
3.3 标签管理
主要是为了方便灵活的给文章标记类型,所以才有标签管理,标签的属性很简单,就是一个名称。
3.3.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface Tag {
id: number,
name: string,
created_at: string,
modified_at: string,
}
export interface TagList {
count: number,
results: Array<Tag> | any
}
3.3.2 API
层
这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在src/api/service.ts
编写如下代码:
export function getTagList(params: any) {
return request({
url: '/tag/',
method: 'get',
params,
}) as unknown as TagList
}
export function saveTag(method: string, data: Tag) {
let url = '/tag/'
if (['put', 'patch'].includes(method)) {
url += data.id + '/'
}
// @ts-ignore
return request({
url,
method,
data,
}) as unknown as ResponseData
}
export function addTag(data: Tag) {
return request({
url: '/tag/',
method: 'post',
data,
}) as unknown as ResponseData
}
export function deleteTag(id: number) {
return request({
url: '/tag/' + id + '/',
method: 'delete',
}) as unknown as ResponseData
}
3.3.3 Component
层
提供一个新增和修改标签的弹框组件,因此在src/components
下创建文件TagEditDialog.vue
,编写代码如下:
<template>
<el-dialog v-model="state.visible" :title="state.title" @close="handleClose(false)" width="440px" >
<el-form size="medium" label-suffix=":" class="form">
<el-form-item label="名称" label-width="80px">
<el-input v-model="state.name" autocomplete="off" size=""></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose(false)">取 消</el-button>
<el-button :loading="loading" type="primary" @click="handleClose(true)">确 定</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts">
import {defineComponent, PropType, reactive} from "vue";
import {Tag} from "../types";
export default defineComponent({
name: "TagEditDialog",
props: {
visible: {
type: Boolean,
require: true,
},
tag: {
type: Object as PropType<Tag>,
require: true,
},
loading: {
type: Boolean,
require: true,
}
},
emits: ["close",],
watch: {
'$props.visible': {
handler(val: Boolean, oldVal: Boolean) {
if (val !== oldVal) {
this.state.visible = val
}
if (val) {
this.state.name = this.$props.tag.name
this.state.title = this.$props.tag.id ? '修改标签' : '新增标签'
}
}
}
},
setup(props) {
const state = reactive({
visible: props.visible as Boolean,
//@ts-ignore
name: '',
//@ts-ignore
title: ''
});
return {
state,
}
},
methods: {
handleClose(isOk: Boolean) {
this.$emit("close", {
obj: {
//@ts-ignore
id: this.$props.tag.id,
name: this.state.name
},
isOk,
})
}
}
})
</script>
<style scoped>
.form{
padding-right: 24px;
}
</style>
3.3.4 View
层
通过表格管理标签,实现对标签的新增,修改,删除和列表查看,在src/views/admin
下新增文件Tag.vue
文件,编写如下代码:
<template>
<div>
<div>
<el-form :inline="true" :model="state.params" class="demo-form-inline">
<el-form-item label="名称">
<el-input v-model="state.params.name" placeholder="名称" />
</el-form-item>
<el-form-item>
<el-button
:loading="state.isLoading"
type="primary"
@click="handleSearch"
>查询</el-button
>
</el-form-item>
</el-form>
</div>
<div class="button-container">
<el-button
:loading="state.isLoading"
type="primary"
@click="showAddDialog"
><i class="el-icon-plus" /> 新 增
</el-button>
</div>
<div>
<el-table
ref="tagTable"
:data="state.tagList"
:header-cell-style="{ background: '#eef1f6', color: '#606266' }"
stripe
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="名称" prop="name" width="200" />
<el-table-column
:formatter="datetimeFormatter"
label="修改时间"
prop="modified_at"
/>
<el-table-column fixed="right" label="操作" width="120">
<template #default="scope">
<el-popconfirm
cancelButtonText="取消"
confirmButtonText="删除"
icon="el-icon-info"
iconColor="red"
title="确定删除系列吗?"
@confirm="deleteObject(scope.$index, scope.row)"
>
<template #reference>
<el-button size="small" type="text"> 删除 </el-button>
</template>
</el-popconfirm>
<el-button
size="small"
type="text"
@click.prevent="showEditDialog(scope.$index, scope.row)"
>
编辑
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
:page-size="10"
:total="state.total"
background
layout="prev, pager, next"
></el-pagination>
</div>
</div>
<TagEditDialog
:loading="state.saveLoading"
:tag="state.tag"
:visible="state.showDialog"
@close="handleCloseDialog"
/>
</template>
<script lang="ts">
import { defineComponent, reactive } from "vue";
import { ResponseData, Tag } from "../../types";
import { addTag, deleteTag, getTagList, saveTag } from "../../api/service";
import { timestampToTime } from "../../utils";
import { ElMessage } from "element-plus";
import TagEditDialog from "../../components/TagEditDialog.vue";
import { useRoute } from "vue-router";
export default defineComponent({
name: "Tag",
components: { TagEditDialog },
watch: {
"$route.path": {
handler(val, oldVal) {
if (val !== oldVal && ["/admin/tag"].includes(val)) this.handleSearch();
},
deep: true,
},
},
setup: function () {
const route = useRoute();
const state = reactive({
tagList: [] as Array<Tag>,
params: {
name: undefined,
page: 1,
page_size: 10,
},
isLoading: false,
total: 0,
showDialog: false,
tag: {
id: 0,
name: "",
} as Tag,
saveLoading: false,
});
const handleSearch = async (): Promise<void> => {
state.isLoading = true;
try {
const data: ResponseData = await getTagList(state.params);
state.isLoading = false;
state.tagList = data.results;
state.total = data.count;
} catch (e) {
console.error(e);
state.isLoading = false;
}
};
const deleteObject = async (index: number, row: Tag) => {
await deleteTag(row.id);
ElMessage({
message: "删除成功!",
type: "success",
});
await handleSearch();
};
const datetimeFormatter = (
row: Tag,
column: number,
cellValue: string,
index: number
) => {
return timestampToTime(cellValue, true);
};
handleSearch();
return {
state,
handleSearch,
datetimeFormatter,
deleteObject,
};
},
methods: {
showEditDialog(index: number, row: Tag) {
this.state.tag = row;
this.state.showDialog = true;
},
showAddDialog() {
this.state.tag = {} as Tag;
this.state.showDialog = true;
},
async handleCloseDialog(params: any) {
if (!params.isOk) {
this.state.showDialog = false;
return;
}
this.state.saveLoading = true;
const method = this.state.tag.id ? "put" : "post";
try {
await saveTag(method, params.obj);
this.state.showDialog = false;
this.state.saveLoading = false;
await this.handleSearch();
} catch (e) {
console.error(e);
this.state.saveLoading = false;
}
},
},
});
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
3.3.5Router
层
定义route
来完成路由跳转。在src/route/index.ts
文件中新增代码:
import {createRouter, createWebHistory, RouteRecordRaw} from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import"../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import("../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/user',
name: 'UserManagement',
component: () => import("../views/admin/User.vue"),
},
{
path: '/admin/tag',
name: 'Tag',
component: () => import("../views/admin/Tag.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
3.4 分类和文章管理
文章和分类是关系比较密切的两个业务对象,因此这里把分类管理的功能和文章管理的功能放在同一个页面处理。
3.4.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface Catalog {
id: number,
name: string,
parent: number,
parents: Array<number>,
children: Array<Catalog>
}
export interface Article {
id: number,
title: string,
cover: string,
toc: string,
excerpt: string,
markdown: string,
html: string,
create_at: string,
views: number,
likes: number,
comments: number,
words: number,
tags: Array<number> | any,
tags_info: Array<Tag> | any
catalog: number,
catalog_info: Catalog,
created_at: string,
modified_at: string,
author: string,
status?: string,
}
export interface ArticleArray {
count: number,
results: Array<Article> | any
}
export interface ArticleParams {
title: string | any,
status: string | any,
tags: Array<number> | any,
catalog: number | any,
page: number,
page_size: number,
}
3.4.2 API
层
这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在src/api/service.ts
编写如下代码:
export function getCatalogTree() {
return request({
url: '/catalog/',
method: 'get',
}) as unknown as Array<Catalog>
}
export function saveCatalog(method: string, data: Catalog) {
let url = '/catalog/'
if (['put', 'patch'].includes(method)) {
url += data.id + '/'
}
// @ts-ignore
return request({
url,
method,
data,
}) as unknown as ResponseData
}
export function deleteCatalog(catalogId: number) {
return request({
url: '/catalog/' + catalogId + '/',
method: 'delete',
}) as unknown as ResponseData
}
export function getArticleList(params: ArticleParams) {
return request({
url: '/list/',
method: 'get',
params
}) as unknown as ArticleArray
}
export function remoteDeleteArticle(articleId: number) {
return request({
url: '/article/' + articleId + '/',
method: 'delete',
}) as unknown as ResponseData
}
export function getArticleDetail(articleId: number) {
return request({
url: '/article/' + articleId + '/',
method: 'get',
}) as unknown as Article
}
export function remoteSaveArticle(method: string, data: Article) {
let url = '/article/'
if (['put', 'patch'].includes(method)) {
url += data.id + '/'
}
// @ts-ignore
return request({
url,
method,
data,
}) as unknown as Article
}
export function remotePublishArticle(articleId: number) {
// @ts-ignore
return request({
url: '/publish/' + articleId + '/',
method: 'patch',
}) as unknown as Article
}
export function remoteOfflineArticle(articleId: number) {
return request({
url: '/offline/' + articleId + '/',
method: 'patch',
}) as unknown as Article
}
3.4.3 Component
层
提供一个管理分类的抽屉组件,因此在src/components
下创建文件CatalogTree.vue
,编写代码如下:
<template>
<el-drawer
v-model="state.visible"
:before-close="handleClose"
direction="rtl"
size="500px"
title="目录管理"
@opened="handleSearch"
>
<div class="drawer-content">
<el-tree
:data="state.catalogs"
:expand-on-click-node="false"
:props="defaultProps"
default-expand-all
node-key="id">
<template #default="{ node, data }">
<span class="custom-tree-node">
<span>{{ node.label }}</span>
<span>
<el-dropdown trigger="click">
<span class="el-dropdown-link">
<i class="el-icon-more"/>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="el-icon-edit">
<a class="more-button" @click.prevent="showEditDialog(data)">
修改
</a>
</el-dropdown-item>
<el-dropdown-item icon="el-icon-circle-plus">
<a class="more-button" @click.prevent="showAddDialog(data)">
新增
</a>
</el-dropdown-item>
<el-dropdown-item icon="el-icon-delete-solid">
<el-popconfirm :title="'确定删除【'+data.name+'】?'" cancelButtonText='取消' confirmButtonText='删除'
icon="el-icon-info" iconColor="red" @confirm="remove(data)">
<template #reference>
<a class="more-button">
删除
</a>
</template>
</el-popconfirm>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</span>
</template>
</el-tree>
</div>
</el-drawer>
<el-dialog v-model="state.showDialog" :title="state.dialogTitle">
<el-form class="form" label-suffix=":" label-width="120px" size="medium">
<el-form-item label="目录名称">
<el-input v-model="state.catalog.name" autocomplete="off"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showDialog=false">取 消</el-button>
<el-button :loading="state.loading" type="primary" @click="saveCatalog">保 存</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Catalog} from "../types";
import {deleteCatalog, getCatalogTree, saveCatalog} from "../api/service";
import {ElMessage} from "element-plus";
export default defineComponent({
name: "CatalogTree",
props: {
visible: {
type: Boolean,
require: true,
}
},
watch: {
'$props.visible': {
handler(val, oldVal) {
if (val != oldVal) {
this.state.visible = val
}
}
}
},
emits: ['close',],
setup(props) {
const state = reactive({
catalogs: [] as Array<Catalog>,
visible: props.visible,
showDialog: false,
catalog: {} as Catalog,
dialogTitle: '',
loading: false,
})
const handleSearch = async () => {
state.catalogs = await getCatalogTree();
}
const defaultProps = {
children: 'children',
label: 'name',
}
return {
state,
handleSearch,
defaultProps
}
},
methods: {
handleClose() {
this.$emit('close')
},
showAddDialog(data: Catalog) {
this.state.showDialog = true
//@ts-ignore
this.state.catalog.id = undefined
//@ts-ignore
this.state.catalog.name = undefined
this.state.catalog.parent = data.id
this.state.dialogTitle = '新增目录'
},
showEditDialog(data: Catalog) {
this.state.showDialog = true
this.state.catalog = data
this.state.dialogTitle = '修改目录'
},
async saveCatalog() {
try {
this.state.loading = true
const method = this.state.catalog.id ? 'patch' : 'post'
await saveCatalog(method, this.state.catalog)
this.state.loading = false
this.state.showDialog = false
ElMessage({
message: '保存成功',
type: 'success'
})
await this.handleSearch()
} catch (e) {
console.error(e)
ElMessage({
message: '保存失败',
type: 'error'
})
this.state.loading = false
}
},
async remove(data: Catalog) {
await deleteCatalog(data.id)
ElMessage({
message: '删除成功',
type: 'success'
})
await this.handleSearch()
}
}
})
</script>
<style lang="less" scoped>
.drawer-content {
padding: 12px 0 0 24px;
border-top: #eeeeee 1px solid;
overflow: auto;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 32px;
}
.add-button {
margin-bottom: 12px;
}
</style>
由于文章管理的界面需要有Markdown编辑器,因此安装markdown编辑器的依赖
yarn add @kangc/v-md-editor@2.3.5
yarn add highlight.js@10.7.2
在main.ts
中增加编辑器的 js
、css
和插件
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import { StateKey, store } from "./store";
import 'element-plus/lib/theme-chalk/index.css';
import 'element-plus/lib/theme-chalk/base.css';
// @ts-ignore
import VMdEditor from '@kangc/v-md-editor';
import '@kangc/v-md-editor/lib/style/base-editor.css';
// @ts-ignore
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js';
import '@kangc/v-md-editor/lib/theme/style/github.css';
// highlightjs
import hljs from 'highlight.js';
VMdEditor.use(githubTheme, {
Hljs: hljs,
});
import {
ElAffix,
ElButton,
ElCard,
ElCascader,
ElCol,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElLoading,
ElMenu,
ElMenuItem,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElPopconfirm,
ElProgress,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTimeline,
ElTimelineItem,
ElTooltip,
ElTree,
ElUpload,
} from 'element-plus';
const app = createApp(App)
const components = [
ElAffix,
ElButton,
ElCard,
ElCascader,
ElCol,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElLoading,
ElMenu,
ElMenuItem,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElPopconfirm,
ElProgress,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTimeline,
ElTimelineItem,
ElTooltip,
ElTree,
ElUpload,
]
const plugins = [
ElLoading,
ElMessage,
ElMessageBox,
]
components.forEach(component => {
app.component(component.name, component)
})
plugins.forEach(plugin => {
app.use(plugin)
})
app.use(router).use(store, StateKey).use(VMdEditor).mount('#app')
提供一个编辑文章的抽屉组件,因此在src/components
下创建文件EditArticle.vue
,编写代码如下:
<template>
<el-drawer
v-model="state.visible"
:before-close="handleClose"
:title="articleId?'修改文章':'新增文章'"
direction="rtl"
size="800px"
@opened="handleSearch"
>
<div class="article-form" style="overflow-y: auto">
<el-form label-suffix=":" label-width="120px">
<el-form-item label="标题">
<el-input ref="articleTitle" v-model="state.article.title"></el-input>
</el-form-item>
<el-form-item label="所属分类">
<el-cascader v-model="state.catalogs" :options="state.catalogTree"
:props="{ checkStrictly: true, value:'id',label:'name',expandTrigger: 'hover'}"
clearable
size="medium"
style="width: 100%"/>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="state.article.tags" clearable multiple placeholder="请选择文章标签" size="medium"
style="width: 100%">
<el-option v-for="s in state.tags" :label="s.name" :value="s.id" :key="s.id"/>
</el-select>
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="state.article.excerpt" :rows="5" type="textarea"></el-input>
</el-form-item>
<el-form-item label="正文">
<v-md-editor v-model="state.article.markdown" height="600px"></v-md-editor>
</el-form-item>
<el-form-item label="封面">
<el-upload
:before-upload="beforeAvatarUpload"
:headers="csrfToken"
:on-success="handleAvatarSuccess"
:show-file-list="false"
action="/api/upload/"
class="avatar-uploader"
>
<img v-if="state.article.cover" :src="state.article.cover" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
</el-form>
</div>
<div class="demo-drawer__footer">
<el-button @click="handleClose">取消</el-button>
<el-button :loading="state.loading" type="primary" @click="saveArticle">保存</el-button>
</div>
</el-drawer>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {getArticleDetail, getCatalogTree, getTagList, remoteSaveArticle} from "../api/service";
import {Article, Catalog, Tag, TagList} from "../types";
import {getCookie} from "../utils";
export default defineComponent({
name: "EditArticle",
props: {
articleId: {
type: Number,
require: true,
default: undefined,
},
visible: {
type: Boolean,
require: true,
}
},
watch: {
'$props.visible': {
handler(val: Boolean, oldVal: Boolean) {
if (val !== oldVal) {
this.state.visible = val
}
}
}
},
emits: ["close",],
setup(props, context) {
const state = reactive({
article: {} as Article,
loading: false,
visible: false as Boolean,
catalogTree: [] as Array<Catalog>,
tags: [] as Array<Tag>,
catalogs: [] as Array<number>
})
const saveArticle = async () => {
try {
state.loading = true
if (state.catalogs.length) {
state.article.catalog = state.catalogs[state.catalogs.length - 1]
}
if (props.articleId) {
await remoteSaveArticle('put', state.article)
} else {
await remoteSaveArticle('post', state.article)
}
state.loading = false
context.emit('close', true)
} catch (e) {
state.loading = false
}
}
const csrfToken = {'X-CSRFToken': getCookie('csrftoken')}
return {
state, saveArticle, csrfToken
}
},
methods: {
async handleSearch() {
this.$refs.articleTitle.focus()
if (this.$props.articleId) {
this.state.article = await getArticleDetail(this.$props.articleId)
this.state.article.tags = this.state.article.tags_info.map((tag: Tag) => tag.id)
this.state.catalogs = this.state.article.catalog_info.parents
} else {
this.state.article = {} as Article
}
this.state.catalogTree = await getCatalogTree()
if (!this.state.tags.length) {
const tags: TagList = await getTagList({})
this.state.tags = tags.results
}
},
handleClose(done: any) {
this.$confirm('确认关闭抽屉?', '提示', {
confirmButtonText: '关闭',
cancelButtonText: '取消',
type: 'warning'
})
.then((_: any): void => {
this.$emit("close", false)
this.state.article = {} as Article
done();
})
.catch((_: any): void => {
console.error(_)
});
},
handleAvatarSuccess(res: any, file: File) {
this.state.article.cover = res.url
},
beforeAvatarUpload(file: File) {
const isImage = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'].includes(file.type);
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
this.$message.error('上传图片只能是 JPG 格式!');
}
if (!isLt2M) {
this.$message.error('上传图片大小不能超过 2MB!');
}
return isImage && isLt2M;
}
}
})
</script>
<style lang="less">
.article-form {
padding: 24px;
overflow-y: auto;
border-top: 1px solid #e8e8e8;
height: calc(100% - 100px);
}
//抽屉//element-ui的drawer固定底部按钮
.el-drawer .el-drawer__body{
margin-bottom: 50px ;
height: 100% !important;
}
.el-drawer__header{
margin-bottom: 16px;
}
.demo-drawer__footer {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
border-top: 1px solid #e8e8e8;
padding: 10px 16px;
text-align: right;
background-color: white;
}
//抽屉//去掉element-ui的drawer标题选中状态
:deep(:focus){
outline: 0;
}
.avatar-uploader {
background-color: #fbfdff;
border: 1px dashed #c0ccda;
border-radius: 6px;
box-sizing: border-box;
width: 125px;
height: 100px;
cursor: pointer;
line-height: 100px;
text-align: center;
font-size: 20px;
}
</style>
3.4.4 View
层
通过表格管理文章,通过树形组件管理分类,在src/views/admin
下新增文件Article.vue
文件,编写如下代码:
<template>
<div>
<div>
<el-form :inline="true" class="demo-form-inline">
<el-form-item label="标题">
<el-input ref="title" v-model="state.params.title" placeholder="文章标题"/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="state.params.status" placeholder="状态">
<el-option label="已发布" value="Published"/>
<el-option label="草稿" value="Draft"/>
</el-select>
</el-form-item>
<el-form-item>
<el-button :loading="state.isLoading" type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<div class="button-container">
<el-button :loading="state.isLoading" type="primary" @click="showAddDrawer"><i class="el-icon-plus"/> 新 增
</el-button>
<el-button circle icon="el-icon-s-unfold" @click="state.showCatalogTree=true"/>
</div>
<div>
<el-table ref="articleTable" :data="state.articleList" :header-cell-style="{background:'#eef1f6',color:'#606266'}" stripe
style="width: 100%">
<el-table-column type="selection" width="55"/>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="标题" prop="title" width="200"/>
<el-table-column label="状态" prop="status" width="100"/>
<el-table-column label="分类" prop="catalog_info.name"/>
<el-table-column :formatter="datetimeFormatter" label="修改时间" prop="modified_at"/>
<el-table-column fixed="right" label="操作" width="120">
<template #default="scope">
<el-popconfirm cancelButtonText='取消' confirmButtonText='删除' icon="el-icon-info" iconColor="red"
title="确定删除该文章吗?" @confirm="deleteArticle(scope.$index,scope.row)">
<template #reference>
<el-button size="small" type="text">
删除
</el-button>
</template>
</el-popconfirm>
<el-button size="small" type="text" @click.prevent="showEditDrawer(scope.$index, scope.row)">
编辑
</el-button>
<el-button v-if="scope.row.status==='草稿'" size="small" type="text"
@click.prevent="publishArticle(scope.$index, scope.row)">
发布
</el-button>
<el-button v-else size="small" type="text"
@click.prevent="offlineArticle(scope.$index, scope.row)">
下线
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination :page-size="10" :total="state.total" background
layout="prev, pager, next"></el-pagination>
</div>
</div>
<EditArticle
:article-id="state.articleId"
:visible="state.showDrawer"
@close="handleCloseDrawer"
/>
<CatalogTree
:visible="state.showCatalogTree"
@close="state.showCatalogTree=false"
/>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article, ArticleArray, ArticleParams} from "../../types";
import {getArticleList, remoteDeleteArticle, remoteOfflineArticle, remotePublishArticle} from "../../api/service";
import {timestampToTime} from "../../utils";
import {ElMessage} from "element-plus";
import EditArticle from "../../components/EditArticle.vue";
import CatalogTree from "../../components/CatalogTree.vue";
export default defineComponent({
name: "Article",
components: {CatalogTree, EditArticle},
setup: function () {
const state = reactive({
articleList: [] as Array<Article>,
params: {
title: undefined,
status: undefined,
tags: undefined,
catalog: undefined,
page: 1,
page_size: 10,
} as ArticleParams,
isLoading: false,
total: 0,
showDrawer: false,
articleId: 0,
showCatalogTree: false,
});
const handleSearch = async (): Promise<void> => {
state.isLoading = true;
try {
const data: ArticleArray = await getArticleList(state.params);
state.isLoading = false;
state.articleList = data.results;
state.total = data.count
} catch (e) {
console.error(e)
state.isLoading = false;
}
};
const publishArticle = async (index: number, row: Article) => {
try {
await remotePublishArticle(row.id)
ElMessage({
message: "发布成功!",
type: "success",
});
await handleSearch()
} catch (e) {
console.error(e)
}
}
const offlineArticle = async (index: number, row: Article) => {
try {
await remoteOfflineArticle(row.id)
ElMessage({
message: "下线成功!",
type: "success",
});
await handleSearch()
} catch (e) {
console.error(e)
}
}
const deleteArticle = async (index: number, row: Article) => {
await remoteDeleteArticle(row.id);
ElMessage({
message: "删除成功!",
type: "success",
});
await handleSearch()
}
const datetimeFormatter = (row: Article, column: number, cellValue: string, index: number) => {
return timestampToTime(cellValue, true);
}
handleSearch()
const handleCloseDrawer = (isOk: boolean) => {
state.showDrawer = false
if (isOk) {
handleSearch()
}
}
return {
state,
handleSearch,
datetimeFormatter,
deleteArticle,
handleCloseDrawer,
publishArticle,
offlineArticle
}
},
mounted() {
this.$refs.title.focus()
},
methods: {
showEditDrawer(index: number, row: Article) {
this.$refs.articleTable.setCurrentRow(row)
this.state.showDrawer = true;
this.state.articleId = row.id
},
showAddDrawer() {
this.state.showDrawer = true;
this.state.articleId = 0;
}
}
})
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
3.4.5 Router
层
定义route
来完成路由跳转。在src/route/index.ts
文件中新增代码:
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import("../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import("../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/user',
name: 'UserManagement',
component: () => import("../views/admin/User.vue"),
},
{
path: '/admin/tag',
name: 'Tag',
component: () => import("../views/admin/Tag.vue"),
},
{
path: '/admin/article',
name: 'ArticleManagement',
component: () => import("../views/admin/Article.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
3.4.6 vite.config.ts
由于我们需要展示对上传后的图片,因此需要对上传后的图片代理,在vite.config.ts
文件中,增加如下代理:
'/upload': {
target: 'http://localhost:8000/',
changeOrigin: true,
ws: false,
rewrite: (pathStr) => pathStr.replace('/api', ''),
timeout: 5000,
},
3.5 评论管理
3.5.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface CommentInfo {
id: number,
user: number,
user_info: User | any,
article: number,
article_info: Article | any,
created_at: string,
reply: number | any,
content: string,
comment_replies: CommentInfo | any,
}
export interface CommentPara {
user: number,
article: number,
reply: number | any,
content: string,
page: number,
page_size: number
}
3.5.2 API
层
这里要处理列表查询。在src/api/service.ts
编写如下代码:
export function getCommentList(params: CommentPara) {
return request({
url: '/comment/',
method: 'get',
params,
}) as unknown as ResponseData
}
3.5.3 Component
层
由于评论无需要做修改删除等操作,只有查看评论详情,因此复用文章详情页面。
3.5.4 View
层
通过表格查看评论,在src/views/admin
下新增文件Comment.vue
文件,编写如下代码:
<template>
<div>
<div>
<el-form :inline="true" :model="state.params" class="demo-form-inline">
<el-form-item label="账号">
<el-select v-model="state.params.user" filterable placeholder="请选择">
<el-option
v-for="item in state.userList"
:key="item.id"
:label="item.nickname || item.username"
:value="item.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="state.params.content" placeholder="评论内容"/>
</el-form-item>
<el-form-item>
<el-button :loading="state.loading" type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<div>
<el-table ref="articleTable" :data="state.commentList" :header-cell-style="{background:'#eef1f6',color:'#606266'}"
stripe>
<el-table-column type="selection" width="55"/>
<el-table-column label="ID" prop="id" width="80"/>
<el-table-column label="评论者" prop="user_info.name" width="200"/>
<el-table-column label="评论内容" prop="content" width="200"/>
<el-table-column label="文章" prop="article_info.title"/>
<el-table-column label="回复评论" prop="reply.id" width="200"/>
<el-table-column :formatter="datetimeFormatter" label="评论时间" prop="created_at"/>
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" type="text"
@click.prevent="showDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination :page-size="10" :total="state.total" background
layout="prev, pager, next"></el-pagination>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article, CommentInfo, CommentPara, ResponseData, User} from "../../types";
import {ElMessage} from "element-plus";
import {timestampToTime} from "../../utils";
import {getCommentList, getUserList, saveUser} from "../../api/service";
import UserDetail from "../../components/UserDetail.vue";
export default defineComponent({
name: "Comment",
components: {UserDetail},
setup: function () {
const state = reactive({
commentList: [] as Array<CommentInfo>,
params: {
user: undefined,
article: undefined,
reply: undefined,
content: '',
page: 1,
page_size: 10,
} as unknown as CommentPara,
total: 0,
userList: [] as Array<User>,
loading: false,
});
const handleSearch = async (): Promise<void> => {
state.loading = true;
try {
const data: ResponseData = await getCommentList(state.params);
state.loading = false;
state.commentList = data.results;
state.total = data.count
} catch (e) {
console.error(e)
state.loading = false;
}
};
const getUsers = async (): Promise<void> => {
try {
const data: ResponseData = await getUserList({});
state.userList = data.results;
} catch (e) {
console.error(e)
}
};
const datetimeFormatter = (row: Article, column: number, cellValue: string, index: number) => {
return timestampToTime(cellValue, true);
}
handleSearch()
getUsers()
return {
state,
handleSearch,
datetimeFormatter,
}
},
methods: {
showDetail(row: CommentInfo) {
const {href} = this.$router.resolve({
path: '/article/',
query: {
id: row.article_info.id
}
});
window.open(href, "_blank");
},
}
})
</script>
<style scoped>
.pagination {
text-align: right;
margin-top: 12px;
}
</style>
3.5.5 Router
层
定义route
来完成路由跳转。在src/route/index.ts
文件中新增代码:
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import Home from "../views/client/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import(/* webpackChunkName: "login" */ "../views/admin/Login.vue")
},
{
path: '/admin',
name: 'Admin',
component: () => import(/* webpackChunkName: "admin" */ "../views/admin/Admin.vue"),
children: [
{
path: '/admin/',
name: 'Dashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import("../views/admin/Dashboard.vue"),
},
{
path: '/admin/user',
name: 'UserManagement',
component: () => import("../views/admin/User.vue"),
},
{
path: '/admin/tag',
name: 'Tag',
component: () => import("../views/admin/Tag.vue"),
},
{
path: '/admin/article',
name: 'ArticleManagement',
component: () => import("../views/admin/Article.vue"),
},
{
path: '/admin/comment',
name: 'CommentManagement',
component: () => import("../views/admin/Comment.vue"),
},
]
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
3.6 管理后台首页
3.6.1 Type
层
在src/types/index.ts
文件中增加代码如下:
export interface NumberInfo {
views: number,
likes: number,
comments: number,
messages: number
}
3.6.2 API
层
这里要编写标签管理相关的接口,列表查询、新增、修改、删除。在src/api/service.ts
编写如下代码:
export function getTopArticleList() {
return request({
url: '/top/',
method: 'get',
}) as unknown as ResponseData
}
export function getNumbers() {
return request({
url: '/number/',
method: 'get',
}) as unknown as NumberInfo
}
3.6.3 Component
层
无需提供额外的组件。
3.6.4 View
层
通过图标和指标卡的形式展示网站的整体情况,修改src/views/admin/Dashboard.vue
,编写如下代码:
<template>
<div>
<div class="title">今日博客访问情况</div>
<el-row :gutter="24" class="numbers">
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-user number-icon"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.views }}</div>
<div>用户访问量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-thumb number-icon" style="background: #64d572;"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.likes }}</div>
<div>点赞量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-chat-line-square number-icon" style="background: #f25e43;"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.comments }}</div>
<div>评论量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6" class="el-col-6">
<el-card>
<div class="number-card">
<div>
<i class="el-icon-message number-icon" style="background-color: #42B983"></i>
</div>
<div class="number-right">
<div class="number-num">{{ state.numbers.messages }}</div>
<div>留言量</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div class="top-articles">
<el-card>
<template #header>
文章访问量TOP10
</template>
<div class="article-list">
<div v-for="( article,index) in state.articleList" class="article" @click="viewArticle(article.id)">
<span style="font-size: 14px">{{ index + 1 + '. ' + article.title }}</span>
<span style="color: #999999; font-size: 14px">{{ article.views }} / {{ article.likes }}</span>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, reactive} from "vue";
import {Article} from "../../types";
import {getNumbers, getTopArticleList} from "../../api/service";
export default defineComponent({
name: "Dashboard",
setup() {
const state = reactive({
numbers: {
views: 0,
likes: 0,
comments: 0,
messages: 0
},
articleList: [{title: 'a', views: 1, likes: 1}] as Array<Article>,
})
return {
state,
}
},
async mounted() {
this.state.articleList = (await getTopArticleList()).results
this.state.numbers = await getNumbers()
},
methods: {
viewArticle(id: number) {
const {href} = this.$router.resolve({
path: '/article/',
query: {
id
}
});
window.open(href, "_blank");
}
}
})
</script>
<style lang="less" scoped>
.numbers {
width: 100%;
}
.title {
color: #999;
margin: 12px 0;
padding-left: 8px;
font-size: 14px;
}
:deep(.el-card__body){
margin: 0;
padding: 0;
}
.number-card {
margin: 0;
padding: 0;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
flex: 1;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 80px;
border: 1px solid #ebeef5;
background-color: #fff;
border-radius: 4px;
overflow: hidden;
}
.number-right {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
text-align: center;
font-size: 14px;
color: #999;
}
.number-num {
font-size: 30px;
font-weight: 700;
color: #2d8cf0;
text-align: center;
}
.number-icon {
font-size: 50px;
width: 80px;
height: 80px;
text-align: center;
line-height: 80px;
color: #fff;
background: #2d8cf0;
}
.top-articles {
margin: 24px 24px 24px 0;
}
.article-list {
padding: 20px;
}
.article {
cursor: pointer;
display: flex;
flex: 1;
justify-content: space-between;
padding: 12px 24px 12px 12px;
border-top: #eeeeee 1px solid;
}
.article:first-child {
border-top: none;
padding-top: 0;
}
.article:last-child {
padding-bottom: 0;
}
.dashboard-list {
display: flex;
flex: 1;
justify-content: space-evenly;
padding: 24px;
margin-right: 24px;;
}
.percentage-value {
display: block;
margin-top: 10px;
font-size: 28px;
}
.percentage-label {
display: block;
margin-top: 10px;
font-size: 12px;
}
</style>
3.6.5Router
层
管理后台已经开发完成,因此需要在路由中做好权限控制,当访问admin路径的时候,需要判断用户是否登录,且用户是否是管理员,因此在src/router/index.ts
中增加如下代码:
router.beforeEach((to, from, next) => {
if (/\/admin/i.test(to.path)
&& (!store.state.user.id ||
store.state.user.role !== 'Admin')) {
next('/login')
return
}
next()
})
在src/views/admin/Login.vue
中第143行后增加一行代码:
is_superuser: data.is_superuser
至此管理后台的前端开发完成
四、实现效果展示
4.1 后端效果
4.1.1 访问API文档
在浏览器中访问http://127.0.0.1:8000/swagger/
,效果如下图:
4.1.2 后端文件夹结构
4.2 前端效果
4.2.1 前端管理后台页面效果
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VDIqfxx1-1629884294151)(/Users/zhou/Desktop/image-20210822221737560.png)]
4.2.2 前端代码结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tcABDlxh-1629884294152)(/Users/zhou/Desktop/image-20210822222209555.png)]
下一篇我们编写博客网站给用户使用的页面。