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是
//成功时
{
"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)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 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训练数据并当服务器共享给他人