yh:系统维护和新增功能

master
云涵 3 weeks ago
parent 1744a1fa7c
commit e98389cd96

@ -5,7 +5,7 @@ from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
from .models import Article, Category, Tag, Links, SideBar, BlogSettings, ArticleLike, ArticleFavorite
# 文章表单(可扩展,比如集成富文本编辑器)
class ArticleForm(forms.ModelForm):
@ -40,7 +40,7 @@ class ArticleAdmin(admin.ModelAdmin):
form = ArticleForm
list_display = ( # 列表页显示的字段
'id', 'title', 'author', 'link_to_category', 'creation_time',
'views', 'status', 'type', 'article_order'
'views', 'like_count', 'status', 'type', 'article_order'
)
list_display_links = ('id', 'title') # 哪些字段可点击进入编辑页
list_filter = ('status', 'type', 'category') # 右侧过滤器
@ -88,4 +88,41 @@ class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
class BlogSettingsAdmin(admin.ModelAdmin):
pass # 博客设置后台,暂时无特殊配置
pass # 博客设置后台,暂时无特殊配置
# 文章点赞管理后台类
class ArticleLikeAdmin(admin.ModelAdmin):
list_display = ('article', 'user', 'created_time')
list_filter = ('created_time',)
search_fields = ('article__title', 'user__username', 'user__email')
date_hierarchy = 'created_time'
readonly_fields = ('created_time',)
raw_id_fields = ('article', 'user')
def has_add_permission(self, request):
# 点赞记录通常由用户在前端创建,后台可选禁止添加
return True
# 自定义方法:显示文章标题和用户名
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('article', 'user')
# 文章收藏管理后台类
class ArticleFavoriteAdmin(admin.ModelAdmin):
list_display = ('article', 'user', 'created_time')
list_filter = ('created_time',)
search_fields = ('article__title', 'user__username', 'user__email')
date_hierarchy = 'created_time'
readonly_fields = ('created_time',)
raw_id_fields = ('article', 'user')
def has_add_permission(self, request):
# 收藏记录通常由用户在前端创建,后台可选禁止添加
return True
# 自定义方法:显示文章标题和用户名
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('article', 'user')

@ -0,0 +1,197 @@
# Generated by Django 5.2.6 on 2025-11-16 17:01
import django.db.models.deletion
import django.utils.timezone
import mdeditor.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': '文章', 'verbose_name_plural': '文章'},
),
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': '网站配置', 'verbose_name_plural': '网站配置'},
),
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': '分类', 'verbose_name_plural': '分类'},
),
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': '友情链接', 'verbose_name_plural': '友情链接'},
),
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': '侧边栏', 'verbose_name_plural': '侧边栏'},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': '标签', 'verbose_name_plural': '标签'},
),
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='内容'),
),
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', '开放评论'), ('c', '关闭评论')], default='o', max_length=1, verbose_name='评论状态'),
),
migrations.AlterField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间'),
),
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='显示目录'),
),
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', '草稿'), ('p', '发布')], default='p', max_length=1, verbose_name='状态'),
),
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签'),
),
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='文章评论数量'),
),
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='Google广告代码'),
),
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='开放站点评论'),
),
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='显示Google广告'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='侧边栏文章数量'),
),
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='侧边栏评论数量'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='站点描述'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='站点关键词'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='站点名称'),
),
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='SEO描述'),
),
migrations.AlterField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间'),
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='分类名称'),
),
migrations.AlterField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页'), ('a', '全部'), ('s', '幻灯片')], default='i', max_length=1, verbose_name='显示位置'),
),
migrations.AlterField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间'),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='标签名称'),
),
migrations.CreateModel(
name='ArticleLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='点赞时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to='blog.article', verbose_name='文章')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '文章点赞',
'verbose_name_plural': '文章点赞',
'ordering': ['-created_time'],
'unique_together': {('article', 'user')},
},
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-16 17:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0007_alter_article_options_alter_blogsettings_options_and_more'),
]
operations = [
migrations.AddField(
model_name='article',
name='like_count',
field=models.PositiveIntegerField(default=0, verbose_name='点赞数'),
),
]

@ -0,0 +1,32 @@
# Generated by Django 5.2.6 on 2025-11-16 18:43
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0008_article_like_count'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ArticleFavorite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='收藏时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_favorites', to='blog.article', verbose_name='文章')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '文章收藏',
'verbose_name_plural': '文章收藏',
'ordering': ['-created_time'],
'unique_together': {('article', 'user')},
},
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-16 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0009_articlefavorite'),
]
operations = [
migrations.AddField(
model_name='article',
name='favorite_count',
field=models.PositiveIntegerField(default=0, verbose_name='收藏数'),
),
]

