Skip to content
Merged
28 changes: 28 additions & 0 deletions blog/templatetags/blog_tags.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import json
import logging
import random
import urllib
Expand Down Expand Up @@ -140,6 +141,33 @@ def comment_markdown(content):
return mark_safe(sanitize_html(content))


@register.filter(is_safe=True)
def to_json(value):
"""
将 Python 对象转换为 JSON 字符串,用于模板中传递给 JavaScript
使用 ensure_ascii=False 以支持 emoji 等 unicode 字符
"""
try:
return mark_safe(json.dumps(value, ensure_ascii=False))
except (TypeError, ValueError):
return mark_safe('{}')


@register.filter
def get_reactions_for_user(comment, user):
"""
获取评论的 reactions 数据(过滤器方式)
用法: {{ comment|get_reactions_for_user:user }}
"""
try:
return comment.get_reactions_summary(user if user.is_authenticated else None)
except Exception as e:
logger.error(f"Error getting reactions for comment {comment.id}: {e}")
return {}




@register.filter(is_safe=True)
@stringfilter
def truncatechars_content(content):
Expand Down
31 changes: 31 additions & 0 deletions comments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _

from .models import Comment, CommentReaction


def disable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=False)
Expand Down Expand Up @@ -47,3 +49,32 @@ def link_to_article(self, obj):

link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')


class CommentReactionAdmin(admin.ModelAdmin):
list_display = ('id', 'reaction_type', 'link_to_comment', 'link_to_user', 'created_at')
list_display_links = ('id', 'reaction_type')
list_filter = ('reaction_type', 'created_at')
raw_id_fields = ('comment', 'user')
search_fields = ('comment__body', 'user__username')
date_hierarchy = 'created_at'

def link_to_comment(self, obj):
info = (obj.comment._meta.app_label, obj.comment._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.comment.id,))
return format_html(
u'<a href="%s">Comment #%s</a>' % (link, obj.comment.id))

def link_to_user(self, obj):
info = (obj.user._meta.app_label, obj.user._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.user.id,))
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.user.nickname if obj.user.nickname else obj.user.username))

link_to_comment.short_description = _('Comment')
link_to_user.short_description = _('User')


admin.site.register(Comment, CommentAdmin)
admin.site.register(CommentReaction, CommentReactionAdmin)
32 changes: 32 additions & 0 deletions comments/migrations/0005_commentreaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 5.2.9 on 2026-01-22 14:13

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('comments', '0004_comment_idx_art_parent_enable_comment_idx_enable_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='CommentReaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reaction_type', models.CharField(choices=[('👍', 'thumbs_up'), ('👎', 'thumbs_down'), ('❤️', 'heart'), ('😄', 'laugh'), ('🎉', 'hooray'), ('😕', 'confused'), ('🚀', 'rocket'), ('👀', 'eyes')], max_length=10, verbose_name='reaction type')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='comments.comment', verbose_name='comment')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'comment reaction',
'verbose_name_plural': 'comment reactions',
'indexes': [models.Index(fields=['comment', 'reaction_type'], name='idx_comment_reaction')],
'unique_together': {('comment', 'user', 'reaction_type')},
},
),
]
92 changes: 92 additions & 0 deletions comments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,95 @@ class Meta:

def __str__(self):
return self.body

def get_reactions_summary(self, user=None):
"""
获取评论的 reactions 统计信息
返回格式: {
'👍': {
'count': 5,
'has_reacted': True,
'users': ['Alice', 'Bob', 'Charlie']
},
'❤️': {'count': 3, 'has_reacted': False, 'users': [...]},
...
}
"""
from django.db.models import Count

reactions = CommentReaction.objects.filter(
comment=self
).values('reaction_type').annotate(count=Count('id'))

result = {}
for reaction in reactions:
emoji = reaction['reaction_type']

# 获取该 emoji 的所有点赞用户
reaction_users = CommentReaction.objects.filter(
comment=self,
reaction_type=emoji
).select_related('user')[:10] # 最多显示10个用户

user_names = [r.user.nickname or r.user.username for r in reaction_users]

