BBS项目-后台管理

1 后台管理页面搭建

参考博客园后台页面

1.1 拷贝导航条代码

1.2 正文部分

正文部分侧边栏和主页比例分配2:10

<div class="container-fluid">
    <div class="row">
        <div class="col-md-2"></div>
        <div class="col-md-10"></div>
    </div>
</div>

侧边栏拷贝bootstrap模板的列表组样式

<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
    <div class="panel panel-default">
        <div class="panel-heading" role="tab" id="headingOne">
            <h4 class="panel-title">更多操作</h4>
        </div>
        
        <div id="collapseOne" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="headingOne">
            <div class="panel-body">
                <a href="/add/article/">添加文章</a>
            </div>
        </div>
        
        <div id="collapseOne" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="headingOne">
            <div class="panel-body">
                <a href="">添加随笔</a>
            </div>
        </div>
        
        <div id="collapseOne" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="headingOne">
            <div class="panel-body">
                <a href="">草稿箱</a>
            </div>
        </div>
        
        <div id="collapseOne" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="headingOne">
            <div class="panel-body">
                <a href="">更多设置</a>
            </div>
        </div>
    </div>
</div>

主页拷贝bootstrap模板的标签页样式,每个标签关联的正文部分制作成block块,导航条+侧边栏+标签为一个整体的后台页面模板backend_base.html

<div>
    <!-- Nav tabs -->
    <ul class="nav nav-tabs" role="tablist">
        <li role="presentation" class="active"><a href="#home" aria-controls="home" role="tab" data-toggle="tab">文章</a></li>
        <li role="presentation"><a href="#profile" aria-controls="profile" role="tab" data-toggle="tab">随笔</a></li>
        <li role="presentation"><a href="#file" aria-controls="file" role="tab" data-toggle="tab">文件</a></li>
        <li role="presentation"><a href="#messages" aria-controls="messages" role="tab" data-toggle="tab">草稿</a></li>
        <li role="presentation"><a href="#settings" aria-controls="settings" role="tab" data-toggle="tab">设置</a></li>
    </ul>

    <!-- Tab panes -->
    <div class="tab-content">
        <div role="tabpanel" class="tab-pane active" id="home">
            {% block article %}

            {% endblock %}
        </div>
        <div role="tabpanel" class="tab-pane" id="profile">
            {% block suibi %}

            {% endblock %}
        </div>
        <div role="tabpanel" class="tab-pane" id="file">
            {% block file %}

            {% endblock %}
        </div>
        <div role="tabpanel" class="tab-pane" id="messages">
            {% block caogao %}

            {% endblock %}
        </div>
        <div role="tabpanel" class="tab-pane" id="settings">
            {% block settings %}

            {% endblock %}
        </div>
    </div>
</div>

1.3 内容展示

以文章为例,继承backend_base.html模板,在article的block块渲染一个table标签,从后端获取该用户所有文章进行展示。

