如今咱們的博客已經具備評論功能了。隨着文章的評論者愈來愈多,有的時候評論者之間也須要交流,甚至部分評論還能合併成一個小的總體。所以最好是有某種方法能夠將相關的評論彙集到一塊兒,這時候多級評論就很是的有用了。javascript
多級評論意味着你須要將模型從新組織爲樹形結構。「樹根」是一級評論,而衆多「樹葉」則是次級評論。本教程會以第三方庫django-mptt爲基礎,開發多級評論功能。css
django-mptt模塊包含了樹形數據結構以及查詢、修改樹形數據的衆多方法。任何須要樹形結構的地方,均可以用 django-mptt 來搭建。好比目錄。html
注意:本章新知識點較多,請讀者作好心理準備,必定要耐心閱讀。前端
既然要創建樹形結構,老的評論模型確定是要修改了。java
首先安裝django-mptt
:node
(env) > pip install django-mptt
安裝成功後,在配置中註冊:python
my_blog/settings.py ... INSTALLED_APPS = [ ... 'mptt', ... ] ...
這些你已經輕車熟路了。jquery
接下來,修改評論模型:git
comment/models.py ... # django-mptt from mptt.models import MPTTModel, TreeForeignKey # 替換 models.Model 爲 MPTTModel class Comment(MPTTModel): ... # 新增,mptt樹形結構 parent = TreeForeignKey( 'self', on_delete=models.CASCADE, null=True, blank=True, related_name='children' ) # 新增,記錄二級評論回覆給誰, str reply_to = models.ForeignKey( User, null=True, blank=True, on_delete=models.CASCADE, related_name='replyers' ) # 替換 Meta 爲 MPTTMeta # class Meta: # ordering = ('created',) class MPTTMeta: order_insertion_by = ['created'] ...
先引入MPTT
相關模塊,而後改動下列幾個位置:github
models.Model
類,替換爲MPTTModel
,所以你的模型自動擁有了幾個用於樹形算法的新字段。(有興趣的讀者,能夠在遷移好數據以後在SQLiteStudio中查看)parent
字段是必須定義的,用於存儲數據之間的關係,不要去修改它。reply_to
外鍵用於存儲被評論人。class Meta
替換爲class MPTTMeta
,參數也有小的變化,這是模塊的默認定義,實際功能是相同的。這些改動大部分都是django-mptt文檔的默認設置。須要說明的是這個reply_to
。
先思考一下,多級評論是否容許無限級數?無限級數聽起來很美好,可是嵌套的層級若是過多,反而會致使結構混亂,而且難以排版。因此這裏就限制評論最多隻能兩級,超過兩級的評論一概重置爲兩級,而後再將實際的被評論人存儲在reply_to
字段中。
舉例說明:一級評論人爲 a,二級評論人爲 b(parent 爲 a),三級評論人爲 c(parent 爲 b)。由於咱們不容許評論超過兩級,所以將 c 的 parent 重置爲 a,reply_to 記錄爲 b,這樣就能正確追溯真正的被評論者了。
模型修改完了,添加了不少非空的字段進去,所以最好先清空全部的評論數據,再進行數據遷移。
遷移時出現下面的提示也不要慌,一概選第 1 項、填入數據 0 就能夠了:
(env) > python manage.py makemigrations You are trying to add a non-nullable field 'level' to comment without a default; we can't do that (the database needs something to populate existing rows). Please select a fix: 1) Provide a one-off default now (will be set on all existing rows with a null value for this column) 2) Quit, and let me add a default in models.py Select an option: 1 Please enter the default value now, as valid Python The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now Type 'exit' to exit this prompt >>> 0
要還不行,就把數據庫文件刪了從新遷移吧。開發階段用點笨辦法也不要緊。
數據遷移仍是老規矩:
(env) > python manage.py makemigrations (env) > python manage.py migrate
這就完成了。
前面章節已經寫過一個視圖post_comment
用於處理評論了,咱們將複用它,以求精簡代碼。
改動較大,代碼全貼出來,請對照改動:
comment/views.py ... # 記得引入 Comment ! from .models import Comment ... @login_required(login_url='/userprofile/login/') # 新增參數 parent_comment_id def post_comment(request, article_id, parent_comment_id=None): article = get_object_or_404(ArticlePost, id=article_id) # 處理 POST 請求 if request.method == 'POST': comment_form = CommentForm(request.POST) if comment_form.is_valid(): new_comment = comment_form.save(commit=False) new_comment.article = article new_comment.user = request.user # 二級回覆 if parent_comment_id: parent_comment = Comment.objects.get(id=parent_comment_id) # 若回覆層級超過二級,則轉換爲二級 new_comment.parent_id = parent_comment.get_root().id # 被回覆人 new_comment.reply_to = parent_comment.user new_comment.save() return HttpResponse('200 OK') new_comment.save() return redirect(article) else: return HttpResponse("表單內容有誤,請從新填寫。") # 處理 GET 請求 elif request.method == 'GET': comment_form = CommentForm() context = { 'comment_form': comment_form, 'article_id': article_id, 'parent_comment_id': parent_comment_id } return render(request, 'comment/reply.html', context) # 處理其餘請求 else: return HttpResponse("僅接受GET/POST請求。")
主要變化有3個地方:
parent_comment_id=None
。此參數表明父評論的id
值,若爲None
則表示評論爲一級評論,如有具體值則爲多級評論。MPTT
的get_root()
方法將其父級重置爲樹形結構最底部的一級評論,而後在reply_to
中保存實際的被回覆人並保存。視圖最終返回的是HttpResponse
字符串,後面會用到。GET
請求的邏輯,用於給二級回覆提供空白的表單。後面會用到。很好,如今視圖中有一個parent_comment_id
參數用於區分多級評論,所以就要求有的url
傳入此參數,有的不傳入,像下面這樣:
comment/urls.py ... urlpatterns = [ # 已有代碼,處理一級回覆 path('post-comment/<int:article_id>', views.post_comment, name='post_comment'), # 新增代碼,處理二級回覆 path('post-comment/<int:article_id>/<int:parent_comment_id>', views.post_comment, name='comment_reply') ]
兩個path
都使用了同一個視圖函數,可是傳入的參數卻不同多,仔細看。第一個path
沒有parent_comment_id
參數,所以視圖就使用了缺省值None
,達到了區分評論層級的目的。
在前端的邏輯上,咱們的理想很豐滿:
然而理想越豐滿,代碼寫得就越痛苦。
首先就是detail.html
的代碼要大改,主要集中在顯示評論部分以及相關的JavaScript
。
須要改動的地方先所有貼出來:
templates/article/detail.html ... <!-- 改動 顯示評論 部分 --> <!-- 不要漏了 load mptt_tags! --> {% load mptt_tags %} <h4>共有{{ comments.count }}條評論</h4> <div class="row"> <!-- 遍歷樹形結構 --> {% recursetree comments %} <!-- 給 node 取個別名 comment --> {% with comment=node %} <div class="{% if comment.reply_to %} offset-1 col-11 {% else %} col-12 {% endif %}" > <hr> <p> <strong style="color: pink"> {{ comment.user }} </strong> {% if comment.reply_to %} <i class="far fa-arrow-alt-circle-right" style="color: cornflowerblue;" ></i> <strong style="color: pink"> {{ comment.reply_to }} </strong> {% endif %} </p> <div>{{ comment.body|safe }}</div> <div> <span style="color: gray"> {{ comment.created|date:"Y-m-d H:i" }} </span> <!-- modal 按鈕 --> <button type="button" class="btn btn-light btn-sm text-muted" onclick="load_modal({{ article.id }}, {{ comment.id }})" > 回覆 </button> </div> <!-- Modal --> <div class="modal fade" id="comment_{{ comment.id }}" tabindex="-1" role="dialog" aria-labelledby="CommentModalCenter" aria-hidden="true" > <div class="modal-dialog modal-dialog-centered modal-lg" role="document"> <div class="modal-content" style="height: 480px"> <div class="modal-header"> <h5 class="modal-title" id="exampleModalCenterTitle">回覆 {{ comment.user }}:</h5> </div> <div class="modal-body" id="modal_body_{{ comment.id }}"></div> </div> </div> </div> {% if not comment.is_leaf_node %} <div class="children"> {{ children }} </div> {% endif %} </div> {% endwith %} {% endrecursetree %} </div> ... {% block script %} ... <!-- 新增代碼,喚醒二級回覆的 modal --> <script> // 加載 modal function load_modal(article_id, comment_id) { let modal_body = '#modal_body_' + comment_id; let modal_id = '#comment_' + comment_id; // 加載編輯器 if ($(modal_body).children().length === 0) { let content = '<iframe src="/comment/post-comment/' + article_id + '/' + comment_id + '"' + ' frameborder="0" style="width: 100%; height: 100%;" id="iframe_' + comment_id + '"></iframe>'; $(modal_body).append(content); }; $(modal_id).modal('show'); } </script> {% endblock script %}
這麼大段確定把你看暈了,不要急,讓咱們拆開來說解。
第一個問題,如何遍歷樹形結構?
django-mptt提供了一個快捷方式:
{% load mptt_tags %} <ul> {% recursetree objs %} <li> {{ node.your_field }} {% if not node.is_leaf_node %} <ul class="children"> {{ children }} </ul> {% endif %} </li> {% endrecursetree %} </ul>
內部的實現你不用去管,當成一個黑盒子去用就行了。objs
是須要遍歷的數據集,node
是其中的單個數據。有兩個地方要注意:
{% load mptt_tags %}
不要忘記寫node
這個變量名太寬泛,用{% with comment=node %}
給它起了個別名Modal是Bootstrap內置的彈窗。本文相關代碼以下:
<!-- modal 按鈕 --> <button type="button" class="btn btn-light btn-sm text-muted" onclick="load_modal({{ article.id }}, {{ comment.id }})" > 回覆 </button> <!-- Modal --> <div class="modal fade" id="comment_{{ comment.id }}" tabindex="-1" role="dialog" aria-labelledby="CommentModalCenter" aria-hidden="true" > <div class="modal-dialog modal-dialog-centered modal-lg" role="document"> <div class="modal-content" style="height: 480px"> <div class="modal-header"> <h5 class="modal-title" id="exampleModalCenterTitle">回覆 {{ comment.user }}:</h5> </div> <div class="modal-body" id="modal_body_{{ comment.id }}"></div> </div> </div> </div>
它幾乎就是從Bootstrap官方文檔抄下來的(因此讀者要多瀏覽官網啊)。有點不一樣的是本文沒有用原生的按鈕,而是用JavaScript
加載的Modal;還有就是增長了幾個容器的id
屬性,方便後面的JavaScript
查詢。
和以前章節用的Layer.js
相比,Bootstrap
的彈窗更笨重些,也更精緻些,很適合在這裏使用。
最難理解的可能就是這段加載Modal的JavaScript
代碼了:
// 加載 modal function load_modal(article_id, comment_id) { let modal_body = '#modal_body_' + comment_id; let modal_id = '#comment_' + comment_id; // 加載編輯器 if ($(modal_body).children().length === 0) { let content = '<iframe src="/comment/post-comment/' + article_id + '/' + comment_id + '" frameborder="0" style="width: 100%; height: 100%;"></iframe>'; $(modal_body).append(content); }; $(modal_id).modal('show'); }
實際上核心邏輯只有3步:
load_modal()
函數,並將文章id、父級評論id傳遞進去$(modal_body).append(content)
找到對應Modal的容器,並將一個iframe
容器動態添加進去$(modal_id).modal('show')
找到對應的Modal,並將其喚醒爲何iframe
須要動態加載?這是爲了避免潛在的性能問題。你確實能夠在頁面初始加載時把全部iframe
都渲染好,可是這須要花費額外的時間,而且絕大部分的Modal用戶根本不會用到,很不划算。
if
語句的做用是判斷Modal中若是已經加載過,就再也不重複加載了。
最後,什麼是iframe
?這是HTML5中的新特性,能夠理解成當前網頁中嵌套的另外一個獨立的網頁。既然是獨立的網頁,那天然也會獨立的向後臺請求數據。仔細看src
中請求的位置,正是前面咱們在urls.py
中寫好的第二個path
。即對應了post_comment
視圖中的GET
邏輯:
comment/views.py def post_comment(request, article_id, parent_comment_id=None): ... # 處理 GET 請求 elif request.method == 'GET': ... return render(request, 'comment/reply.html', context) ...
視圖返回的comment/reply.html
模板尚未寫,接下來就把它寫好。
老實說用iframe來加載ckeditor彈窗並非很「優雅」。單頁面上多個ckeditor的動態加載、取值、傳參,博主沒能嘗試成功。有興趣的讀者能夠和我交流。
在templates
中新建comment
目錄,並新建reply.html
,寫入代碼:
templates/comment/reply.html <!-- 載入靜態文件 --> {% load staticfiles %} <!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}"> </head> <body> <form action="." method="POST" id="reply_form" > {% csrf_token %} <div class="form-group"> <div id="test"> {{ comment_form.media }} {{ comment_form.body }} </div> </div> </form> <!-- 提交按鈕 --> <button onclick="confirm_submit({{ article_id }}, {{ parent_comment_id }})" class="btn btn-primary">發送</button> <script src="{% static 'jquery/jquery-3.3.1.js' %}"></script> <script src="{% static 'popper/popper-1.14.4.js' %}"></script> <script src="{% static 'bootstrap/js/bootstrap.min.js' %}"></script> <!-- csrf token --> <script src="{% static 'csrf.js' %}"></script> <script> $(function(){ $(".django-ckeditor-widget").removeAttr('style'); }); function confirm_submit(article_id, comment_id){ // 從 ckeditor 中取值 let content = CKEDITOR.instances['id_body'].getData(); // 調用 ajax 與後端交換數據 $.ajax({ url: '/comment/post-comment/' + article_id + '/' + comment_id, type: 'POST', data: {body: content}, // 成功回調 success: function(e){ if(e === '200 OK'){ parent.location.reload(); } } }) } </script> </body> </html>
這個模板的做用是提供一個ckeditor的編輯器,因此沒有繼承base.html
。讓咱們拆開來說。
用Ajax技術來提交表單,與傳統方法很是不一樣。
傳統方法提交表單時向後端提交一個請求。後端處理請求後會返回一個全新的網頁。這種作法浪費了不少帶寬,由於先後兩個頁面中大部份內容每每都是相同的。與此不一樣,AJAX技術能夠僅向服務器發送並取回必須的數據,並在客戶端採用JavaScript處理來自服務器的迴應。由於在服務器和瀏覽器之間交換的數據大量減小,服務器迴應更快了。
雖然本教程只用到Ajax的一點皮毛,可是Ajax的應用很是普遍,建議讀者多瞭解相關知識。
這裏會用到Ajax,倒不是由於其效率高,而是由於Ajax能夠在表單提交成功後獲得反饋,以便刷新頁面。
核心代碼以下:
function confirm_submit(article_id, comment_id){ // 從 ckeditor 中取值 let content = CKEDITOR.instances['id_body'].getData(); // 調用 ajax 與後端交換數據 $.ajax({ url: '/comment/post-comment/' + article_id + '/' + comment_id, type: 'POST', data: {body: content}, // 成功回調 success: function(e){ if(e === '200 OK'){ parent.location.reload(); } } }) }
CKEDITOR.instances['id_body'].getData()
取得當前編輯器中用戶輸入的內容。success
是ajax的回調函數。當獲得視圖的相應後執行內部的函數。前面寫視圖的時候,二級評論提交成功後會返回200 OK
,回調函數接收到這個信號後,就會調用reload()
方法,刷新當前的父頁面(即文章所在的頁面),實現了數據的更新。
代碼中有這麼一行:
<script src="{% static 'csrf.js' %}"></script>
沒有這一行,後端會返回403 Forbidden
錯誤,而且表單提交失敗。
還記得以前提交傳統表單時的{% csrf_token %}
嗎?Django爲了防止跨域攻擊,要求表單必須提供這個token,驗證提交者的身份。
問題是在Ajax中怎麼解決這個問題呢?一種方法就是在頁面中插入這個csrf.js
模塊。
在static目錄中將csrf.js文件粘貼進去,並在頁面中引用,就能夠解決此問題了。
csrf.js文件能夠在 個人GitHub倉庫下載。
進入文章頁面,評論的邊上多出一個按鈕,能夠對評論者進行評論了:
點擊回覆按鈕,彈出帶有富文本編輯器的彈窗:
點擊發送按鈕,頁面會自動刷新,而且二級評論也出現了:
還能夠繼續對二級評論者評論,不過更高級的評論會被強制轉換爲二級評論:
功能正常運行了。
有興趣的讀者能夠打開SQLiteStudio,研究一下comment數據表的結構。
認真看完本章並實現了多級評論的同窗,能夠給本身點掌聲了。本章應該是教程到目前爲止知識點最多、最雜的章節,涵蓋了MTV、Jquery、Ajax、iframe、modal等多種先後端技術。
沒成功實現也不要急躁,web開發嘛,走點彎路很正常的。多觀察Django和控制檯的報錯信息,找到問題並解決它。