result[emoji] = {
'count': reaction['count'],
'has_reacted': False,
'users': user_names
}

if user and user.is_authenticated:
result[emoji]['has_reacted'] = CommentReaction.objects.filter(
comment=self,
user=user,
reaction_type=emoji
).exists()

return result


class CommentReaction(models.Model):
"""
评论的 Emoji 反应/点赞
"""
REACTION_CHOICES = [
('👍', 'thumbs_up'),
('👎', 'thumbs_down'),
('❤️', 'heart'),
('😄', 'laugh'),
('🎉', 'hooray'),
('😕', 'confused'),
('🚀', 'rocket'),
('👀', 'eyes'),
]

comment = models.ForeignKey(
Comment,
verbose_name=_('comment'),
on_delete=models.CASCADE,
related_name='reactions'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('user'),
on_delete=models.CASCADE
)
reaction_type = models.CharField(
_('reaction type'),
max_length=10,
choices=REACTION_CHOICES
)
created_at = models.DateTimeField(_('created at'), auto_now_add=True)

class Meta:
verbose_name = _('comment reaction')
verbose_name_plural = _('comment reactions')
# 每个用户对同一评论的同一种 emoji 只能点一次
unique_together = ['comment', 'user', 'reaction_type']
indexes = [
models.Index(fields=['comment', 'reaction_type'], name='idx_comment_reaction'),
]

def __str__(self):
return f'{self.user.username} - {self.reaction_type} on comment {self.comment.id}'
4 changes: 4 additions & 0 deletions comments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
path(
'comment/<int:comment_id>/react',
views.CommentReactionView.as_view(),
name='comment_react'),
]
73 changes: 69 additions & 4 deletions comments/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
# Create your views here.
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.views import View

from djangoblog.base_views import AuthenticatedFormView
from accounts.models import BlogUser
from blog.models import Article
from djangoblog.base_views import AuthenticatedFormView
from .forms import CommentForm
from .models import Comment
from .models import Comment, CommentReaction


class CommentPostView(AuthenticatedFormView):
"""
评论提交视图(重构版)
评论提交视图
使用 AuthenticatedFormView 基类,自动提供:
- 登录验证(未登录用户会被重定向)
Expand Down Expand Up @@ -62,3 +63,67 @@ def form_valid(self, form):
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk))


class CommentReactionView(View):
"""
评论 Emoji 反应 API
GET /comment/<comment_id>/react - 获取 reactions(公开)
POST /comment/<comment_id>/react - 切换 reaction(需要登录)
"""

def get(self, request, comment_id):
"""获取评论的 reactions 数据(公开访问)"""
comment = get_object_or_404(Comment, id=comment_id, is_enable=True)

# 传递用户信息,如果未登录则传递 None
user = request.user if request.user.is_authenticated else None
reactions_data = comment.get_reactions_summary(user)

return JsonResponse({
'success': True,
'reactions': reactions_data
})

def post(self, request, comment_id):
# POST 需要登录验证
if not request.user.is_authenticated:
return JsonResponse({
'success': False,
'error': 'Authentication required'
}, status=401)
# 获取评论(只有已启用的评论才能点赞)
comment = get_object_or_404(Comment, id=comment_id, is_enable=True)

# 获取 reaction 类型
reaction_type = request.POST.get('reaction_type')

# 验证 reaction_type 是否合法
valid_reactions = [choice[0] for choice in CommentReaction.REACTION_CHOICES]
if reaction_type not in valid_reactions:
return JsonResponse({
'error': 'Invalid reaction type'
}, status=400)

# 切换 reaction(如果已存在则删除,否则创建)
reaction, created = CommentReaction.objects.get_or_create(
comment=comment,
user=request.user,
reaction_type=reaction_type
)

if not created:
# 已存在,删除它(取消点赞)
reaction.delete()
action = 'removed'
else:
action = 'added'

# 返回该评论的所有 reactions 统计
reactions_data = comment.get_reactions_summary(request.user)

return JsonResponse({
'success': True,
'action': action,
'reactions': reactions_data
})
Loading
Loading