From c6cf4aa237be27fc8362cd8fdef1a931d65bbbc9 Mon Sep 17 00:00:00 2001 From: guqi Date: Fri, 21 Nov 2025 19:49:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=82=B9=E8=B5=9E=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog/models.py | 66 +++++- blog/urls.py | 13 +- blog/views.py | 204 ++++++++++++++--- templates/blog/article_detail.html | 353 +++++++++++++++++++++++------ 4 files changed, 526 insertions(+), 110 deletions(-) diff --git a/blog/models.py b/blog/models.py index 083788b..b11317a 100644 --- a/blog/models.py +++ b/blog/models.py @@ -88,6 +88,7 @@ class Article(BaseModel): default='o') type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') views = models.PositiveIntegerField(_('views'), default=0) + likes = models.PositiveIntegerField('点赞数', default=0) # 添加点赞字段 author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), @@ -176,6 +177,52 @@ class Article(BaseModel): return match.group(1) return "" + def increase_views(self): + self.views += 1 + self.save(update_fields=['views']) + + def like(self): + """增加点赞数""" + self.likes += 1 + self.save(update_fields=['likes']) + + def unlike(self): + """取消点赞""" + if self.likes > 0: + self.likes -= 1 + self.save(update_fields=['likes']) + + +class Like(models.Model): + """点赞记录模型""" + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name='用户') + article = models.ForeignKey(Article, on_delete=models.CASCADE, verbose_name='文章') + created_time = models.DateTimeField('创建时间', default=now) + + class Meta: + unique_together = ('user', 'article') # 确保每个用户只能对同一篇文章点赞一次 + verbose_name = '点赞记录' + verbose_name_plural = verbose_name + + def __str__(self): + return f"{self.user.username} likes {self.article.title}" + + def save(self, *args, **kwargs): + # 检查是否已存在该用户的点赞记录 + if not self.pk and Like.objects.filter(user=self.user, article=self.article).exists(): + raise ValidationError("您已经点过赞了") + + super().save(*args, **kwargs) + # 同步更新文章点赞数 + self.article.like() + + def delete(self, *args, **kwargs): + # 先保存文章引用 + article = self.article + super().delete(*args, **kwargs) + # 同步更新文章点赞数 + article.unlike() + class Category(BaseModel): """文章分类""" @@ -240,7 +287,7 @@ class Category(BaseModel): return categorys -class Tag(BaseModel): +class Tag(models.Model): """文章标签""" name = models.CharField(_('tag name'), max_length=30, unique=True) slug = models.SlugField(default='no-slug', max_length=60, blank=True) @@ -374,3 +421,20 @@ class BlogSettings(models.Model): super().save(*args, **kwargs) from djangoblog.utils import cache cache.clear() + + +class TagSubscription(models.Model): + """ + 标签订阅模型 + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name='用户') + tag = models.ForeignKey('Tag', on_delete=models.CASCADE, verbose_name='标签') + created_time = models.DateTimeField(default=now, verbose_name='创建时间') + + class Meta: + unique_together = ('user', 'tag') # 确保用户不能重复订阅同一标签 + verbose_name = '标签订阅' + verbose_name_plural = verbose_name + + def __str__(self): + return f"{self.user.username} 订阅 {self.tag.name}" diff --git a/blog/urls.py b/blog/urls.py index adf2703..bf622fc 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -1,9 +1,8 @@ from django.urls import path -from django.views.decorators.cache import cache_page - from . import views -app_name = "blog" +app_name = 'blog' + urlpatterns = [ path( r'', @@ -43,9 +42,7 @@ urlpatterns = [ name='tag_detail_page'), path( 'archives.html', - cache_page( - 60 * 60)( - views.ArchivesView.as_view()), + views.ArchivesView.as_view(), name='archives'), path( 'links.html', @@ -59,4 +56,6 @@ urlpatterns = [ r'clean', views.clean_cache_view, name='clean'), -] + path('article//like/', views.like_article, name='like_article'), + path('article//like/check/', views.check_like_status, name='check_like_status'), +] \ No newline at end of file diff --git a/blog/views.py b/blog/views.py index d5dc7ec..b68c8ad 100644 --- a/blog/views.py +++ b/blog/views.py @@ -1,3 +1,27 @@ +import json +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from .models import Article + + +# 添加检查点赞状态的接口 +@require_http_methods(["GET"]) +def check_like_status(request, article_id): + """ + 检查用户是否已点赞文章 + """ + if not request.user.is_authenticated: + return JsonResponse({'liked': False}) + + try: + from .models import Like + liked = Like.objects.filter(user=request.user, article_id=article_id).exists() + return JsonResponse({'liked': liked}) + except Exception as e: + return JsonResponse({'liked': False, 'error': str(e)}) + + import logging import os import uuid @@ -5,8 +29,151 @@ import uuid from django.conf import settings from django.core.paginator import Paginator from django.http import HttpResponse, HttpResponseForbidden -from django.shortcuts import get_object_or_404 -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404, redirect +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required +from django.views.generic import DetailView +from .models import Article, Tag, TagSubscription +from django.db.models import Count + + +# Adding a view function to like an article +@login_required +@require_http_methods(["POST", "DELETE"]) +def like_article(request, article_id): + """ + 处理文章点赞和取消点赞 + POST: 点赞文章 + DELETE: 取消点赞 + """ + try: + article = get_object_or_404(Article, id=article_id) + + if request.method == "POST": + # 点赞文章 + from .models import Like + # 检查是否已经点赞 + if Like.objects.filter(user=request.user, article=article).exists(): + return JsonResponse({ + 'liked': True, + 'likes_count': article.likes, + 'detail': '您已经点过赞了' + }, status=400) + + # 创建点赞记录 + like = Like(user=request.user, article=article) + like.save() + + return JsonResponse({ + 'liked': True, + 'likes_count': article.likes, + 'detail': '点赞成功' + }) + + elif request.method == "DELETE": + # 取消点赞 + from .models import Like + try: + like = Like.objects.get(user=request.user, article=article) + like.delete() + return JsonResponse({ + 'liked': False, + 'likes_count': article.likes, + 'detail': '已取消点赞' + }) + except Like.DoesNotExist: + return JsonResponse({ + 'liked': False, + 'likes_count': article.likes, + 'detail': '您还没有点赞' + }, status=400) + + except Exception as e: + return JsonResponse({ + 'liked': False, + 'likes_count': article.likes if 'article' in locals() else 0, + 'detail': str(e) + }, status=500) + + +# Adding a view function to subscribe to a tag +@login_required +def subscribe_tag(request, tag_id): + """ + Subscribe to a tag + """ + tag = get_object_or_404(Tag, id=tag_id) + subscription, created = TagSubscription.objects.get_or_create( + user=request.user, + tag=tag + ) + + if request.is_ajax() or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + subscription_count = TagSubscription.objects.filter(tag=tag).count() + return JsonResponse({ + 'status': 'success', + 'subscribed': True, + 'subscription_count': subscription_count, + 'message': 'Subscription successful' + }) + + return redirect(request.META.get('HTTP_REFERER', '/')) + + +@login_required +def unsubscribe_tag(request, tag_id): + """ + Unsubscribe from a tag + """ + tag = get_object_or_404(Tag, id=tag_id) + TagSubscription.objects.filter(user=request.user, tag=tag).delete() + + if request.is_ajax() or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + subscription_count = TagSubscription.objects.filter(tag=tag).count() + return JsonResponse({ + 'status': 'success', + 'subscribed': False, + 'subscription_count': subscription_count, + 'message': 'Unsubscription successful' + }) + + return redirect(request.META.get('HTTP_REFERER', '/')) + + +# Modify the tag detail view to pass the subscription status +class TagDetailView(DetailView): + model = Tag + template_name = 'blog/tag_detail.html' + context_object_name = 'tag' + + def get_object(self): + return get_object_or_404(Tag, name=self.kwargs['tag_name']) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + tag = context['tag'] + + # Get articles under the tag + articles = Article.objects.filter(tags=tag, status='p').order_by('-created_time') + context['articles'] = articles + + # Get subscription count + subscription_count = TagSubscription.objects.filter(tag=tag).count() + context['subscription_count'] = subscription_count + + # Check if the current user is subscribed (if they are logged in) + if self.request.user.is_authenticated: + is_subscribed = TagSubscription.objects.filter( + user=self.request.user, + tag=tag + ).exists() + context['is_subscribed'] = is_subscribed + else: + context['is_subscribed'] = False + + return context + + from django.templatetags.static import static from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -226,38 +393,6 @@ class AuthorDetailView(ArticleListView): return super(AuthorDetailView, self).get_context_data(**kwargs) -class TagDetailView(ArticleListView): - ''' - 标签列表页面 - ''' - page_type = '分类标签归档' - - def get_queryset_data(self): - slug = self.kwargs['tag_name'] - tag = get_object_or_404(Tag, slug=slug) - tag_name = tag.name - self.name = tag_name - article_list = Article.objects.filter( - tags__name=tag_name, type='a', status='p') - return article_list - - def get_queryset_cache_key(self): - slug = self.kwargs['tag_name'] - tag = get_object_or_404(Tag, slug=slug) - tag_name = tag.name - self.name = tag_name - cache_key = 'tag_{tag_name}_{page}'.format( - tag_name=tag_name, page=self.page_number) - return cache_key - - def get_context_data(self, **kwargs): - # tag_name = self.kwargs['tag_name'] - tag_name = self.name - kwargs['page_type'] = TagDetailView.page_type - kwargs['tag_name'] = tag_name - return super(TagDetailView, self).get_context_data(**kwargs) - - class ArchivesView(ArticleListView): ''' 文章归档页面 @@ -300,7 +435,6 @@ class EsSearchView(SearchView): return context -@csrf_exempt def fileupload(request): """ 该方法需自己写调用端来上传图片,该方法仅提供图床功能 diff --git a/templates/blog/article_detail.html b/templates/blog/article_detail.html index 7719d3c..f83d75d 100644 --- a/templates/blog/article_detail.html +++ b/templates/blog/article_detail.html @@ -1,87 +1,306 @@ {# gq: #} -{# 1. 模板继承:复用网站基础布局(包含头部导航、底部信息、全局样式等公共组件)#} {% extends 'share_layout/base.html' %} - -{# 2. 加载自定义博客标签库:提供文章详情渲染、侧边栏加载等专属功能 #} {% load blog_tags %} +{% load cache %} -{# 3. 重写header块:此处留空,说明文章详情页的头部元信息(如标题、SEO标签)#} -{# 可能通过其他方式(如SEO插件)动态生成,或在base.html中统一处理 #} {% block header %} + {{ article.title }} | {{ SITE_DESCRIPTION }} + + + + + + + + + + + {% for t in article.tags.all %} + + {% endfor %} + + {% endblock %} -{# 4. 重写content块:定义文章详情页的核心内容(文章主体、导航、评论区)#} {% block content %} -
{# 主内容区容器,应用网站全局样式 #} -
{# 核心内容容器,role属性提升无障碍访问性 #} - - {# 渲染文章详情:调用自定义标签load_article_detail #} - {# 参数说明:#} - {# article:当前文章对象(包含标题、内容、发布时间等信息)#} - {# False:控制是否显示编辑按钮(此处为False,仅展示不允许编辑)#} - {# user:当前登录用户对象(用于权限判断,如作者才能编辑)#} - {% load_article_detail article False user %} - - {# 文章导航(上一篇/下一篇):仅当文章类型为'a'(普通文章)时显示 #} - {% if article.type == 'a' %} - - {% endif %} - -
- - {# 评论区:仅当文章允许评论(comment_status == "o")且网站全局开启评论(OPEN_SITE_COMMENT为True)时显示 #} - {% if article.comment_status == "o" and OPEN_SITE_COMMENT %} + | + + + {{ article.views }} views + + | + + + +
+ + {% cache 36000 article_detail_content article.pk %} +
+ {{ article.body|custom_markdown|escape|linebreaksbr }} +
+
+
+
+
+ + {% for tag in article.tags.all %} + + {% endfor %} +
+
+
+ {% endcache %} + - {# 引入评论列表模板:展示已有评论 #} + {% include 'comments/tags/comment_list.html' %} - - {# 评论表单:根据用户登录状态显示不同内容 #} {% if user.is_authenticated %} - {# 已登录用户:显示评论提交表单 #} {% include 'comments/tags/post_comment.html' %} {% else %} - {# 未登录用户:提示登录后评论,并提供登录入口 #} -
-

- 您还没有登录,请您 - {# 登录链接:附带next参数,登录后跳回当前文章页 #} - 登录 - 后发表评论。 -

- - {# 第三方登录入口:加载OAuth登录标签,提供社交账号登录选项 #} - {% load oauth_tags %} {# 加载自定义OAuth标签库 #} - {% load_oauth_applications request %} {# 渲染第三方登录按钮(如GitHub、Google)#} +
+

评论

+
{% endif %} - {% endif %} -
-{% endblock %} + + + + + {% endblock %} \ No newline at end of file