添加点赞功能

gq_branch
guqi 3 months ago
parent be17e11558
commit c6cf4aa237

@ -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}"

@ -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/<int:article_id>/like/', views.like_article, name='like_article'),
path('article/<int:article_id>/like/check/', views.check_like_status, name='check_like_status'),
]

@ -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):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能

@ -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 %}
<title>{{ article.title }} | {{ SITE_DESCRIPTION }}</title>
<meta name="description" content="{{ article.body|truncatewords:1 }}"/>
<meta name="keywords" content="{% keywords_to_str article %}"/>
<meta property="og:type" content="article"/>
<meta property="og:title" content="{{ article.title }}"/>
<meta property="og:description" content="{{ article.body|truncatewords:1 }}"/>
<meta property="og:url"
content="{{ article.get_full_url }}"/>
<meta property="article:published_time" content="{% datetimeformat article.pub_time %}"/>
<meta property="article:modified_time" content="{% datetimeformat article.last_modify_time %}"/>
<meta property="article:author" content="{{ article.author.get_full_url }}"/>
<meta property="article:section" content="{{ article.category.name }}"/>
{% for t in article.tags.all %}
<meta property="article:tag" content="{{ t.name }}"/>
{% endfor %}
<link rel="canonical" href="{{ article.get_full_url }}"/>
<style type="text/css">
.like-button {
background-color: #f0f0f0;
border: none;
color: #333;
padding: 8px 16px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 4px;
min-width: 100px; /* 确保按钮有足够的宽度 */
visibility: visible !important; /* 强制确保按钮可见 */
position: relative;
z-index: 10;
}
.like-button:hover {
background-color: #e0e0e0;
transform: scale(1.05); /* 调整缩放比例 */
}
.like-button.liked {
background-color: #ff6b6b;
color: white;
box-shadow: 0 2px 4px rgba(255, 107, 107, 0.5);
}
.like-button.loading {
opacity: 0.7;
cursor: not-allowed;
}
.likes-count {
font-weight: bold;
margin-left: 5px;
font-size: 14px;
}
.entry-likes {
display: inline-block;
vertical-align: middle;
}
/* 确保点赞按钮区域总是可见 */
.entry-meta .entry-likes {
display: inline-block !important;
}
</style>
{% endblock %}
{# 4. 重写content块定义文章详情页的核心内容文章主体、导航、评论区#}
{% block content %}
<div id="primary" class="site-content"> {# 主内容区容器,应用网站全局样式 #}
<div id="content" role="main"> {# 核心内容容器role属性提升无障碍访问性 #}
{# 渲染文章详情调用自定义标签load_article_detail #}
{# 参数说明:#}
{# article当前文章对象包含标题、内容、发布时间等信息#}
{# False控制是否显示编辑按钮此处为False仅展示不允许编辑#}
{# user当前登录用户对象用于权限判断如作者才能编辑#}
{% load_article_detail article False user %}
{# 文章导航(上一篇/下一篇):仅当文章类型为'a'(普通文章)时显示 #}
{% if article.type == 'a' %}
<nav class="nav-single"> {# 导航容器,应用单篇文章导航样式 #}
<h3 class="assistive-text">文章导航</h3> {# 辅助文本,提升无障碍访问性 #}
{# 下一篇文章链接:若存在下一篇(发布时间更早的文章)则显示 #}
{% if next_article %}
<span class="nav-previous">
<a href="{{ next_article.get_absolute_url }}" rel="prev"> {# rel="prev"告知搜索引擎是前序内容 #}
<span class="meta-nav">&larr;</span> {# 左箭头符号 #}
{{ next_article.title }} {# 下一篇文章标题 #}
</a>
<div id="primary" class="site-content">
<div id="content" role="main">
<!-- 将点赞按钮移出缓存区域以确保它能正确加载 -->
<article id="post-{{ article.pk }}" class="post-{{ article.pk }} post type-post status-publish format-standard hentry">
<header class="entry-header">
<h1 class="entry-title">
{{ article.title }}
</h1>
<div class="entry-meta">
<span class="byline">
<i class="fa fa-user"></i>
<span class="author vcard">
<a class="url fn n" href="{{ article.author.get_absolute_url }}"
title="View all posts by {{ article.author.username }}"
rel="author">{{ article.author.username }}
</a>
</span>
</span>
<span class="sep">|</span>
<span class="entry-date">
<i class="fa fa-calendar"></i>
<time class="entry-date updated" datetime="{{ article.pub_time }}">
{% datetimeformat article.pub_time %}
</time>
</span>
{% endif %}
{# 上一篇文章链接:若存在上一篇(发布时间更晚的文章)则显示 #}
{% if prev_article %}
<span class="nav-next">
<a href="{{ prev_article.get_absolute_url }}" rel="next"> {# rel="next"告知搜索引擎是后序内容 #}
{{ prev_article.title }} {# 上一篇文章标题 #}
<span class="meta-nav">&rarr;</span> {# 右箭头符号 #}
<span class="sep">|</span>
<span class="entry-category">
<i class="fa fa-folder-o"></i>
<a href="{{ article.category.get_absolute_url }}" rel="category tag">
{{ article.category.name }}
</a>
</span>
{% endif %}
</nav><!-- .nav-single -->
{% endif %}
</div><!-- #content -->
{# 评论区仅当文章允许评论comment_status == "o"且网站全局开启评论OPEN_SITE_COMMENT为True时显示 #}
{% if article.comment_status == "o" and OPEN_SITE_COMMENT %}
<span class="sep">|</span>
<span class="entry-views">
<i class="fa fa-eye"></i>
{{ article.views }} views
</span>
<span class="sep">|</span>
<span class="entry-likes">
<button id="like-button" class="like-button" data-article-id="{{ article.pk }}">
<i class="fa fa-heart"></i>
<span class="likes-text">Like</span>
<span class="likes-count">({{ article.likes }})</span>
</button>
</span>
</div>
</header>
{% cache 36000 article_detail_content article.pk %}
<div class="entry-content">
{{ article.body|custom_markdown|escape|linebreaksbr }}
</div>
<footer class="entry-footer">
<div style="padding-bottom: 10px;">
<hr/>
<div class="post-like">
<i class="fa fa-tags"></i>
{% for tag in article.tags.all %}
<a href="{{ tag.get_absolute_url }}" rel="tag">{{ tag.name }}</a>
{% endfor %}
</div>
</div>
</footer>
{% endcache %}
</article>
{# 引入评论列表模板:展示已有评论 #}
<!-- You can start editing here. -->
{% include 'comments/tags/comment_list.html' %}
{# 评论表单:根据用户登录状态显示不同内容 #}
{% if user.is_authenticated %}
{# 已登录用户:显示评论提交表单 #}
{% include 'comments/tags/post_comment.html' %}
{% else %}
{# 未登录用户:提示登录后评论,并提供登录入口 #}
<div class="comments-area">
<h3 class="comment-meta">
您还没有登录,请您
{# 登录链接附带next参数登录后跳回当前文章页 #}
<a href="{% url "account:login" %}?next={{ request.get_full_path }}" rel="nofollow">登录</a>
后发表评论。
</h3>
{# 第三方登录入口加载OAuth登录标签提供社交账号登录选项 #}
{% load oauth_tags %} {# 加载自定义OAuth标签库 #}
{% load_oauth_applications request %} {# 渲染第三方登录按钮如GitHub、Google#}
<div class="comments-area-title">
<h3 class="comments-title">评论</h3>
<div class="comment-form-login-notice">
<p>您必须<a href="{% url 'account:login' %}?next={{ request.get_full_path }}">登录</a>才能发表评论。</p>
</div>
</div>
{% endif %}
{% endif %}
</div><!-- #primary -->
{% endblock %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// 添加获取CSRF token的函数
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// 使用一个全局变量来跟踪是否已经初始化了点赞按钮
let likeButtonInitialized = false;
// 创建一个初始化函数
function initializeLikeButton() {
// 如果已经初始化过,则不再执行
if (likeButtonInitialized) {
return;
}
const likeButton = document.getElementById('like-button');
// 添加调试信息
console.log('Like button element:', likeButton);
console.log('Article ID:', likeButton ? likeButton.getAttribute('data-article-id') : 'Not found');
if (likeButton) {
likeButtonInitialized = true;
const article_id = likeButton.getAttribute('data-article-id');
const likesCountElement = likeButton.querySelector('.likes-count');
const likesTextElement = likeButton.querySelector('.likes-text');
// 检查用户是否已点赞
axios.get(`/article/${article_id}/like/check/`, {
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(function(response) {
const data = response.data;
if (data.liked) {
likeButton.classList.add('liked');
likesTextElement.textContent = 'Liked'; // 确保更新按钮文本
}
})
.catch(function(error) {
console.error('Error checking like status:', error);
});
// 点击事件处理
likeButton.addEventListener('click', function() {
// 添加加载状态
likeButton.classList.add('loading');
likeButton.disabled = true;
// 根据当前状态决定是点赞还是取消点赞
const isLiked = likeButton.classList.contains('liked');
const url = `/article/${article_id}/like/`;
// 发送相应请求
let requestPromise;
if (isLiked) {
// 取消点赞 - 发送 DELETE 请求
requestPromise = axios.delete(url, {
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
});
} else {
// 点赞 - 发送 POST 请求
requestPromise = axios.post(url, {}, {
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
});
}
requestPromise
.then(function(response) {
const data = response.data;
// 更新点赞数量
if (data.likes_count !== undefined) {
likesCountElement.textContent = '(' + data.likes_count + ')';
}
// 更新按钮状态
if (data.liked !== undefined) {
if (data.liked) {
likeButton.classList.add('liked');
likesTextElement.textContent = 'Liked';
} else {
likeButton.classList.remove('liked');
likesTextElement.textContent = 'Like';
}
}
// 移除加载状态
likeButton.classList.remove('loading');
likeButton.disabled = false;
})
.catch(function(error) {
// 移除加载状态
likeButton.classList.remove('loading');
likeButton.disabled = false;
if (error.response && error.response.status === 403) {
alert('请先登录再点赞');
window.location.href = '/accounts/login/?next=' + encodeURIComponent(window.location.pathname);
} else if (error.response && error.response.data) {
// 处理400错误返回的JSON数据
const data = error.response.data;
if (data.detail) {
alert(data.detail);
}
// 即使出现错误,也更新显示正确的点赞数
if (data.likes_count !== undefined) {
likesCountElement.textContent = '(' + data.likes_count + ')';
}
} else {
console.error('Error:', error);
alert('操作失败,请稍后再试');
}
});
});
} else {
console.error('Like button not found in the DOM.');
}
}
{# 5. 重写sidebar块加载文章详情页专属侧边栏 #}
{% block sidebar %}
{# 调用自定义标签load_sidebar参数"p"可能指定侧边栏样式(如包含热门文章、分类等组件)#}
{% load_sidebar user "p" %}
// 使用更可靠的DOM加载完成检测方法
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeLikeButton);
} else {
// DOM已经加载完成
initializeLikeButton();
}
</script>
{% endblock %}
Loading…
Cancel
Save