@ -2,64 +2,58 @@ import logging
import re
from abc import abstractmethod
from django.conf import settings # Django项目设置
from django.core.exceptions import ValidationError # 表单验证异常
from django.db import models # Django ORM模型基类
from django.urls import reverse # 用于生成URL
from django.utils.timezone import now # 当前时间
from django.utils.translation import gettext_lazy as _ # 国际化翻译
from mdeditor.fields import MDTextField # Markdown文本编辑字段
from uuslug import slugify # URL友好的字符串转换工具
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache # 自定义缓存装饰器与缓存工具
from djangoblog.utils import get_current_site # 获取当前站点信息
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__) # 日志记录器
logger = logging.getLogger(__name__)
# 枚举:链接显示位置类型
class LinkShowType(models.TextChoices):
I = ('i', _('首页')) # 首页显示
L = ('l', _('列表页')) # 列表页显示
P = ('p', _('文章页')) # 文章页显示
A = ('a', _('全部')) # 全部页面显示
S = ('s', _('幻灯片')) # 幻灯片显示
I = ('i', _('首页'))
L = ('l', _('列表页'))
P = ('p', _('文章页'))
A = ('a', _('全部'))
S = ('s', _('幻灯片'))
# 抽象基类所有模型的基础包含创建和修改时间以及自动设置slug
class BaseModel(models.Model):
id = models.AutoField(primary_key=True) # 主键ID
creation_time = models.DateTimeField(_('创建时间'), default=now) # 创建时间
last_modify_time = models.DateTimeField(_('修改时间'), default=now) # 修改时间
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('修改时间'), default=now)
def save(self, *args, **kwargs):
# 如果是更新文章浏览量,则直接更新而不走常规保存逻辑
is_update_views = isinstance(self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
is_update_views = isinstance(self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == [
'views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 如果有slug字段但未设置则根据title或name生成
if 'slug' in self.__dict__:
slug_source = getattr(self, 'title', '') if 'title' in self.__dict__ else getattr(self, 'name', '')
setattr(self, 'slug', slugify(slug_source)) # 自动生成slug
super().save(*args, **kwargs) # 调用父类保存方法
setattr(self, 'slug', slugify(slug_source))
super().save(*args, **kwargs)
def get_full_url(self):
# 获取当前站点域名并拼接完整URL
site = get_current_site().domain
url = "https://{site}{path}".format(site=site, path=self.get_absolute_url())
return url
class Meta:
abstract = True # 抽象类,不生成数据库表
abstract = True
@abstractmethod
def get_absolute_url(self):
# 子类必须实现获取当前对象的详情页URL
pass
# 文章模型
class Article(BaseModel):
STATUS_CHOICES = (
('d', _('草稿')),
@ -74,33 +68,96 @@ class Article(BaseModel):
('p', _('页面')),
)
title = models.CharField(_('标题'), max_length=200, unique=True) # 文章标题,唯一
body = MDTextField(_('内容')) # 文章正文使用Markdown编辑器
pub_time = models.DateTimeField(_('发布时间'), blank=False, null=False, default=now) # 发布时间
status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p') # 状态,默认发布
comment_status = models.CharField(_('评论状态'), max_length=1, choices=COMMENT_STATUS, default='o') # 评论状态
type = models.CharField(_('类型'), max_length=1, choices=TYPE, default='a') # 类型,默认文章
views = models.PositiveIntegerField(_('浏览量'), default=0) # 浏览次数
author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('作者'), on_delete=models.CASCADE) # 作者外键
article_order = models.IntegerField(_('排序'), default=0) # 排序权重
show_toc = models.BooleanField(_('显示目录'), default=False) # 是否显示文章目录
category = models.ForeignKey('Category', verbose_name=_('分类'), on_delete=models.CASCADE) # 分类外键
tags = models.ManyToManyField('Tag', verbose_name=_('标签'), blank=True) # 标签多对多
title = models.CharField(_('标题'), max_length=200, unique=True)
body = MDTextField(_('内容'))
pub_time = models.DateTimeField(_('发布时间'), blank=False, null=False, default=now)
status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p')
comment_status = models.CharField(_('评论状态'), max_length=1, choices=COMMENT_STATUS, default='o')
type = models.CharField(_('类型'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('浏览量'), default=0)
like_count = models.PositiveIntegerField(_('点赞数'), default=0)
favorite_count = models.PositiveIntegerField(_('收藏数'), default=0)
author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('作者'), on_delete=models.CASCADE)
article_order = models.IntegerField(_('排序'), default=0)
show_toc = models.BooleanField(_('显示目录'), default=False)
category = models.ForeignKey('Category', verbose_name=_('分类'), on_delete=models.CASCADE)
tags = models.ManyToManyField('Tag', verbose_name=_('标签'), blank=True)
# ========== 点赞相关方法 ==========
def is_liked_by(self, user):
"""检查用户是否已点赞此文章"""
if user and user.is_authenticated:
return self.article_likes.filter(user=user).exists()
return False
@property
def likes_count(self):
"""获取点赞数量"""
return self.article_likes.count()
def toggle_like(self, user):
"""切换点赞状态"""
if not user or not user.is_authenticated:
return False, 0
like, created = self.article_likes.get_or_create(user=user)
if not created:
like.delete()
liked = False
else:
liked = True
self.like_count = self.article_likes.count()
self.save(update_fields=['like_count'])
return liked, self.like_count
# ========== 收藏相关方法 ==========
def is_favorited_by(self, user):
"""检查用户是否已收藏此文章"""
if user and user.is_authenticated:
return self.article_favorites.filter(user=user).exists()
return False
@property
def favorites_count(self):
"""获取收藏数量"""
return self.article_favorites.count()
def toggle_favorite(self, user):
"""切换收藏状态"""
if not user or not user.is_authenticated:
return False, 0
favorite, created = self.article_favorites.get_or_create(user=user)
if not created:
favorite.delete()
favorited = False
else:
favorited = True
self.favorite_count = self.article_favorites.count()
self.save(update_fields=['favorite_count'])
return favorited, self.favorite_count
# ========== 结束新增 ==========
def body_to_string(self):
return self.body # 返回文章内容字符串
return self.body
def __str__(self):
return self.title # 对象字符串表示为标题
return self.title
class Meta:
ordering = ['-article_order', '-pub_time'] # 排序:先按排序权重,再按发布时间倒序
ordering = ['-article_order', '-pub_time']
verbose_name = _('文章')
verbose_name_plural = verbose_name
get_latest_by = 'id' # 最新对象依据ID
get_latest_by = 'id'
def get_absolute_url(self):
# 生成文章详情页URL包含年、月、日和文章ID
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
@ -108,23 +165,20 @@ class Article(BaseModel):
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10) # 缓存10小时
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
# 获取分类及其所有祖先分类的树状结构
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs) # 调用父类保存
super().save(*args, **kwargs)
def viewed(self):
# 增加文章浏览量并保存
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
# 获取该文章的所有启用状态的评论并缓存10分钟
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
@ -137,50 +191,44 @@ class Article(BaseModel):
return comments
def get_admin_url(self):
# 获取该文章在后台管理中的编辑URL
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100) # 缓存100秒
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 获取当前文章的下一篇文章按ID排序状态为发布
return Article.objects.filter(id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 获取当前文章的上一篇文章
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
# 从文章正文中提取第一张图片的URL
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# 分类模型
class Category(BaseModel):
name = models.CharField(_('分类名称'), max_length=30, unique=True) # 分类名称,唯一
parent_category = models.ForeignKey('self', verbose_name=_('父级分类'), blank=True, null=True, on_delete=models.CASCADE) # 父分类
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # Slug字段
index = models.IntegerField(default=0, verbose_name=_('排序')) # 排序索引
name = models.CharField(_('分类名称'), max_length=30, unique=True)
parent_category = models.ForeignKey('self', verbose_name=_('父级分类'), blank=True, null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('排序'))
class Meta:
ordering = ['-index'] # 按排序索引倒序
ordering = ['-index']
verbose_name = _('分类')
verbose_name_plural = verbose_name
def get_absolute_url(self):
# 获取分类详情页URL
return reverse('blog:category_detail', kwargs={'category_name': self.slug})
def __str__(self):
return self.name # 对象字符串表示为分类名称
return self.name
@cache_decorator(60 * 60 * 10) # 缓存10小时
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
# 递归获取当前分类及其所有祖先分类
categorys = []
def parse(category):
@ -193,7 +241,6 @@ class Category(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
# 获取当前分类的所有子分类
categorys = []
all_categorys = Category.objects.all()
@ -210,100 +257,146 @@ class Category(BaseModel):
return categorys
# 标签模型
class Tag(BaseModel):
name = models.CharField(_('标签名称'), max_length=30, unique=True) # 标签名称,唯一
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # Slug字段
name = models.CharField(_('标签名称'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name # 对象字符串表示为标签名称
return self.name
def get_absolute_url(self):
# 获取标签详情页URL
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10) # 缓存10小时
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
# 获取关联该标签的文章数量
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name'] # 按名称排序
ordering = ['name']
verbose_name = _('标签')
verbose_name_plural = verbose_name
# 友情链接模型
class Links(models.Model):
name = models.CharField(_('链接名称'), max_length=30, unique=True) # 链接名称,唯一
link = models.URLField(_('链接地址')) # 链接URL
sequence = models.IntegerField(_('排序'), unique=True) # 排序,唯一
is_enable = models.BooleanField(_('是否显示'), default=True) # 是否启用
show_type = models.CharField(_('显示位置'), max_length=1, choices=LinkShowType.choices, default=LinkShowType.I) # 显示位置类型
creation_time = models.DateTimeField(_('创建时间'), default=now) # 创建时间
last_mod_time = models.DateTimeField(_('修改时间'), default=now) # 修改时间
name = models.CharField(_('链接名称'), max_length=30, unique=True)
link = models.URLField(_('链接地址'))
sequence = models.IntegerField(_('排序'), unique=True)
is_enable = models.BooleanField(_('是否显示'), default=True)
show_type = models.CharField(_('显示位置'), max_length=1, choices=LinkShowType.choices, default=LinkShowType.I)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
ordering = ['sequence'] # 按排序排序
ordering = ['sequence']
verbose_name = _('友情链接')
verbose_name_plural = verbose_name
def __str__(self):
return self.name # 对象字符串表示为链接名称
return self.name
# 侧边栏模型
class SideBar(models.Model):
name = models.CharField(_('标题'), max_length=100) # 侧边栏标题
content = models.TextField(_('内容')) # 侧边栏内容可以是HTML
sequence = models.IntegerField(_('排序'), unique=True) # 排序,唯一
is_enable = models.BooleanField(_('是否启用'), default=True) # 是否启用
creation_time = models.DateTimeField(_('创建时间'), default=now) # 创建时间
last_mod_time = models.DateTimeField(_('修改时间'), default=now) # 修改时间
name = models.CharField(_('标题'), max_length=100)
content = models.TextField(_('内容'))
sequence = models.IntegerField(_('排序'), unique=True)
is_enable = models.BooleanField(_('是否启用'), default=True)
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta:
ordering = ['sequence'] # 按排序排序
ordering = ['sequence']
verbose_name = _('侧边栏')
verbose_name_plural = verbose_name
def __str__(self):
return self.name # 对象字符串表示为侧边栏标题
return self.name
class ArticleLike(models.Model):
article = models.ForeignKey(
'Article',
verbose_name=_('文章'),
on_delete=models.CASCADE,
related_name='article_likes'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('点赞时间'), default=now)
class Meta:
verbose_name = _('文章点赞')
verbose_name_plural = verbose_name
unique_together = ('article', 'user')
ordering = ['-created_time']
def __str__(self):
return f'{self.user.username} 点赞了 {self.article.title}'
class ArticleFavorite(models.Model):
"""
文章收藏模型
记录用户对文章的收藏关系每个用户对同一篇文章只能收藏一次
"""
article = models.ForeignKey(
'Article',
verbose_name=_('文章'),
on_delete=models.CASCADE,
related_name='article_favorites'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('用户'),
on_delete=models.CASCADE
)
created_time = models.DateTimeField(_('收藏时间'), default=now)
class Meta:
verbose_name = _('文章收藏')
verbose_name_plural = verbose_name
unique_together = ('article', 'user')
ordering = ['-created_time']
def __str__(self):
return f'{self.user.username} 收藏了 {self.article.title}'
# 博客设置模型(单例)
class BlogSettings(models.Model):
site_name = models.CharField(_('站点名称'), max_length=200, null=False, blank=False, default='') # 站点名称
site_description = models.TextField(_('站点描述'), max_length=1000, null=False, blank=False, default='') # 站点描述
site_seo_description = models.TextField(_('SEO描述'), max_length=1000, null=False, blank=False, default='') # SEO描述
site_keywords = models.TextField(_('站点关键词'), max_length=1000, null=False, blank=False, default='') # 站点关键词
article_sub_length = models.IntegerField(_('文章摘要长度'), default=300) # 文章摘要显示长度
sidebar_article_count = models.IntegerField(_('侧边栏文章数量'), default=10) # 侧边栏显示的文章数量
sidebar_comment_count = models.IntegerField(_('侧边栏评论数量'), default=5) # 侧边栏显示的评论数量
article_comment_count = models.IntegerField(_('文章评论数量'), default=5) # 文章页显示的评论数量
show_google_adsense = models.BooleanField(_('显示Google广告'), default=False) # 是否显示Google广告
google_adsense_codes = models.TextField(_('Google广告代码'), max_length=2000, null=True, blank=True, default='') # Google广告代码
open_site_comment = models.BooleanField(_('开放站点评论'), default=True) # 是否开放站点评论
global_header = models.TextField(_("公共头部"), null=True, blank=True, default='') # 公共头部HTML
global_footer = models.TextField(_("公共尾部"), null=True, blank=True, default='') # 公共尾部HTML
beian_code = models.CharField(_('备案号'), max_length=2000, null=True, blank=True, default='') # 备案号
analytics_code = models.TextField(_("网站统计代码"), max_length=1000, null=False, blank=False, default='') # 统计代码,如百度统计
show_gongan_code = models.BooleanField(_('是否显示公安备案号'), default=False) # 是否显示公安备案号
gongan_beiancode = models.TextField(_('公安备案号'), max_length=2000, null=True, blank=True, default='') # 公安备案号
comment_need_review = models.BooleanField(_('评论是否需要审核'), default=False) # 评论是否需要审核
site_name = models.CharField(_('站点名称'), max_length=200, null=False, blank=False, default='')
site_description = models.TextField(_('站点描述'), max_length=1000, null=False, blank=False, default='')
site_seo_description = models.TextField(_('SEO描述'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(_('站点关键词'), max_length=1000, null=False, blank=False, default='')
article_sub_length = models.IntegerField(_('文章摘要长度'), default=300)
sidebar_article_count = models.IntegerField(_('侧边栏文章数量'), default=10)
sidebar_comment_count = models.IntegerField(_('侧边栏评论数量'), default=5)
article_comment_count = models.IntegerField(_('文章评论数量'), default=5)
show_google_adsense = models.BooleanField(_('显示Google广告'), default=False)
google_adsense_codes = models.TextField(_('Google广告代码'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('开放站点评论'), default=True)
global_header = models.TextField(_("公共头部"), null=True, blank=True, default='')
global_footer = models.TextField(_("公共尾部"), null=True, blank=True, default='')
beian_code = models.CharField(_('备案号'), max_length=2000, null=True, blank=True, default='')
analytics_code = models.TextField(_("网站统计代码"), max_length=1000, null=False, blank=False, default='')
show_gongan_code = models.BooleanField(_('是否显示公安备案号'), default=False)
gongan_beiancode = models.TextField(_('公安备案号'), max_length=2000, null=True, blank=True, default='')
comment_need_review = models.BooleanField(_('评论是否需要审核'), default=False)
class Meta:
verbose_name = _('网站配置')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name # 对象字符串表示为站点名称
return self.site_name
def clean(self):
# 确保只能存在一个站点配置实例
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('只能存在一个配置'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear() # 保存配置后清除缓存
cache.clear()

@ -17,11 +17,38 @@ urlpatterns = [
views.IndexView.as_view(),
name='index_page'),
# 文章详情页通过年、月、日、文章ID定位
# 文章详情页通过年、月、日、文章ID定位
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# 文章点赞API
path(
r'article/<int:article_id>/like/',
views.article_like,
name='article_like'),
# 文章收藏API
path(
r'article/<int:article_id>/favorite/',
views.article_favorite,
name='article_favorite'),
# 我的喜欢列表
path(
r'my-likes/',
views.MyLikesView.as_view(),
name='my_likes'),
path(
r'my-likes/<int:page>/',
views.MyLikesView.as_view(),
name='my_likes_page'),
# 我的收藏列表
path(
r'my-favorites/',
views.MyFavoritesView.as_view(),
name='my_favorites'),
path(
r'my-favorites/<int:page>/',
views.MyFavoritesView.as_view(),
name='my_favorites_page'),
# 分类目录详情页,通过分类别名
path(
r'category/<slug:category_name>.html',

@ -2,69 +2,54 @@ import logging
import os
import uuid
from django.conf import settings # Django项目设置
from django.core.paginator import Paginator # 分页工具
from django.http import HttpResponse, HttpResponseForbidden # HTTP响应类
from django.shortcuts import get_object_or_404, render # 快捷函数获取对象或404渲染模板
from django.templatetags.static import static # 静态文件URL生成
from django.utils import timezone # 时间工具
from django.utils.translation import gettext_lazy as _ # 国际化翻译
from django.views.decorators.csrf import csrf_exempt # CSRF豁免装饰器
from django.views.generic.detail import DetailView # 详情页通用视图
from django.views.generic.list import ListView # 列表页通用视图
from haystack.views import SearchView # Haystack搜索视图
from blog.models import Article, Category, LinkShowType, Links, Tag # 博客相关模型
from comments.forms import CommentForm # 评论表单
from djangoblog.plugin_manage import hooks # 插件管理钩子
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME # 插件常量
from djangoblog.utils import cache, get_blog_setting, get_sha256 # 工具函数缓存、获取博客设置、生成SHA256
logger = logging.getLogger(__name__) # 日志记录器
# 文章列表视图(通用列表视图,用于首页、分类、作者、标签等)
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import F
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag, ArticleLike, ArticleFavorite
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# 指定使用的模板
template_name = 'blog/article_index.html'
# 上下文中使用的变量名
context_object_name = 'article_list'
# 页面类型,用于区分不同列表页
page_type = ''
# 每页显示条数,从项目设置中获取
paginate_by = settings.PAGINATE_BY
# 分页参数名
page_kwarg = 'page'
# 链接显示类型默认为LinkShowType.L列表页
link_type = LinkShowType.L
def get_view_cache_key(self):
# 获取视图缓存键目前未使用request GET参数可根据需求调整
return self.request.GET.get('pages', '')
@property
def page_number(self):
# 获取当前页码优先级kwargs中的page > GET参数中的page > 默认1
page_kwarg = self.page_kwarg
page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self):
"""
子类必须重写此方法用于生成查询集的缓存键
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
子类必须重写此方法用于获取查询集的数据
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
"""
从缓存中获取查询集数据若缓存存在则返回缓存数据否则获取数据并设置缓存
"""
value = cache.get(cache_key)
if value:
logger.info('get view cache.key:{key}'.format(key=cache_key))
@ -76,61 +61,40 @@ class ArticleListView(ListView):
return article_list
def get_queryset(self):
"""
重写默认的get_queryset方法优先从缓存中获取查询集
"""
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
# 向上下文中添加链接类型
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
# 首页视图继承自ArticleListView
class IndexView(ArticleListView):
'''
首页视图展示最新发布的文章
'''
# 链接类型设置为首页显示
link_type = LinkShowType.I
def get_queryset_data(self):
# 获取所有状态为发布、类型为文章的文章
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
# 缓存键根据页码生成,如 index_1, index_2, ...
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
# 文章详情页视图继承自DetailView
class ArticleDetailView(DetailView):
'''
文章详情页视图展示单篇文章的详细内容
'''
template_name = 'blog/article_detail.html' # 使用的模板
model = Article # 关联的模型
pk_url_kwarg = 'article_id' # URL中用于识别文章的主键参数名
context_object_name = "article" # 上下文中使用的变量名
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get_context_data(self, **kwargs):
# 创建评论表单实例
comment_form = CommentForm()
# 获取当前文章的所有启用状态的评论
article_comments = self.object.comment_list()
# 过滤出顶级评论(没有父评论的评论)
parent_comments = article_comments.filter(parent_comment=None)
# 获取博客设置
blog_setting = get_blog_setting()
# 创建评论分页器,每页显示指定数量的评论
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
# 获取请求中的评论页码参数默认为1
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
page = 1
@ -141,67 +105,74 @@ class ArticleDetailView(DetailView):
if page > paginator.num_pages:
page = paginator.num_pages
# 获取当前页的评论
p_comments = paginator.page(page)
# 获取下一页页码如果没有则None
next_page = p_comments.next_page_number() if p_comments.has_next() else None
# 获取上一页页码如果没有则None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# 如果有下一页生成下一页评论的URL
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
# 如果有上一页生成上一页评论的URL
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
# 将评论表单添加到上下文
kwargs['form'] = comment_form
# 将所有评论添加到上下文
kwargs['article_comments'] = article_comments
# 将当前页的评论添加到上下文
kwargs['p_comments'] = p_comments
# 将评论总数添加到上下文
kwargs['comment_count'] = len(article_comments) if article_comments else 0
# 添加下一篇和上一篇的文章链接
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
# 调用父类的get_context_data方法获取基础上下文
# ========== 修复点赞相关信息 ==========
user = self.request.user
if user.is_authenticated:
# 使用正确的方法名
is_liked = self.object.is_liked_by(user)
kwargs['article_liked_class'] = 'liked' if is_liked else ''
kwargs['like_icon_class'] = 'fa fa-heart' if is_liked else 'fa fa-heart-o'
kwargs['like_text'] = '已点赞' if is_liked else '点赞'
# 收藏相关信息
is_favorited = self.object.is_favorited_by(user)
kwargs['article_favorited_class'] = 'favorited' if is_favorited else ''
kwargs['favorite_icon_class'] = 'fa fa-star' if is_favorited else 'fa fa-star-o'
kwargs['favorite_text'] = '已收藏' if is_favorited else '收藏'
else:
kwargs['article_liked_class'] = ''
kwargs['like_icon_class'] = 'fa fa-heart-o'
kwargs['like_text'] = '点赞'
kwargs['article_favorited_class'] = ''
kwargs['favorite_icon_class'] = 'fa fa-star-o'
kwargs['favorite_text'] = '收藏'
# 添加点赞和收藏数量
kwargs['like_count'] = self.object.like_count
kwargs['favorite_count'] = self.object.favorite_count
# ========== 结束修复 ==========
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# 执行插件钩子,通知有插件“文章详情已获取”
hooks.run_action('after_article_body_get', article=article, request=self.request)
return context
# 分类目录视图继承自ArticleListView
class CategoryDetailView(ArticleListView):
'''
分类目录视图展示某个分类下的所有文章
'''
page_type = "分类目录归档"
def get_queryset_data(self):
# 从URL参数中获取分类别名
slug = self.kwargs['category_name']
# 获取对应的分类对象如果不存在则返回404
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
# 获取该分类的所有子分类名称
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
# 获取该分类及其子分类下的所有状态为发布、类型为文章的文章
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self):
# 缓存键根据分类名称和页码生成,如 category_list_分类名_页码
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
@ -211,27 +182,20 @@ class CategoryDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
# 处理分类名称,尝试去除可能的路径分隔符
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
# 向上下文中添加页面类型和标签名称(此处为分类名称)
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
# 作者文章视图继承自ArticleListView
class AuthorDetailView(ArticleListView):
'''
作者文章视图展示某个作者的所有文章
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
# 使用uuslug将作者名称转换为Slug生成缓存键如 author_作者Slug_页码
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
@ -239,40 +203,31 @@ class AuthorDetailView(ArticleListView):
return cache_key
def get_queryset_data(self):
# 从URL参数中获取作者名称获取该作者的所有状态为发布、类型为文章的文章
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs):
# 向上下文中添加页面类型和标签名称(此处为作者名称)
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
# 标签详情视图继承自ArticleListView
class TagDetailView(ArticleListView):
'''
标签详情视图展示某个标签下的所有文章
'''
page_type = '分类标签归档'
def get_queryset_data(self):
# 从URL参数中获取标签别名获取对应的标签对象
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):
# 缓存键根据标签名称和页码生成,如 tag_标签名_页码
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
@ -282,148 +237,316 @@ class TagDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
# 向上下文中添加页面类型和标签名称
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
# 文章归档视图继承自ArticleListView
class ArchivesView(ArticleListView):
'''
文章归档视图展示所有状态为发布的文章通常按时间归档
'''
page_type = '文章归档'
paginate_by = None # 不进行分页
page_kwarg = None # 不使用页码参数
template_name = 'blog/article_archives.html' # 使用不同的模板
paginate_by = None
page_kwarg = None
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
# 获取所有状态为发布的文章
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
# 缓存键为 archives
cache_key = 'archives'
return cache_key
# 友情链接视图,展示所有启用的友情链接
class LinkListView(ListView):
model = Links # 关联的模型
template_name = 'blog/links_list.html' # 使用的模板
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
# 获取所有启用的友情链接
return Links.objects.filter(is_enable=True)
# Haystack搜索视图用于全文搜索
class EsSearchView(SearchView):
def get_context(self):
# 构建搜索结果的上下文,包括查询词、表单、分页器、建议等
paginator, page = self.build_page()
context = {
"query": self.query, # 搜索查询词
"form": self.form, # 搜索表单
"page": page, # 当前页的分页对象
"paginator": paginator, # 分页器
"suggestion": None, # 搜索建议初始为None
"query": self.query,
"form": self.form,
"page": page,
"paginator": paginator,
"suggestion": None,
}
# 如果搜索后端支持拼写建议,并且有拼写建议,则添加到上下文中
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
# 添加额外的上下文信息
context.update(self.extra_context())
return context
# 文件上传视图(图床功能),需要自行实现调用端
@csrf_exempt # 豁免CSRF保护生产环境应谨慎使用
@csrf_exempt
def fileupload(request):
"""
该方法用于上传图片需自行实现调用端仅提供图床功能
:param request: HTTP请求对象
:return: HTTP响应包含上传后的图片URL列表或错误信息
"""
if request.method == 'POST':
# 获取签名参数,用于验证请求合法性
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden() # 未提供签名,禁止访问
# 验证签名是否正确签名应为SECRET_KEY的双重SHA256
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden() # 签名不正确,禁止访问
response = [] # 响应数据存储上传后的图片URL
return HttpResponseForbidden()
response = []
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d') # 当前日期,用于文件目录
imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] # 支持的图片扩展名
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 # 判断是否为图片
# 构建存储目录,按日期分类
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir) # 如果目录不存在,则创建
# 构建保存路径文件名为UUID + 原扩展名
os.makedirs(base_dir)
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post") # 安全校验,防止路径遍历
# 保存上传的文件内容
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
# 如果是图片使用PIL优化并压缩图片
if isimage:
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
# 生成静态文件URL
url = static(savepath)
response.append(url)
return HttpResponse(response) # 返回上传后的图片URL列表
return HttpResponse(response)
else:
return HttpResponse("only for post") # 仅接受POST请求
return HttpResponse("only for post")
# 自定义404错误页面视图
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception) # 记录错误日志
url = request.get_full_path() # 获取请求的完整路径
logger.error(exception)
url = request.get_full_path()
return render(request,
template_name,
{'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404) # 渲染404错误页面
status=404)
# 自定义500错误页面视图
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500) # 渲染500错误页面
status=500)
# 自定义403权限拒绝页面视图
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
if exception:
logger.error(exception) # 记录错误日志
logger.error(exception)
return render(
request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403) # 渲染403错误页面
'statuscode': '403'}, status=403)
# 清理缓存视图,调用缓存清理函数并返回成功响应
def clean_cache_view(request):
cache.clear() # 清除所有缓存
return HttpResponse('ok') # 返回简单的'ok'响应
cache.clear()
return HttpResponse('ok')
# 文章点赞API视图
@login_required
def article_like(request, article_id):
"""
文章点赞/取消点赞 API
用户可以通过该接口对文章进行点赞或取消点赞
如果用户已点赞再次点赞将取消点赞如果未点赞则添加点赞
Args:
request: HTTP请求对象
article_id: 文章ID
Returns:
JsonResponse: 返回JSON格式的响应
"""
# 只接受POST请求
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': _('只支持POST请求')
}, status=405)
# 获取文章对象
article = get_object_or_404(Article, id=article_id, status='p')
user = request.user
try:
# 尝试获取点赞记录
like = ArticleLike.objects.filter(article=article, user=user).first()
if like:
# 如果已点赞,则取消点赞
like.delete()
# 减少点赞数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(like_count=F('like_count') - 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'liked': False,
'like_count': article.like_count,
'message': _('取消点赞成功')
})
else:
# 如果未点赞,则添加点赞
ArticleLike.objects.create(article=article, user=user)
# 增加点赞数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(like_count=F('like_count') + 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'liked': True,
'like_count': article.like_count,
'message': _('点赞成功')
})
except Exception as e:
logger.error(f'点赞失败: {str(e)}')
return JsonResponse({
'success': False,
'message': _('点赞失败,请稍后再试')
}, status=500)
# 文章收藏API视图
@login_required
def article_favorite(request, article_id):
"""
文章收藏/取消收藏 API
用户可以通过该接口对文章进行收藏或取消收藏
如果用户已收藏再次收藏将取消收藏如果未收藏则添加收藏
Args:
request: HTTP请求对象
article_id: 文章ID
Returns:
JsonResponse: 返回JSON格式的响应
"""
# 只接受POST请求
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': _('只支持POST请求')
}, status=405)
# 获取文章对象
article = get_object_or_404(Article, id=article_id, status='p')
user = request.user
try:
# 尝试获取收藏记录
favorite = ArticleFavorite.objects.filter(article=article, user=user).first()
if favorite:
# 如果已收藏,则取消收藏
favorite.delete()
# 减少收藏数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(favorite_count=F('favorite_count') - 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'favorited': False,
'favorite_count': article.favorite_count,
'message': _('取消收藏成功')
})
else:
# 如果未收藏,则添加收藏
ArticleFavorite.objects.create(article=article, user=user)
# 增加收藏数使用F表达式避免竞态条件
Article.objects.filter(id=article_id).update(favorite_count=F('favorite_count') + 1)
article.refresh_from_db()
return JsonResponse({
'success': True,
'favorited': True,
'favorite_count': article.favorite_count,
'message': _('收藏成功')
})
except Exception as e:
logger.error(f'收藏失败: {str(e)}')
return JsonResponse({
'success': False,
'message': _('收藏失败,请稍后再试')
}, status=500)
# 我的喜欢列表视图
class MyLikesView(ArticleListView):
"""显示当前用户点赞的所有文章"""
template_name = 'blog/article_index.html'
page_type = '我的喜欢'
def get_queryset_data(self):
if not self.request.user.is_authenticated:
return Article.objects.none()
# 获取用户点赞的所有文章ID
liked_article_ids = ArticleLike.objects.filter(
user=self.request.user
).values_list('article_id', flat=True)
# 返回对应的文章列表
return Article.objects.filter(
id__in=liked_article_ids,
status='p'
).order_by('-pub_time')
def get_queryset_cache_key(self):
# 不使用缓存,因为点赞列表会频繁变化
return None
def get_queryset_from_cache(self, cache_key):
# 直接返回数据,不使用缓存
return self.get_queryset_data()
def get_context_data(self, **kwargs):
kwargs['page_type'] = self.page_type
return super(MyLikesView, self).get_context_data(**kwargs)
# 我的收藏列表视图
class MyFavoritesView(ArticleListView):
"""显示当前用户收藏的所有文章"""
template_name = 'blog/article_index.html'
page_type = '我的收藏'
def get_queryset_data(self):
if not self.request.user.is_authenticated:
return Article.objects.none()
# 获取用户收藏的所有文章ID
favorited_article_ids = ArticleFavorite.objects.filter(
user=self.request.user
).values_list('article_id', flat=True)
# 返回对应的文章列表
return Article.objects.filter(
id__in=favorited_article_ids,
status='p'
).order_by('-pub_time')
def get_queryset_cache_key(self):
# 不使用缓存,因为收藏列表会频繁变化
return None
def get_queryset_from_cache(self, cache_key):
# 直接返回数据,不使用缓存
return self.get_queryset_data()
def get_context_data(self, **kwargs):
kwargs['page_type'] = self.page_type
return super(MyFavoritesView, self).get_context_data(**kwargs)

@ -90,6 +90,8 @@ admin_site.register(Tag, TagAdmin) # 标签模型
admin_site.register(Links, LinksAdmin) # 友情链接模型
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型
admin_site.register(ArticleLike, ArticleLikeAdmin) # 文章点赞模型
admin_site.register(ArticleFavorite, ArticleFavoriteAdmin) # 文章收藏模型
# 注册服务器管理相关模型
admin_site.register(commands, CommandsAdmin) # 命令模型

@ -2,12 +2,158 @@
{% load blog_tags %}
{% block header %}
<style>
/* 点赞按钮样式 */
.article-like-btn {
display: inline-block;
padding: 10px 20px;
margin: 20px 0;
background-color: #f8f9fa;
border: 2px solid #dee2e6;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 16px;
color: #495057;
text-align: center;
}
.article-like-btn:hover {
background-color: #e9ecef;
border-color: #adb5bd;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.article-like-btn.liked {
background-color: #ff6b6b;
border-color: #ff6b6b;
color: white;
}
.article-like-btn.liked:hover {
background-color: #ff5252;
border-color: #ff5252;
}
.article-like-btn i {
margin-right: 5px;
font-size: 18px;
}
.article-like-btn.liked i {
animation: heartBeat 0.3s ease;
}
/* 收藏按钮样式 */
.article-favorite-btn {
display: inline-block;
padding: 10px 20px;
margin: 20px 0;
background-color: #f8f9fa;
border: 2px solid #dee2e6;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 16px;
color: #495057;
text-align: center;
}
.article-favorite-btn:hover {
background-color: #e9ecef;
border-color: #adb5bd;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.article-favorite-btn.favorited {
background-color: #ffc107;
border-color: #ffc107;
color: white;
}
.article-favorite-btn.favorited:hover {
background-color: #ffb300;
border-color: #ffb300;
}
.article-favorite-btn i {
margin-right: 5px;
font-size: 18px;
}
.article-favorite-btn.favorited i {
animation: starBounce 0.3s ease;
}
@keyframes heartBeat {
0%, 100% { transform: scale(1); }
25% { transform: scale(1.2); }
50% { transform: scale(1.1); }
}
@keyframes starBounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.like-container {
text-align: center;
margin: 30px 0;
}
.article-like-btn:disabled,
.article-favorite-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
{% endblock %}
{% block content %}
<div id="primary" class="site-content">
<div id="content" role="main">
{% load_article_detail article False user %}
<!-- 文章点赞和收藏按钮 -->
<div class="like-container" style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<!-- 点赞按钮 -->
{% if user.is_authenticated %}
<button class="article-like-btn {{ article_liked_class }}"
id="like-btn"
data-article-id="{{ article.id }}"
onclick="toggleLike()">
<i class="{{ like_icon_class }}" id="like-icon"></i>
<span id="like-text">{{ like_text }}</span>
<span id="like-count">{{ article.like_count }}</span>
</button>
{% else %}
<button class="article-like-btn" disabled title="请登录后点赞">
<i class="fa fa-heart-o"></i>
<span>点赞</span>
<span id="like-count">{{ article.like_count }}</span>
</button>
{% endif %}
<!-- 收藏按钮 -->
{% if user.is_authenticated %}
<button class="article-favorite-btn {{ article_favorited_class }}"
id="favorite-btn"
data-article-id="{{ article.id }}"
onclick="toggleFavorite()">
<i class="{{ favorite_icon_class }}" id="favorite-icon"></i>
<span id="favorite-text">{{ favorite_text }}</span>
<span id="favorite-count">{{ article.favorite_count }}</span>
</button>
{% else %}
<button class="article-favorite-btn" disabled title="请登录后收藏">
<i class="fa fa-star-o"></i>
<span>收藏</span>
<span id="favorite-count">{{ article.favorite_count }}</span>
</button>
{% endif %}
</div>
{% if article.type == 'a' %}
<nav class="nav-single">
<h3 class="assistive-text">文章导航</h3>
@ -49,4 +195,256 @@
{% block sidebar %}
{% load_sidebar user "p" %}
{% endblock %}
{% block extra_js %}
<script>
console.log('Like script loaded successfully');
function toggleLike() {
console.log('toggleLike function called');
const btn = document.getElementById('like-btn');
const icon = document.getElementById('like-icon');
const text = document.getElementById('like-text');
const count = document.getElementById('like-count');
if (!btn) {
console.error('Like button not found!');
return;
}
const articleId = btn.dataset.articleId;
// 禁用按钮防止重复点击
btn.disabled = true;
// 获取CSRF token
const csrftoken = getCookie('csrftoken');
console.log('Article ID:', articleId);
console.log('CSRF Token:', csrftoken);
console.log('Request URL:', `/article/${articleId}/like/`);
if (!csrftoken) {
console.error('CSRF token not found!');
showMessage('无法获取CSRF token请刷新页面', 'error');
btn.disabled = false;
return;
}
// 发送Ajax请求
fetch(`/article/${articleId}/like/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
},
credentials: 'same-origin'
})
.then(response => {
console.log('Response status:', response.status);
console.log('Response headers:', response.headers);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('Response data:', data);
if (data.success) {
// 更新UI
if (data.liked) {
// 已点赞状态
btn.classList.add('liked');
icon.className = 'fa fa-heart';
text.textContent = '已点赞';
} else {
// 未点赞状态
btn.classList.remove('liked');
icon.className = 'fa fa-heart-o';
text.textContent = '点赞';
}
// 更新点赞数
count.textContent = data.like_count;
// 显示提示消息
showMessage(data.message, 'success');
} else {
// 错误处理
showMessage(data.message || '操作失败,请稍后再试', 'error');
}
})
.catch(error => {
console.error('Fetch error:', error);
showMessage('网络错误: ' + error.message, 'error');
})
.finally(() => {
// 重新启用按钮
btn.disabled = false;
console.log('Request completed');
});
}
// 收藏功能
function toggleFavorite() {
console.log('toggleFavorite function called');
const btn = document.getElementById('favorite-btn');
const icon = document.getElementById('favorite-icon');
const text = document.getElementById('favorite-text');
const count = document.getElementById('favorite-count');
if (!btn) {
console.error('Favorite button not found!');
return;
}
const articleId = btn.dataset.articleId;
// 禁用按钮防止重复点击
btn.disabled = true;
// 获取CSRF token
const csrftoken = getCookie('csrftoken');
console.log('Article ID:', articleId);
console.log('CSRF Token:', csrftoken);
console.log('Request URL:', `/article/${articleId}/favorite/`);
if (!csrftoken) {
console.error('CSRF token not found!');
showMessage('无法获取CSRF token请刷新页面', 'error');
btn.disabled = false;
return;
}
// 发送Ajax请求
fetch(`/article/${articleId}/favorite/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
},
credentials: 'same-origin'
})
.then(response => {
console.log('Response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('Response data:', data);
if (data.success) {
// 更新UI
if (data.favorited) {
// 已收藏状态
btn.classList.add('favorited');
icon.className = 'fa fa-star';
text.textContent = '已收藏';
} else {
// 未收藏状态
btn.classList.remove('favorited');
icon.className = 'fa fa-star-o';
text.textContent = '收藏';
}
// 更新收藏数
count.textContent = data.favorite_count;
// 显示提示消息
showMessage(data.message, 'success');
} else {
// 错误处理
showMessage(data.message || '操作失败,请稍后再试', 'error');
}
})
.catch(error => {
console.error('Fetch error:', error);
showMessage('网络错误: ' + error.message, 'error');
})
.finally(() => {
// 重新启用按钮
btn.disabled = false;
console.log('Request completed');
});
}
// 获取Cookie函数
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;
}
// 显示提示消息函数
function showMessage(message, type) {
console.log('Showing message:', message, 'type:', type);
// 简单的提示消息实现
const messageDiv = document.createElement('div');
messageDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background-color: ${type === 'success' ? '#28a745' : '#dc3545'};
color: white;
border-radius: 5px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 9999;
animation: slideIn 0.3s ease;
`;
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
// 3秒后自动消失
setTimeout(() => {
messageDiv.style.animation = 'slideOut 0.3s ease';
setTimeout(() => messageDiv.remove(), 300);
}, 3000);
}
// 添加动画样式
if (!document.getElementById('message-animations')) {
const style = document.createElement('style');
style.id = 'message-animations';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
}
// 页面加载后检查
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM loaded');
const likeBtn = document.getElementById('like-btn');
if (likeBtn) {
console.log('Like button found:', likeBtn);
console.log('Article ID:', likeBtn.dataset.articleId);
} else {
console.log('Like button NOT found - user might not be logged in');
}
});
</script>
{% endblock %}

@ -58,12 +58,185 @@
{% endblock %}
{% endcompress %}
<!-- 暗色模式CSS样式 -->
<style>
/* 默认亮色模式变量 */
:root {
--bg-color: #ffffff;
--text-color: #333333;
--header-bg: #f9f9f9;
--nav-bg: #ffffff;
--nav-text: #333333;
--link-color: #21759b;
--border-color: #e5e5e5;
--code-bg: #f5f5f5;
--sidebar-bg: #ffffff;
}
/* 暗色模式变量 */
body.dark-mode {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--header-bg: #2d2d2d;
--nav-bg: #2d2d2d;
--nav-text: #e0e0e0;
--link-color: #4a9eff;
--border-color: #404040;
--code-bg: #2d2d2d;
--sidebar-bg: #2d2d2d;
}
/* 应用样式 */
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 确保所有主要容器都使用正确的背景色 */
#page {
background-color: var(--bg-color);
}
.wrapper {
background-color: var(--bg-color);
}
#masthead {
background-color: var(--header-bg);
border-bottom: 1px solid var(--border-color);
}
.site-title a,
.site-description {
color: var(--text-color);
}
#site-navigation {
background-color: var(--nav-bg);
border-bottom: 1px solid var(--border-color);
}
.nav-menu a {
color: var(--nav-text);
border-bottom: none;
transition: background-color 0.2s ease;
}
.nav-menu a:hover {
background-color: rgba(0, 0, 0, 0.05);
}
body.dark-mode .nav-menu a:hover {
background-color: rgba(255, 255, 255, 0.1);
}
a {
color: var(--link-color);
}
#primary,
#secondary {
background-color: var(--bg-color);
}
/* 文章列表和文章内容 */
.entry-content,
.comment-content,
.entry-header,
.entry-meta {
color: var(--text-color);
}
article,
.hentry {
background-color: var(--bg-color);
border-color: var(--border-color);
}
.entry-title a {
color: var(--text-color);
}
.entry-title a:hover {
color: var(--link-color);
}
pre,
code {
background-color: var(--code-bg);
border-color: var(--border-color);
color: var(--text-color);
}
.widget-area .widget {
background-color: var(--sidebar-bg);
border: 1px solid var(--border-color);
}
.widget-title {
color: var(--text-color);
}
/* 搜索框和输入框 */
input[type="text"],
input[type="search"],
input[type="email"],
textarea {
background-color: var(--code-bg);
color: var(--text-color);
border-color: var(--border-color);
}
/* 评论区域 */
.comments-area,
.comment-list,
.comment-body {
background-color: var(--bg-color);
border-color: var(--border-color);
}
/* 右上角暗色模式切换按钮 */
.dark-mode-toggle-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: var(--nav-bg);
border: 2px solid var(--border-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.dark-mode-toggle-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.dark-mode-toggle-btn i {
font-size: 24px;
color: var(--nav-text);
}
</style>
{% if GLOBAL_HEADER %}
{{ GLOBAL_HEADER|safe }}
{% endif %}
</head>
<body class="home blog custom-font-enabled">
<!-- 右上角暗色模式切换按钮 -->
<button class="dark-mode-toggle-btn" onclick="toggleDarkMode()" title="切换亮/暗模式">
<i class="fa fa-moon-o" id="dark-mode-icon"></i>
</button>
<div id="page" class="hfeed site">
<header id="masthead" class="site-header" role="banner">
<hgroup>
@ -103,6 +276,51 @@
<!-- MathJax智能加载器 -->
<script src="{% static 'blog/js/mathjax-loader.js' %}" async defer></script>
<!-- 暗色模式切换JavaScript -->
<script>
// 页面加载时检查本地存储的主题设置
(function() {
const darkMode = localStorage.getItem('darkMode');
const body = document.body;
if (darkMode === 'enabled') {
body.classList.add('dark-mode');
// 等待DOM加载后更新图标
document.addEventListener('DOMContentLoaded', function() {
const icon = document.getElementById('dark-mode-icon');
if (icon) {
icon.className = 'fa fa-sun-o';
}
});
}
})();
// 切换暗色模式函数
function toggleDarkMode() {
const body = document.body;
const icon = document.getElementById('dark-mode-icon');
body.classList.toggle('dark-mode');
if (body.classList.contains('dark-mode')) {
// 启用暗色模式
localStorage.setItem('darkMode', 'enabled');
if (icon) {
icon.className = 'fa fa-sun-o'; // 显示太阳图标(表示可切换到亮色)
}
} else {
// 禁用暗色模式
localStorage.setItem('darkMode', 'disabled');
if (icon) {
icon.className = 'fa fa-moon-o'; // 显示月亮图标(表示可切换到暗色)
}
}
}
</script>
{% block extra_js %}
{% endblock %}
{% block footer %}
{% endblock %}
</body>

@ -21,6 +21,17 @@
</li>
{% endfor %}
{% endif %}
<!-- 喜欢和收藏链接 -->
{% if user.is_authenticated %}
<li class="menu-item menu-item-type-taxonomy menu-item-object-category">
<a href="{% url 'blog:my_likes' %}"><i class="fa fa-heart"></i> 我的喜欢</a>
</li>
<li class="menu-item menu-item-type-taxonomy menu-item-object-category">
<a href="{% url 'blog:my_favorites' %}"><i class="fa fa-star"></i> 我的收藏</a>
</li>
{% endif %}
<li class="menu-item menu-item-type-taxonomy menu-item-object-category menu-item-has-children">
<a href="{% url "blog:archives" %}">{% trans 'Article archive' %}</a>

Loading…
Cancel
Save