{% block article %}
{# 展示当前用户所有文章 #}
    <table class="table table-striped table-hover">
        <thead>
            <tr>
                <th>文章标题</th>
                <th>点赞数</th>
                <th>评论数</th>
                <th>操作</th>
                <th>操作</th>
            </tr>
        </thead>
        <tbody>
            {% for article_obj in page_queryset %}
                <tr>
                    <td><a href="/{{ request.user.username}}/article/{{ article_obj.pk }}">{{ article_obj.title }}</a></td>
                    <td>{{ article_obj.up_num }}</td>
                    <td>{{ article_obj.comment_num }}</td>
                    <td><a href="{% url 'edit_article' article_obj.pk %}" class="btn btn-primary btn-xs">编辑</a></td>
                    <td><button class="btn btn-danger btn-xs del" delete_id="{{ article_obj.pk }}">删除</button></td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
    <div class="pull-right">{{ page_obj.page_html|safe }}</div>
{% endblock %}

后端view

@login_required
def backend(request):
    # 获取当前用户对象所有文章展示到页面
    article_list = models.Article.objects.filter(blog=request.user.blog)
    # 生成分页器对象
    current_page = request.GET.get('page', 1)
    all_count = article_list.count()
    page_obj = Pagination(current_page=current_page, all_count=all_count, per_page_num=9)
    page_queryset = article_list[page_obj.start:page_obj.end]
    return render(request, 'backend/backend.html', locals())

 

2 添加文章

点击侧边栏添加文章标签,跳转到添加文章页面,如下:

2.1 因为导航条、侧边栏、标签都没有变动,因此导入backend_base.html模板

{% extends 'backend/backend_base.html' %}


{% block article %}
    <h3>添加文章</h3>
    <p>标题</p>
    <div>
        <input type="text" name="title" class="form-control" id="id_title">
        <span style="color: red;"></span>
    </div>
    <p>内容</p>
    <div>
        <textarea name="content" id="id_content" cols="30" rows="10"></textarea>
    </div>
    <p>个人分类</p>
    <div>
        {% for category in category_list %}
            <input type="radio" value="{{ category.pk }}" name="category" id="id_category">{{ category.name }}
        {% endfor %}

    </div>
    <hr>
    <p>个人标签</p>
    <div>
        {% for tag in tag_list %}
            <input type="checkbox" value="{{ tag.pk }}" name="tags" tag_id="{{ tag.pk }}">{{ tag.name }}
        {% endfor %}
    </div>
    <br>
    <input type="button" class="btn btn-danger" value="发布文章" id="id_commit">
{% endblock %}

2.2 富文本编辑器的使用

1、下载kindeditor,拷贝文件放到static文件夹

2、在需要用编辑器的页面,导入kindeditor-all-min.js

3、看文档,配置初始参数

{% block js %}
{#    富文本编辑器的使用#}
    {% load static %}
    <script charset="utf-8" src="{% static 'kindeditor/kindeditor-all-min.js' %}"></script>

    <script>
        KindEditor.ready(function (K) {
            window.editor = K.create('#id_content', {   //关联textarea标签的id
                width: '100%',                  
                height: '300px',
                resizeType: 1,                  // 控制只能垂直拉伸,不能水平拉伸
                uploadJson: '/upload_image/',   // 上传图片的后端提交路径,要在urls.py中开一个接口
                extraFileUploadParams: {        // post请求中间件,用它提供的额外的文件提交参数
                    'csrfmiddlewaretoken': '{{ csrf_token }}'
                },
                // 当Ajax提交post请求时,textarea的value还是空的,需要使用sync()去同步HTML数据
                // 那么在什么时候去同步,怎么同步?编辑器失去焦点(blur)时执行的回调函数
                afterBlur: function () {
                    this.sync()
                }
            });
        });
    </script>
{% endblock %}

2.3 用ajax发送post请求

// 点击发布文章向后端发送ajax请求
$('#id_commit').click(function () {
    // 复选的input框,不能用val()直接获取值;针对checkbox多选,先定义一个空列表
    let tagsList = [];
    // 前端如果勾选了,该标签默认为checked,通过属性查找到被勾选的标签;each遍历取值,添加到空列表中,利用attr(自定义属性)获取对应的值
    $("input[name='tags']:checked").each(function () {
        tagsList.push($(this).attr('tag_id'))
    });

    $.ajax({
        url: '',
        type: 'post',
        contentType: 'application/json',
        data: JSON.stringify({
            'title': $('#id_title').val(),
            'content': $('#id_content').val(),
            'category': $('#id_category:checked').val(),   // radio单选框不能直接val()取值,要查找到被checked的标签
            'tags': tagsList,   // 数组对象后端收不到,需要先序列化成字符类型JSON.stringify(tagslist),这里已经整体序列化为json格式
        }),
        success: function (args) {
            if (args.code === 0) {
                alert(args.msg);
                window.location.href = args.url
            } else {
                $('#id_title').focus().next().text(args.msg)
            }
        }
    })
})

// 给标题输入框绑定oninput事件,实时监控输入内容
$('#id_title').on('input', function () {
    if ($(this).val() !== '') {
        $(this).next().text('')
    } else {
        $(this).next().text('请输入文章标题')
    }
})

2.4 XSS攻击和文章简介处理

第一、针对支持用户直接编写html代码的网站,防止用户直接书写script标签造成XSS攻击,我们需要进行处理:注释掉标签内部的内容或者直接将script标签删除。

第二、文章的简介不能直接从文章内容截取(截取出来包含了标签),应该先想办法获取到当前页面的文本内容之后,再截取一定数量的文本字符。

针对上述两个问题,我们在后端,利用beautifulsoup模块帮忙处理。

2.5 自定义中间件

用来解决前后端分离json格式交互,需要到body中解码,再反序列化的复杂操作

from django.utils.deprecation import MiddlewareMixin
import json

"""
自定义一个中间件,在request对象里加一个data属性, post提交过来的数据都可以从request.data提取出字典
用来解决前后端分离json格式交互,需要到body中解码,再反序列化的复杂操作
用捕获异常,处理三种可能:
1、前端传json格式,后端json.loads直接获取,拿到字典
2、前端传urlencoded格式,后端捕获异常,让request.POST获取
3、前端传文件,后端捕获异常,也让request.POST获取,注意:文件对象还是在FILES里
"""


class MyJsonMiddleware(MiddlewareMixin):
    def process_request(self, request):
        try:
            request.data = json.loads(request.body)
        except Exception as e:
            request.data = request.POST

 2.6 后端 view 完整代码

@login_required
def add_article(request):
    if request.is_ajax():
        back_dic = {'code': 0, 'msg': ''}
        # 利用自定义中间件,request对象中的data属性,直接获取字典格式,利用get取值
        title = request.data.get('title')
        content = request.data.get('content')
        category_id = request.data.get('category')
        tags_id_list = request.data.get('tags')  # get拿到的是列表,['4', '5', '6'] <class 'list'>

        # bs4模块使用,调用类-->传入文本内容、解析器-->生成soup对象,使用python内置解析器parser
        soup = BeautifulSoup(content, 'html.parser')
        # tags拿到整个html页面所有内容,包括标签和文本,它支持for循环
        tags = soup.find_all()
        for tag in tags:
            # 筛选出script标签、删除掉,防止XSS跨站脚本攻击
            if tag.name == 'script':
                tag.decompose()
        # soup对象拿到文本内容,截取150个文本字符,作为文章简介
        desc = soup.text[0:150]

        if not title == '':
            # 判断标题,如果不为空字符串,操作文章表,录入数据
            article_obj = models.Article.objects.create(
                title=title,
                desc=desc,
                content=str(soup),       # 处理了文本标签(删除了script标签),录入的文本数据应该是soup对象
                categorize_id=category_id,
                blog=request.user.blog   # 文章表还有个blog外键,绑定当前文章属于哪一个个人站点
            )
            """
            文章和标签第三张关系表是半自动创建,没有add、remove、set、clear方法
            自己去操作文章和标签表写入数据,文章和标签多对多关系,可能一次性添加多条数据,为了提升效率,可以采用批量插入的方法
            """
            article_tag_list = []
            for i in tags_id_list:  # ['1', '2', '3'],循环出来的是字符,要转整型
                # 先生成对象
                article_tag_obj = models.Article2Tag(article_id=article_obj.pk, tag_id=int(i))
                article_tag_list.append(article_tag_obj)
            models.Article2Tag.objects.bulk_create(article_tag_list)
            back_dic['msg'] = '文章发布成功'
            back_dic['url'] = '/backend/'
        else:
            back_dic['code'] = 1
            back_dic['msg'] = '请输入文章标题'
        return JsonResponse(back_dic)
    # 获取当前用户的所有分类和标签,渲染到前端页面
    category_list = models.Categorize.objects.filter(blog=request.user.blog)
    tag_list = models.Tag.objects.filter(blog=request.user.blog)
    return render(request, 'backend/add_article.html', locals())

 

3 修改文章

前端渲染的部分跟添加文章相似,导入backend_base.html模板

{% block article %}
    <h3>修改文章</h3>
    <p>标题</p>
    <div>
        <input type="text" name="title" class="form-control" id="id_title" value="{{ edit_obj.title}}">
        <span style="color: red;"></span>
    </div>
    <p>内容</p>
    <div>
        <textarea name="content" id="id_content" cols="30" rows="10">{{ edit_obj.content }}</textarea>
    </div>
    <p>个人分类</p>
    <div>
        {% for category in category_list %}
            {% if edit_obj.categorize == category %}
                <input type="radio" value="{{ category.pk }}" name="category" id="id_category" checked> {{ category.name }}
            {% else %}
                <input type="radio" value="{{ category.pk }}" name="category" id="id_category">{{ category.name }}
            {% endif %}
        {% endfor %}
    </div>
    <hr>
    <p>个人标签</p>
    <div>
        {% for tag in tag_list %}
            {% if tag in edit_obj.tags.all %}
                <input type="checkbox" value="{{ tag.pk }}" name="tags" tag_id="{{ tag.pk }}" checked>{{ tag.name }}
            {% else %}
                <input type="checkbox" value="{{ tag.pk }}" name="tags" tag_id="{{ tag.pk }}">{{ tag.name }}
            {% endif %}
        {% endfor %}
    </div>
    <br>
    <input type="button" class="btn btn-primary" value="确定修改" id="id_commit">
{% endblock %}

标题和内容的输入框,通过模板语法获取到该文章原本的标题和内容展示到页面。

分类和标签渲染出已经选中的选项,需要做判断,选中的默认checked。

前端js部分代码,包括使用富文本编辑器,ajax发送post请求等跟添加文章完全一样。

后端view代码和添加文章基本一样

@login_required
def edit_article(request, edit_id):
    edit_obj = models.Article.objects.filter(pk=edit_id).first()
    if request.is_ajax():
        back_dic = {'code': 0, 'msg': ''}
        title = request.data.get('title')
        content = request.data.get('content')
        category_id = request.data.get('category')
        tags_id_list = request.data.get('tags')

        soup = BeautifulSoup(content, 'html.parser')
        tags = soup.find_all()
        for tag in tags:
            if tag.name == 'script':
                tag.decompose()
        desc = soup.text[0:150]

        if not title == '':
            models.Article.objects.filter(pk=edit_id).update(title=title, desc=desc, content=str(soup),
                                                             categorize_id=category_id)
            # 文章和标签第三张关系表是半自动创建,没有add、remove、set、clear方法
            # 先删除当前修改的文章标签表数据
            models.Article2Tag.objects.filter(article_id=edit_id).delete()
            # 再重新插入数据
            article_tag_list = []
            for i in tags_id_list:
                article_tag_obj = models.Article2Tag(article_id=edit_id, tag_id=int(i))
                article_tag_list.append(article_tag_obj)
            models.Article2Tag.objects.bulk_create(article_tag_list)
            back_dic['msg'] = '文章修改成功'
            back_dic['url'] = '/backend/'
        else:
            back_dic['code'] = 1
            back_dic['msg'] = '请输入文章标题'
        return JsonResponse(back_dic)

    category_list = models.Categorize.objects.filter(blog=request.user.blog)
    tag_list = models.Tag.objects.filter(blog=request.user.blog)
    return render(request, 'backend/edit_article.html', locals())

 

4 删除文章

在backend.html,后台文章展示页面,删除标签用ajax结合sweetalert

{% block js %}
<script src="{% static 'dist/sweetalert.min.js' %}"></script>

<script>
    $('.del').click(function (){
        // 先把当前操作对象用变量存储
        let currentBtn = $(this);
        // 二次确认弹框
        swal({
          title: "确定删除文章吗?",
          text: "删除后数据将无法恢复!",
          type: "warning",
          showCancelButton: true,
          confirmButtonClass: "btn-danger",
          confirmButtonText: "是的, 确定删除!",
          cancelButtonText: "不, 让我想想!",
          closeOnConfirm: false,
          closeOnCancel: false,
          showLoaderOnConfirm: true
        },
        function(isConfirm) {
          if (isConfirm) {
              // 朝后端发送ajax请求删除数据之后 再弹下面的提示框
              $.ajax({
                  url:'/delete/article/',
                  type: 'post',
                  data: {'delete_id':currentBtn.attr('delete_id')},
                  success:function (args){
                      if(args.code === 0){
                          swal("删除!", args.msg, "success");
                          // 删除后页面刷新方式1
                          {#window.location.reload()#}
                          // 删除后页面刷新方式2,页面不刷新,利用DOM操作实现标签动态刷新
                          // 找到当前删除的这行,利用DOM操作移除掉,利用标签层级关系,当前标签button,父级td,父级tr
                          currentBtn.parent().parent().remove()
                      }
                      else{
                          swal("失败!", "出现了未知错误", "warning")
                      }
                  }
              })
          } else {
            swal("怂逼", "我就知道你不敢删", "error");
          }
        });
    })
</script>
{% endblock %}

后端路径只用处理数据库,直接删除

@login_required
def delete_article(request):
    if request.is_ajax():
        back_dic = {'code': 0, 'msg': ''}
        if request.method == 'POST':
            article_id = request.POST.get('delete_id')
            models.Article.objects.filter(pk=article_id, blog=request.user.blog).delete()
            back_dic['msg'] = '文章删除成功'
        return JsonResponse(back_dic)

 

5 编辑器上传图片

编辑器使用的第三方的,别人写好了接口,我们使用时需要参考文档说明,自己进行手动修改

KindEditor.ready(function (K) {
    window.editor = K.create('#id_content', {
        width: '100%',
        height: '300px',
        resizeType: 1,
        uploadJson: '/upload_image/',   // 上传图片的后端提交路径
        extraFileUploadParams: {        // post请求中间件,用它提供的额外的文件提交参数
            'csrfmiddlewaretoken': '{{ csrf_token }}'
        },
    });
});

uploadJson是 kindeditor编辑器用于文件上传的参数,参数值是上传文件的后端提交路径。

extraFileUploadPararms是kindeditor编辑器用于文件上传的额外提交参数,我们只需要用到csrftoken。

配置好url,书写后端接口upload_image,因为接口是别人写的,后端返回数据时要按照它固定的格式,以下是文档说明要求的返回格式:

//成功时
{
    "error" : 0,
    "url" : "http://www.example.com/path/to/file.ext"    //当前文件在后端的存储路径
}
//失败时
{
    "error" : 1,
    "message" : "错误信息"
}

后端接口view

@login_required
def upload_image(request):
    # 提前定义返回给编辑器的数据格式,参照别人要求的格式
    back_dic = {'error': 0, }

    if request.method == 'POST':
        # 获取用户上传的图片对象,因为代码不是自己写的,不知道前端传来的数据,键是什么,无法get获取, 用print打印,看到键固定叫imgFile
        file_obj = request.FILES.get('imgFile')
        # 用户写文章时上传的图片也是静态资源,应该放到media文件夹下, 文章图片没办法直接保存到media下,需要我们手动拼接文件存储路径
        file_dir = os.path.join(settings.BASE_DIR, 'media', 'article_img')
        # 优化操作,判断当前文件夹是否存在,不存在则自动创建单级目录'article_img'
        if not os.path.isdir(file_dir):
            os.mkdir(file_dir)

        # 拼接图片存储的完整路径(文件存储路径+文件名)
        # file_path = os.path.join(file_dir, file_obj.name)

        # file_obj.name 文件名同名会覆盖,为避免冲突,应该做一个唯一标识的处理,可以利用时间戳+uuid
        filename = uuid.uuid4().hex
        file_path = os.path.join(file_dir, filename)
        with open(file_path, 'wb') as f:
            for line in file_obj:
                f.write(line)
        """
        返回给前端的url就是当前文件在后端的存储路径,需要手动拼接
        为什么不直接返回file_path,因为file_path是: newbbs/media/article_img/, 你并没有开放newbbs的接口,只开放了media的接口
        """
        back_dic['url'] = '/media/article_img/%s' % filename
    return JsonResponse(back_dic)

 

6 修改用户头像

修改用户头像功能,我们跟修改密码功能一样,利用模态框实现。由于使用了模板的继承(导航条做成了模板),我们只需要在base.html, backend_base.html, home.html三个页面进行渲染。

修改头像模态框整体代码如下:

<div class="modal fade bs-example-modal-sm" tabindex="-1" role="dialog" aria-labelledby="myLargeModalLabel">
  <div class="modal-dialog modal-lg" role="document">
    <div class="modal-content">
        <h1 class="text-center">修改头像</h1>
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="form-group">
                    <label for="old_avatar">原头像
                    <img src="/media/{{ request.user.avatar }}/" alt="" id="old_avatar" width="130">
                    </label>
                </div>
                <div class="form-group">
                    <label for="new_avatar">新头像
                      <img src="{% static 'img/default.png' %}" alt="" id="myimg" width="100" style="margin-left: 10px">
                    </label>
                      <input type="file" id="new_avatar" name="avatar" style="display: none">
                </div>
                <div class="modal-footer">
                <button type="button" class="btn btn-default" data-dismiss="modal">取消</button>
                <button type="button" class="btn btn-primary" id="set_avatar">修改</button>
                </div>
            </div>
        </div>
    </div>
  </div>
</div>

实现选择头像预览以及朝后端发送ajax请求的js代码:

// 修改头像预览
$('#new_avatar').change(function () {
    // 利用文件阅读器对象获取图片文件并实时展示
    // 1、先生成一个文件阅读器对象(内置对象)
    let myFileReaderObj = new FileReader();
    // 2、获取用户上传的头像文件
    // this指代的当前被操作对象未$('#myfile'),jQurey对象先转成原生js对象,用files方法取索引0
    let fileObj = $(this)[0].files[0];
    // 3、将文件对象交给阅读器对象读取
    myFileReaderObj.readAsDataURL(fileObj);
    // 4、利用文件阅读器将文件展示到前端页面,即修改img标签的src属性
    // 注意:阅读器对象读取文件本身是IO操作,但它由是异步提交
    // 也就意味着到最后一步时,文件根本没有读取完毕,前端展示不出来
    // 因此,最后一步展示操作要等待文件阅读器加载完毕之后再执行
    myFileReaderObj.onload=function (){
        $('#myimg').attr('src', myFileReaderObj.result)
    }
})

// 利用ajax向后端提交修改头像数据
$('#set_avatar').click(function (){
    let formDataObj = new FormData();
    formDataObj.append('avatar',$('#new_avatar')[0].files[0]);
    $.ajax({
        url:'/set/avatar/',
        type: 'post',
        data: formDataObj,
        contentType:false,
        processData:false,
        success:function (args){
            if(args.code===0){
                alert(args.msg);
                window.location.reload()
            }
        }
    })
})

后端接口view代码:

@login_required
def set_avatar(request):
    if request.is_ajax():
        back_dic = {'code': 0, 'msg': ''}
        if request.method == 'POST':
            file_obj = request.FILES.get('avatar')
            """
            avatar字段保存文件路径,如avatar/default.png
            用update更新图片文件,不会自动加avatar前缀,路径不完整,前端展示不出图片
            models.UserInfo.objects.filter(pk=request.user.pk).update(avatar=file_obj)
            """
            # 1、自己手动加前缀
            # 2、换一种更新方式
            user_obj = request.user
            user_obj.avatar = file_obj
            user_obj.save()
            back_dic['msg'] = '头像修改成功'
        return JsonResponse(back_dic)

 

posted @   不会钓鱼的猫  阅读(66)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· winform 绘制太阳,地球,月球 运作规律
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
点击右上角即可分享
微信分享提示