django #11

Open
pqkpsr3gu wants to merge 1 commits from hz_branch into master

@ -47,6 +47,13 @@ class BlogUserChangeForm(UserChangeForm):
class BlogUserAdmin(UserAdmin):
form = BlogUserChangeForm
add_form = BlogUserCreationForm
# Django 5.x 兼容性覆盖add_fieldsets以排除usable_password字段
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('username', 'password1', 'password2'),
}),
)
list_display = (
'id',
'nickname',

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
#zhq: 文章表单类 - 可用于定制文章编辑表单
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
@ -16,29 +16,29 @@ class ArticleForm(forms.ModelForm):
model = Article
fields = '__all__'
#zhq: Admin动作函数 - 批量发布文章
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
#zhq: Admin动作函数 - 批量设为草稿
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
#zhq: Admin动作函数 - 批量关闭评论
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
#zhq: Admin动作函数 - 批量开启评论
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
#zhq: 设置动作的描述信息
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
#zhq: 文章管理类 - 定制文章在Admin中的显示和行为
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
@ -52,7 +52,8 @@ class ArticlelAdmin(admin.ModelAdmin):
'views',
'status',
'type',
'article_order')
'article_order',
'version_count') # 添加版本数量显示
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
date_hierarchy = 'creation_time'
@ -63,17 +64,30 @@ class ArticlelAdmin(admin.ModelAdmin):
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
open_article_commentstatus] #zhq: 注册批量动作
raw_id_fields = ('author', 'category',)
def link_to_category(self, obj):
# zhq: 自定义方法,生成分类的管理后台链接
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('category')
def version_count(self, obj):
"""显示文章版本数量和链接"""
from blog.models_version import ArticleVersion
count = ArticleVersion.objects.filter(article=obj).count()
if count > 0:
url = reverse('admin:blog_articleversion_changelist') + f'?article__id__exact={obj.id}'
return format_html('<a href="{}">{} 个版本</a>', url, count)
return '无版本'
version_count.short_description = _('Versions')
def get_form(self, request, obj=None, **kwargs):
# zhq: 限制作者字段只能选择超级用户
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
@ -83,6 +97,7 @@ class ArticlelAdmin(admin.ModelAdmin):
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
# zhq: 获取文章在前台的访问链接
if obj:
url = obj.get_full_url()
return url
@ -91,24 +106,37 @@ class ArticlelAdmin(admin.ModelAdmin):
site = get_current_site().domain
return site
class Media:
"""引入草稿自动保存的 JavaScript"""
js = ('blog/js/article-draft-autosave.js',)
#zhq: 标签管理类 - 简化管理界面
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
#zhq: 分类管理类 - 显示父级分类信息
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
#zhq: 友情链接管理类
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
#zhq: 侧边栏管理类
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
#zhq: 博客设置管理类 - 使用默认管理界面
class BlogSettingsAdmin(admin.ModelAdmin):
pass
# 导入文章版本管理(导入模块以触发装饰器注册)
import blog.admin_version
# 导入文章草稿管理(导入模块以触发装饰器注册)
import blog.admin_draft
# 导入社交功能管理(导入模块以触发装饰器注册)
import blog.admin_social
# 导入多媒体管理(导入模块以触发装饰器注册)
import blog.admin_media

@ -0,0 +1,110 @@
# 文章草稿 Admin 管理界面
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from blog.models_draft import ArticleDraft
@admin.register(ArticleDraft)
class ArticleDraftAdmin(admin.ModelAdmin):
"""文章草稿管理界面"""
list_display = [
'id',
'title_display',
'author',
'article_link',
'preview',
'last_update_time',
'is_published',
'action_buttons'
]
list_filter = [
'is_published',
'author',
'last_update_time',
'creation_time'
]
search_fields = [
'title',
'body',
'author__username'
]
readonly_fields = [
'author',
'article',
'creation_time',
'last_update_time',
'session_id'
]
fields = [
'author',
'article',
'title',
'body',
'category_id',
'tags_data',
'status',
'comment_status',
'type',
'session_id',
'is_published',
'creation_time',
'last_update_time'
]
list_per_page = 50
def has_add_permission(self, request):
"""禁止手动添加草稿"""
return False
def title_display(self, obj):
"""显示标题"""
title = obj.title or '(无标题)'
if len(title) > 40:
return title[:37] + '...'
return title
title_display.short_description = '标题'
def article_link(self, obj):
"""文章链接"""
if obj.article:
url = reverse('admin:blog_article_change', args=[obj.article.id])
return format_html('<a href="{}">{}</a>', url, obj.article.title)
return '-'
article_link.short_description = '文章'
def preview(self, obj):
"""预览文本"""
preview_text = obj.get_preview_text(30)
return format_html('<span style="color: #666;">{}</span>', preview_text)
preview.short_description = '预览'
def action_buttons(self, obj):
"""操作按钮"""
if not obj.is_published:
apply_url = f'/blog/api/draft/apply/'
delete_url = f'/blog/api/draft/delete/'
return format_html(
'<button class="button" onclick="applyDraft({})">应用到文章</button> '
'<button class="button" onclick="deleteDraft({})">删除</button>',
obj.id, obj.id
)
return '已发布'
action_buttons.short_description = '操作'
class Media:
js = ('admin/js/draft_actions.js',)

@ -0,0 +1,210 @@
# 多媒体管理 Admin 界面
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from blog.models_media import MediaFile, MediaFolder, MediaFileFolder
@admin.register(MediaFile)
class MediaFileAdmin(admin.ModelAdmin):
"""媒体文件管理界面"""
list_display = [
'id',
'thumbnail_preview',
'original_filename',
'file_type',
'file_size_display',
'uploader_link',
'upload_time',
'reference_count',
'is_public'
]
list_filter = [
'file_type',
'is_public',
'upload_time'
]
search_fields = [
'original_filename',
'description',
'uploader__username'
]
readonly_fields = [
'stored_filename',
'file_hash',
'file_size',
'mime_type',
'file_path',
'thumbnail_path',
'width',
'height',
'upload_time',
'thumbnail_preview_large'
]
fieldsets = (
('基本信息', {
'fields': ('original_filename', 'file_type', 'description', 'is_public')
}),
('文件详情', {
'fields': ('stored_filename', 'file_size', 'file_hash', 'mime_type', 'file_path')
}),
('图片信息', {
'fields': ('width', 'height', 'thumbnail_path', 'thumbnail_preview_large'),
'classes': ('collapse',)
}),
('用户信息', {
'fields': ('uploader', 'upload_time', 'reference_count')
}),
)
list_per_page = 50
def thumbnail_preview(self, obj):
"""缩略图预览(列表)"""
if obj.is_image():
return format_html(
'<img src="{}" style="max-width: 50px; max-height: 50px; object-fit: cover;" />',
obj.get_thumbnail_url()
)
return '📄'
thumbnail_preview.short_description = '预览'
def thumbnail_preview_large(self, obj):
"""缩略图预览(详情)"""
if obj.is_image():
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px;" />',
obj.get_absolute_url()
)
return '非图片文件'
thumbnail_preview_large.short_description = '图片预览'
def file_size_display(self, obj):
"""文件大小显示"""
size = obj.file_size
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
file_size_display.short_description = '文件大小'
def uploader_link(self, obj):
"""上传者链接"""
if obj.uploader:
url = reverse('admin:accounts_bloguser_change', args=[obj.uploader.id])
return format_html('<a href="{}">{}</a>', url, obj.uploader.username)
return '-'
uploader_link.short_description = '上传者'
def has_add_permission(self, request):
"""禁止手动添加(应通过上传功能)"""
return False
@admin.register(MediaFolder)
class MediaFolderAdmin(admin.ModelAdmin):
"""媒体文件夹管理界面"""
list_display = [
'id',
'name',
'full_path_display',
'owner_link',
'files_count_display',
'created_time'
]
list_filter = [
'created_time'
]
search_fields = [
'name',
'description',
'owner__username'
]
readonly_fields = [
'created_time',
'full_path_display'
]
fieldsets = (
('文件夹信息', {
'fields': ('name', 'parent', 'owner', 'description')
}),
('详细信息', {
'fields': ('created_time', 'full_path_display')
}),
)
def full_path_display(self, obj):
"""完整路径显示"""
return obj.get_full_path()
full_path_display.short_description = '完整路径'
def owner_link(self, obj):
"""所有者链接"""
url = reverse('admin:accounts_bloguser_change', args=[obj.owner.id])
return format_html('<a href="{}">{}</a>', url, obj.owner.username)
owner_link.short_description = '所有者'
def files_count_display(self, obj):
"""文件数量显示"""
return obj.file_relations.count()
files_count_display.short_description = '文件数量'
@admin.register(MediaFileFolder)
class MediaFileFolderAdmin(admin.ModelAdmin):
"""媒体文件-文件夹关联管理界面"""
list_display = [
'id',
'file_link',
'folder_link',
'added_time'
]
list_filter = [
'added_time'
]
search_fields = [
'file__original_filename',
'folder__name'
]
readonly_fields = [
'added_time'
]
def file_link(self, obj):
"""文件链接"""
url = reverse('admin:blog_mediafile_change', args=[obj.file.id])
return format_html('<a href="{}">{}</a>', url, obj.file.original_filename)
file_link.short_description = '文件'
def folder_link(self, obj):
"""文件夹链接"""
url = reverse('admin:blog_mediafolder_change', args=[obj.folder.id])
return format_html('<a href="{}">{}</a>', url, obj.folder.name)
folder_link.short_description = '文件夹'

@ -0,0 +1,168 @@
# 用户关注和收藏 Admin 管理界面
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from blog.models_social import UserFollow, ArticleFavorite, ArticleLike
@admin.register(UserFollow)
class UserFollowAdmin(admin.ModelAdmin):
"""用户关注管理界面"""
list_display = [
'id',
'follower_link',
'following_link',
'creation_time'
]
list_filter = [
'creation_time'
]
search_fields = [
'follower__username',
'following__username'
]
readonly_fields = [
'follower',
'following',
'creation_time'
]
list_per_page = 50
def has_add_permission(self, request):
"""禁止手动添加"""
return False
def follower_link(self, obj):
"""关注者链接"""
url = reverse('admin:accounts_bloguser_change', args=[obj.follower.id])
return format_html('<a href="{}">{}</a>', url, obj.follower.username)
follower_link.short_description = '关注者'
def following_link(self, obj):
"""被关注者链接"""
url = reverse('admin:accounts_bloguser_change', args=[obj.following.id])
return format_html('<a href="{}">{}</a>', url, obj.following.username)
following_link.short_description = '被关注者'
@admin.register(ArticleFavorite)
class ArticleFavoriteAdmin(admin.ModelAdmin):
"""文章收藏管理界面"""
list_display = [
'id',
'user_link',
'article_link',
'note_display',
'creation_time'
]
list_filter = [
'creation_time'
]
search_fields = [
'user__username',
'article__title',
'note'
]
readonly_fields = [
'user',
'article',
'creation_time'
]
list_per_page = 50
def has_add_permission(self, request):
"""禁止手动添加"""
return False
def user_link(self, obj):
"""用户链接"""
url = reverse('admin:accounts_bloguser_change', args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
user_link.short_description = '用户'
def article_link(self, obj):
"""文章链接"""
url = reverse('admin:blog_article_change', args=[obj.article.id])
title = obj.article.title
if len(title) > 50:
title = title[:47] + '...'
return format_html('<a href="{}">{}</a>', url, title)
article_link.short_description = '文章'
def note_display(self, obj):
"""显示备注"""
if obj.note:
if len(obj.note) > 30:
return obj.note[:27] + '...'
return obj.note
return '-'
note_display.short_description = '备注'
@admin.register(ArticleLike)
class ArticleLikeAdmin(admin.ModelAdmin):
"""文章点赞管理界面"""
list_display = [
'id',
'user_link',
'article_link',
'creation_time'
]
list_filter = [
'creation_time'
]
search_fields = [
'user__username',
'article__title'
]
readonly_fields = [
'user',
'article',
'creation_time'
]
list_per_page = 50
def has_add_permission(self, request):
"""禁止手动添加"""
return False
def user_link(self, obj):
"""用户链接"""
url = reverse('admin:accounts_bloguser_change', args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
user_link.short_description = '用户'
def article_link(self, obj):
"""文章链接"""
url = reverse('admin:blog_article_change', args=[obj.article.id])
title = obj.article.title
if len(title) > 50:
title = title[:47] + '...'
return format_html('<a href="{}">{}</a>', url, title)
article_link.short_description = '文章'

@ -0,0 +1,217 @@
# 文章版本管理 Admin 界面
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.shortcuts import redirect, render
from django.contrib import messages
from django.template.response import TemplateResponse
from blog.models_version import ArticleVersion
@admin.register(ArticleVersion)
class ArticleVersionAdmin(admin.ModelAdmin):
"""文章版本管理界面"""
list_display = [
'version_number',
'article_link',
'title_display',
'created_by',
'creation_time',
'change_summary_display',
'is_auto_save',
'action_buttons'
]
list_filter = [
'is_auto_save',
'creation_time',
'created_by'
]
search_fields = [
'title',
'change_summary',
'article__title'
]
readonly_fields = [
'article',
'version_number',
'title',
'body',
'pub_time',
'status',
'comment_status',
'type',
'category_id',
'category_name',
'created_by',
'creation_time',
'is_auto_save'
]
fields = [
'article',
'version_number',
'title',
'body',
'pub_time',
'status',
'comment_status',
'type',
'category_name',
'created_by',
'creation_time',
'change_summary',
'is_auto_save'
]
list_per_page = 50
def has_add_permission(self, request):
"""禁止手动添加版本"""
return False
def has_delete_permission(self, request, obj=None):
"""禁止删除版本(保持历史完整性)"""
return False
def article_link(self, obj):
"""文章链接"""
url = reverse('admin:blog_article_change', args=[obj.article.id])
return format_html('<a href="{}">{}</a>', url, obj.article.title)
article_link.short_description = '文章'
def title_display(self, obj):
"""显示标题(截断)"""
if len(obj.title) > 50:
return obj.title[:47] + '...'
return obj.title
title_display.short_description = '标题'
def change_summary_display(self, obj):
"""显示变更说明"""
if obj.change_summary:
return obj.change_summary
return format_html('<span style="color: #999;">自动保存</span>')
change_summary_display.short_description = '变更说明'
def action_buttons(self, obj):
"""操作按钮"""
restore_url = reverse('admin:restore_article_version', args=[obj.id])
compare_url = reverse('admin:compare_article_version', args=[obj.id])
return format_html(
'<a class="button" href="{}">恢复此版本</a> '
'<a class="button" href="{}">对比</a>',
restore_url,
compare_url
)
action_buttons.short_description = '操作'
def get_urls(self):
"""添加自定义URL"""
from django.urls import path
urls = super().get_urls()
custom_urls = [
path(
'<int:version_id>/restore/',
self.admin_site.admin_view(self.restore_version),
name='restore_article_version',
),
path(
'<int:version_id>/compare/',
self.admin_site.admin_view(self.compare_version),
name='compare_article_version',
),
]
return custom_urls + urls
def restore_version(self, request, version_id):
"""恢复版本"""
try:
version = ArticleVersion.objects.get(id=version_id)
except ArticleVersion.DoesNotExist:
messages.error(request, '版本不存在')
return redirect('admin:blog_articleversion_changelist')
if request.method == 'POST':
# 在恢复前先保存当前版本
ArticleVersion.create_version(
article=version.article,
user=request.user,
change_summary=f'恢复前的版本(准备恢复到 v{version.version_number}',
is_auto_save=False
)
if version.restore_to_article():
messages.success(
request,
f'已成功将文章恢复到版本 v{version.version_number}'
)
return redirect('admin:blog_article_change', version.article.id)
else:
messages.error(request, '恢复版本失败')
return redirect('admin:blog_articleversion_changelist')
# 显示确认页面
context = {
**self.admin_site.each_context(request),
'version': version,
'current': version.article,
'opts': self.model._meta,
'title': f'恢复版本 v{version.version_number}',
}
return TemplateResponse(
request,
'admin/blog/articleversion/restore_confirmation.html',
context
)
def compare_version(self, request, version_id):
"""对比版本"""
try:
version = ArticleVersion.objects.get(id=version_id)
except ArticleVersion.DoesNotExist:
messages.error(request, '版本不存在')
return redirect('admin:blog_articleversion_changelist')
current = version.article
diff = version.get_diff_with_current()
# 生成正文差异(如果有变化)
body_diff_html = None
if diff['body_changed']:
import difflib
d = difflib.HtmlDiff()
body_diff_html = d.make_table(
version.body.splitlines(),
current.body.splitlines(),
f'版本 v{version.version_number}',
'当前版本',
context=True,
numlines=3
)
context = {
**self.admin_site.each_context(request),
'version': version,
'current': current,
'diff': diff,
'body_diff_html': body_diff_html,
'opts': self.model._meta,
'title': f'对比版本 v{version.version_number}',
}
return TemplateResponse(
request,
'admin/blog/articleversion/compare.html',
context
)

@ -1,5 +1,20 @@
from django.apps import AppConfig
#zhq: 博客应用配置类 - 继承自Django的AppConfig基类
class BlogConfig(AppConfig):
name = 'blog'
name = 'blog' #zhq: 应用名称对应INSTALLED_APPS中的名称
default_auto_field = 'django.db.models.BigAutoField'
def ready(self):
"""
应用启动时执行的初始化操作
导入所有Admin模块以确保它们被注册
"""
# 导入Admin模块以触发注册
try:
from blog import admin_version # noqa
from blog import admin_draft # noqa
from blog import admin_social # noqa
from blog import admin_media # noqa
except ImportError:
pass

@ -7,7 +7,7 @@ from .models import Category, Article
logger = logging.getLogger(__name__)
#zhq: SEO上下文处理器 - 为所有模板提供SEO相关变量和导航数据
def seo_processor(requests):
key = 'seo_processor'
value = cache.get(key)
@ -15,29 +15,32 @@ def seo_processor(requests):
return value
else:
logger.info('set processor cache.')
# zhq: 获取博客全局设置
setting = get_blog_setting()
# zhq: 构建上下文数据字典
value = {
'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'SITE_NAME': setting.site_name,#zhq: 网站名称
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,#zhq: 是否显示Google广告
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,#zhq: Google广告代码
'SITE_SEO_DESCRIPTION': setting.site_seo_description,#zhq: SEO描述
'SITE_DESCRIPTION': setting.site_description,#zhq: 网站描述
'SITE_KEYWORDS': setting.site_keywords,#zhq: 网站关键词
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',#zhq: 网站基础URL
'ARTICLE_SUB_LENGTH': setting.article_sub_length,#zhq: 文章摘要长度
'nav_category_list': Category.objects.all(),#zhq: 导航分类列表
'nav_pages': Article.objects.filter(
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
type='p',#zhq: 页面类型
status='p'),#zhq: 发布状态
'OPEN_SITE_COMMENT': setting.open_site_comment,#zhq: 是否开启全站评论
'BEIAN_CODE': setting.beian_code,#zhq: ICP备案号
'ANALYTICS_CODE': setting.analytics_code,#zhq: 网站统计代码
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,#zhq: 公安备案号
"SHOW_GONGAN_CODE": setting.show_gongan_code,#zhq: 是否显示公安备案号
"CURRENT_YEAR": timezone.now().year,#zhq: 当前年份
"GLOBAL_HEADER": setting.global_header,#zhq: 全局头部HTML
"GLOBAL_FOOTER": setting.global_footer,#zhq: 全局尾部HTML
"COMMENT_NEED_REVIEW": setting.comment_need_review,#zhq: 评论是否需要审核
}
# zhq: 缓存上下文数据10小时提高性能
cache.set(key, value, 60 * 60 * 10)
return value

@ -5,15 +5,18 @@ from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
#zhq: 博客搜索表单类 - 继承自Haystack的SearchForm
class BlogSearchForm(SearchForm):
querydata = forms.CharField(required=True)
def search(self):
#zhq: 执行搜索操作调用父类的search方法
datas = super(BlogSearchForm, self).search()
# zhq: 如果表单验证失败,返回空结果
if not self.is_valid():
return self.no_query_found()
# zhq: 记录搜索关键词到日志
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas

@ -9,6 +9,7 @@ from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
logger = logging.getLogger(__name__)
#zhq: 在线中间件类 - 用于监控页面加载时间和用户访问信息
class OnlineMiddleware(object):
def __init__(self, get_response=None):
self.get_response = get_response
@ -16,16 +17,20 @@ class OnlineMiddleware(object):
def __call__(self, request):
''' page render time '''
# zhq: 记录请求开始时间
start_time = time.time()
response = self.get_response(request)
#zhq: 获取客户端IP地址和User-Agent信息
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
ip, _ = get_client_ip(request)
user_agent = parse(http_user_agent)
if not response.streaming:
try:
#zhq: 计算页面渲染时间
cast_time = time.time() - start_time
# zhq: 如果启用了Elasticsearch记录性能数据
if ELASTICSEARCH_ENABLED:
time_taken = round((cast_time) * 1000, 2)
time_taken = round((cast_time) * 1000, 2) #zhq: 转换为毫秒并保留2位小数
url = request.path
from django.utils import timezone
ElaspedTimeDocumentManager.create(
@ -34,8 +39,9 @@ class OnlineMiddleware(object):
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
# zhq: 在响应内容中替换加载时间占位符
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5])) #zhq: 保留前5位字符
except Exception as e:
logger.error("Error OnlineMiddleware: %s" % e)

@ -0,0 +1,45 @@
# 性能优化:为常用查询字段添加数据库索引
# Generated manually for performance optimization
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
# 为 Article 模型添加索引
migrations.AddIndex(
model_name='article',
index=models.Index(fields=['status', 'type'], name='article_status_type_idx'),
),
migrations.AddIndex(
model_name='article',
index=models.Index(fields=['status', 'pub_time'], name='article_status_pubtime_idx'),
),
migrations.AddIndex(
model_name='article',
index=models.Index(fields=['-pub_time'], name='article_pubtime_desc_idx'),
),
migrations.AddIndex(
model_name='article',
index=models.Index(fields=['article_order', '-pub_time'], name='article_order_pubtime_idx'),
),
# 为 Category 添加 slug 索引用于URL查找
migrations.AlterField(
model_name='category',
name='slug',
field=models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True),
),
# 为 Tag 添加 slug 索引用于URL查找
migrations.AlterField(
model_name='tag',
name='slug',
field=models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True),
),
]

@ -0,0 +1,55 @@
# 文章版本管理功能 - 数据库迁移
# Generated manually for article version management feature
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0002_add_performance_indexes'),
]
operations = [
migrations.CreateModel(
name='ArticleVersion',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version_number', models.PositiveIntegerField(default=1, verbose_name='version number')),
('title', models.CharField(max_length=200, verbose_name='title')),
('body', models.TextField(verbose_name='body')),
('pub_time', models.DateTimeField(verbose_name='publish time')),
('status', models.CharField(max_length=1, verbose_name='status')),
('comment_status', models.CharField(max_length=1, verbose_name='comment status')),
('type', models.CharField(max_length=1, verbose_name='type')),
('category_id', models.IntegerField(verbose_name='category id')),
('category_name', models.CharField(max_length=30, verbose_name='category name')),
('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')),
('change_summary', models.CharField(blank=True, default='', max_length=200, verbose_name='change summary')),
('is_auto_save', models.BooleanField(default=True, verbose_name='is auto save')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='blog.Article', verbose_name='article')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_versions_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')),
],
options={
'verbose_name': 'article version',
'verbose_name_plural': 'article versions',
'ordering': ['-version_number'],
},
),
migrations.AddIndex(
model_name='articleversion',
index=models.Index(fields=['article', '-version_number'], name='version_article_num_idx'),
),
migrations.AddIndex(
model_name='articleversion',
index=models.Index(fields=['creation_time'], name='version_creation_time_idx'),
),
migrations.AlterUniqueTogether(
name='articleversion',
unique_together={('article', 'version_number')},
),
]

@ -0,0 +1,54 @@
# 文章草稿功能 - 数据库迁移
# Generated manually for article draft auto-save feature
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0003_add_article_version'),
]
operations = [
migrations.CreateModel(
name='ArticleDraft',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, default='', max_length=200, verbose_name='title')),
('body', models.TextField(blank=True, default='', verbose_name='body')),
('category_id', models.IntegerField(blank=True, null=True, verbose_name='category id')),
('tags_data', models.JSONField(blank=True, default=list, verbose_name='tags data')),
('status', models.CharField(default='d', max_length=1, verbose_name='status')),
('comment_status', models.CharField(default='o', max_length=1, verbose_name='comment status')),
('type', models.CharField(default='a', max_length=1, verbose_name='type')),
('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')),
('last_update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='last update time')),
('session_id', models.CharField(blank=True, default='', max_length=64, verbose_name='session id')),
('is_published', models.BooleanField(default=False, verbose_name='is published')),
('article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='drafts', to='blog.Article', verbose_name='article')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_drafts', to=settings.AUTH_USER_MODEL, verbose_name='author')),
],
options={
'verbose_name': 'article draft',
'verbose_name_plural': 'article drafts',
'ordering': ['-last_update_time'],
},
),
migrations.AddIndex(
model_name='articledraft',
index=models.Index(fields=['author', '-last_update_time'], name='draft_author_time_idx'),
),
migrations.AddIndex(
model_name='articledraft',
index=models.Index(fields=['article', '-last_update_time'], name='draft_article_time_idx'),
),
migrations.AddIndex(
model_name='articledraft',
index=models.Index(fields=['is_published', '-last_update_time'], name='draft_published_time_idx'),
),
]

@ -0,0 +1,78 @@
# 用户关注和收藏功能 - 数据库迁移
# Generated manually for social features
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0004_add_article_draft'),
]
operations = [
# 创建用户关注模型
migrations.CreateModel(
name='UserFollow',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')),
('follower', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL, verbose_name='follower')),
('following', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL, verbose_name='following')),
],
options={
'verbose_name': 'user follow',
'verbose_name_plural': 'user follows',
'ordering': ['-creation_time'],
},
),
# 创建文章收藏模型
migrations.CreateModel(
name='ArticleFavorite',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')),
('note', models.CharField(blank=True, default='', max_length=200, verbose_name='note')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='blog.Article', verbose_name='article')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'article favorite',
'verbose_name_plural': 'article favorites',
'ordering': ['-creation_time'],
},
),
# 添加索引
migrations.AddIndex(
model_name='userfollow',
index=models.Index(fields=['follower', '-creation_time'], name='follow_follower_time_idx'),
),
migrations.AddIndex(
model_name='userfollow',
index=models.Index(fields=['following', '-creation_time'], name='follow_following_time_idx'),
),
migrations.AddIndex(
model_name='articlefavorite',
index=models.Index(fields=['user', '-creation_time'], name='favorite_user_time_idx'),
),
migrations.AddIndex(
model_name='articlefavorite',
index=models.Index(fields=['article', '-creation_time'], name='favorite_article_time_idx'),
),
# 添加唯一约束
migrations.AlterUniqueTogether(
name='userfollow',
unique_together={('follower', 'following')},
),
migrations.AlterUniqueTogether(
name='articlefavorite',
unique_together={('user', 'article')},
),
]

@ -0,0 +1,43 @@
# Generated migration for ArticleLike model
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_add_social_features'),
]
operations = [
migrations.CreateModel(
name='ArticleLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='creation time')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='liked_by', to='blog.article', verbose_name='article')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'article like',
'verbose_name_plural': 'article likes',
'ordering': ['-creation_time'],
},
),
migrations.AddIndex(
model_name='articlelike',
index=models.Index(fields=['user', '-creation_time'], name='like_user_time_idx'),
),
migrations.AddIndex(
model_name='articlelike',
index=models.Index(fields=['article', '-creation_time'], name='like_article_time_idx'),
),
migrations.AlterUniqueTogether(
name='articlelike',
unique_together={('user', 'article')},
),
]

@ -0,0 +1,101 @@
# Generated migration for Media Management System
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0006_add_article_like'),
]
operations = [
# 创建 MediaFile 模型
migrations.CreateModel(
name='MediaFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('original_filename', models.CharField(max_length=255, verbose_name='original filename')),
('stored_filename', models.CharField(max_length=255, unique=True, verbose_name='stored filename')),
('file_type', models.CharField(choices=[('image', 'Image'), ('file', 'File')], max_length=10, verbose_name='file type')),
('file_size', models.BigIntegerField(verbose_name='file size')),
('file_hash', models.CharField(db_index=True, max_length=32, verbose_name='file hash')),
('mime_type', models.CharField(max_length=100, verbose_name='MIME type')),
('file_path', models.CharField(max_length=500, verbose_name='file path')),
('upload_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='upload time')),
('width', models.IntegerField(blank=True, null=True, verbose_name='width')),
('height', models.IntegerField(blank=True, null=True, verbose_name='height')),
('thumbnail_path', models.CharField(blank=True, max_length=500, verbose_name='thumbnail path')),
('description', models.TextField(blank=True, verbose_name='description')),
('is_public', models.BooleanField(default=True, verbose_name='is public')),
('reference_count', models.IntegerField(default=0, verbose_name='reference count')),
('uploader', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL, verbose_name='uploader')),
],
options={
'verbose_name': 'media file',
'verbose_name_plural': 'media files',
'ordering': ['-upload_time'],
},
),
# 创建 MediaFolder 模型
migrations.CreateModel(
name='MediaFolder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='folder name')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created time')),
('description', models.TextField(blank=True, verbose_name='description')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_folders', to=settings.AUTH_USER_MODEL, verbose_name='owner')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='blog.mediafolder', verbose_name='parent folder')),
],
options={
'verbose_name': 'media folder',
'verbose_name_plural': 'media folders',
'ordering': ['name'],
},
),
# 创建 MediaFileFolder 模型
migrations.CreateModel(
name='MediaFileFolder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='added time')),
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_relations', to='blog.mediafile')),
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='file_relations', to='blog.mediafolder')),
],
options={
'verbose_name': 'media file folder relation',
'verbose_name_plural': 'media file folder relations',
},
),
# 添加索引
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['file_type', '-upload_time'], name='media_type_time_idx'),
),
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['uploader', '-upload_time'], name='media_uploader_time_idx'),
),
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['file_hash'], name='media_hash_idx'),
),
# 添加唯一约束
migrations.AlterUniqueTogether(
name='mediafolder',
unique_together={('name', 'parent', 'owner')},
),
migrations.AlterUniqueTogether(
name='mediafilefolder',
unique_together={('file', 'folder')},
),
]

@ -0,0 +1,14 @@
# Generated by Django 5.2.7 on 2025-11-24 02:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
('blog', '0007_add_media_management'),
]
operations = [
]

@ -0,0 +1,359 @@
# Generated by Django 5.2.7 on 2025-11-25 13:02
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_merge_20251124_0221'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='articledraft',
options={'ordering': ['-last_update_time'], 'verbose_name': '文章草稿', 'verbose_name_plural': '文章草稿'},
),
migrations.AlterModelOptions(
name='articlefavorite',
options={'ordering': ['-creation_time'], 'verbose_name': '文章收藏', 'verbose_name_plural': '文章收藏'},
),
migrations.AlterModelOptions(
name='articlelike',
options={'ordering': ['-creation_time'], 'verbose_name': '文章点赞', 'verbose_name_plural': '文章点赞'},
),
migrations.AlterModelOptions(
name='articleversion',
options={'ordering': ['-version_number'], 'verbose_name': '文章版本', 'verbose_name_plural': '文章版本'},
),
migrations.AlterModelOptions(
name='mediafile',
options={'ordering': ['-upload_time'], 'verbose_name': '媒体文件', 'verbose_name_plural': '媒体文件'},
),
migrations.AlterModelOptions(
name='mediafilefolder',
options={'verbose_name': '文件-文件夹关联', 'verbose_name_plural': '文件-文件夹关联'},
),
migrations.AlterModelOptions(
name='mediafolder',
options={'ordering': ['name'], 'verbose_name': '媒体文件夹', 'verbose_name_plural': '媒体文件夹'},
),
migrations.AlterModelOptions(
name='userfollow',
options={'ordering': ['-creation_time'], 'verbose_name': '用户关注', 'verbose_name_plural': '用户关注'},
),
migrations.AlterField(
model_name='articledraft',
name='article',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='drafts', to='blog.article', verbose_name='文章'),
),
migrations.AlterField(
model_name='articledraft',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_drafts', to=settings.AUTH_USER_MODEL, verbose_name='作者'),
),
migrations.AlterField(
model_name='articledraft',
name='body',
field=models.TextField(blank=True, default='', verbose_name='正文'),
),
migrations.AlterField(
model_name='articledraft',
name='category_id',
field=models.IntegerField(blank=True, null=True, verbose_name='分类ID'),
),
migrations.AlterField(
model_name='articledraft',
name='comment_status',
field=models.CharField(default='o', max_length=1, verbose_name='评论状态'),
),
migrations.AlterField(
model_name='articledraft',
name='creation_time',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='articledraft',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='articledraft',
name='is_published',
field=models.BooleanField(default=False, verbose_name='已发布'),
),
migrations.AlterField(
model_name='articledraft',
name='last_update_time',
field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='最后更新时间'),
),
migrations.AlterField(
model_name='articledraft',
name='session_id',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='会话ID'),
),
migrations.AlterField(
model_name='articledraft',
name='status',
field=models.CharField(default='d', max_length=1, verbose_name='状态'),
),
migrations.AlterField(
model_name='articledraft',
name='tags_data',
field=models.JSONField(blank=True, default=list, verbose_name='标签数据'),
),
migrations.AlterField(
model_name='articledraft',
name='title',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='标题'),
),
migrations.AlterField(
model_name='articledraft',
name='type',
field=models.CharField(default='a', max_length=1, verbose_name='类型'),
),
migrations.AlterField(
model_name='articlefavorite',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='blog.article', verbose_name='文章'),
),
migrations.AlterField(
model_name='articlefavorite',
name='creation_time',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='articlefavorite',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='articlefavorite',
name='note',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='备注'),
),
migrations.AlterField(
model_name='articlefavorite',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
migrations.AlterField(
model_name='articlelike',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='liked_by', to='blog.article', verbose_name='文章'),
),
migrations.AlterField(
model_name='articlelike',
name='creation_time',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='articlelike',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
migrations.AlterField(
model_name='articleversion',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='blog.article', verbose_name='文章'),
),
migrations.AlterField(
model_name='articleversion',
name='body',
field=models.TextField(verbose_name='正文'),
),
migrations.AlterField(
model_name='articleversion',
name='category_id',
field=models.IntegerField(verbose_name='分类ID'),
),
migrations.AlterField(
model_name='articleversion',
name='category_name',
field=models.CharField(max_length=30, verbose_name='分类名称'),
),
migrations.AlterField(
model_name='articleversion',
name='change_summary',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='变更说明'),
),
migrations.AlterField(
model_name='articleversion',
name='comment_status',
field=models.CharField(max_length=1, verbose_name='评论状态'),
),
migrations.AlterField(
model_name='articleversion',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_versions_created', to=settings.AUTH_USER_MODEL, verbose_name='创建者'),
),
migrations.AlterField(
model_name='articleversion',
name='creation_time',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='articleversion',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='articleversion',
name='is_auto_save',
field=models.BooleanField(default=True, verbose_name='自动保存'),
),
migrations.AlterField(
model_name='articleversion',
name='pub_time',
field=models.DateTimeField(verbose_name='发布时间'),
),
migrations.AlterField(
model_name='articleversion',
name='status',
field=models.CharField(max_length=1, verbose_name='状态'),
),
migrations.AlterField(
model_name='articleversion',
name='title',
field=models.CharField(max_length=200, verbose_name='标题'),
),
migrations.AlterField(
model_name='articleversion',
name='type',
field=models.CharField(max_length=1, verbose_name='类型'),
),
migrations.AlterField(
model_name='articleversion',
name='version_number',
field=models.PositiveIntegerField(default=1, verbose_name='版本号'),
),
migrations.AlterField(
model_name='mediafile',
name='description',
field=models.TextField(blank=True, verbose_name='描述'),
),
migrations.AlterField(
model_name='mediafile',
name='file_hash',
field=models.CharField(db_index=True, max_length=32, verbose_name='文件哈希'),
),
migrations.AlterField(
model_name='mediafile',
name='file_path',
field=models.CharField(max_length=500, verbose_name='文件路径'),
),
migrations.AlterField(
model_name='mediafile',
name='file_size',
field=models.BigIntegerField(verbose_name='文件大小'),
),
migrations.AlterField(
model_name='mediafile',
name='file_type',
field=models.CharField(choices=[('image', '图片'), ('file', '文件')], max_length=10, verbose_name='文件类型'),
),
migrations.AlterField(
model_name='mediafile',
name='height',
field=models.IntegerField(blank=True, null=True, verbose_name='高度'),
),
migrations.AlterField(
model_name='mediafile',
name='is_public',
field=models.BooleanField(default=True, verbose_name='是否公开'),
),
migrations.AlterField(
model_name='mediafile',
name='mime_type',
field=models.CharField(max_length=100, verbose_name='MIME类型'),
),
migrations.AlterField(
model_name='mediafile',
name='original_filename',
field=models.CharField(max_length=255, verbose_name='原始文件名'),
),
migrations.AlterField(
model_name='mediafile',
name='reference_count',
field=models.IntegerField(default=0, verbose_name='引用次数'),
),
migrations.AlterField(
model_name='mediafile',
name='stored_filename',
field=models.CharField(max_length=255, unique=True, verbose_name='存储文件名'),
),
migrations.AlterField(
model_name='mediafile',
name='thumbnail_path',
field=models.CharField(blank=True, max_length=500, verbose_name='缩略图路径'),
),
migrations.AlterField(
model_name='mediafile',
name='upload_time',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='上传时间'),
),
migrations.AlterField(
model_name='mediafile',
name='uploader',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL, verbose_name='上传者'),
),
migrations.AlterField(
model_name='mediafile',
name='width',
field=models.IntegerField(blank=True, null=True, verbose_name='宽度'),
),
migrations.AlterField(
model_name='mediafilefolder',
name='added_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='添加时间'),
),
migrations.AlterField(
model_name='mediafolder',
name='created_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='mediafolder',
name='description',
field=models.TextField(blank=True, verbose_name='描述'),
),
migrations.AlterField(
model_name='mediafolder',
name='name',
field=models.CharField(max_length=100, verbose_name='文件夹名称'),
),
migrations.AlterField(
model_name='mediafolder',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_folders', to=settings.AUTH_USER_MODEL, verbose_name='所有者'),
),
migrations.AlterField(
model_name='mediafolder',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='blog.mediafolder', verbose_name='父文件夹'),
),
migrations.AlterField(
model_name='userfollow',
name='creation_time',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='userfollow',
name='follower',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL, verbose_name='关注者'),
),
migrations.AlterField(
model_name='userfollow',
name='following',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL, verbose_name='被关注者'),
),
migrations.AlterField(
model_name='userfollow',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

@ -16,27 +16,28 @@ from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
#zhq: 链接显示类型选择类 - 定义链接在不同页面的显示方式
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
I = ('i', _('index'))#zhq: 首页显示
L = ('l', _('list'))#zhq: 列表页显示
P = ('p', _('post'))#zhq: 文章页显示
A = ('a', _('all'))#zhq: 所有页面显示
S = ('s', _('slide'))#zhq: 幻灯片显示
#zhq: 基础模型抽象类 - 所有模型的基类,包含公共字段和方法
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
def save(self, *args, **kwargs):#zhq: 特殊处理文章浏览量的更新,避免触发其他字段的保存逻辑
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:
# zhq: 自动生成slug字段用于SEO友好的URL
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
@ -45,6 +46,7 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
def get_full_url(self):
# zhq: 获取对象的完整URL地址
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
@ -55,9 +57,10 @@ class BaseModel(models.Model):
@abstractmethod
def get_absolute_url(self):
# zhq: 抽象方法子类必须实现获取绝对URL的方法
pass
#zhq: 文章模型 - 博客的核心内容模型
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
@ -112,10 +115,17 @@ class Article(BaseModel):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
ordering = ['-article_order', '-pub_time']#zhq: 按排序和发布时间降序排列
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
# 性能优化:添加组合索引,提升常用查询性能
indexes = [
models.Index(fields=['status', 'type'], name='article_status_type_idx'),
models.Index(fields=['status', 'pub_time'], name='article_status_pubtime_idx'),
models.Index(fields=['-pub_time'], name='article_pubtime_desc_idx'),
models.Index(fields=['article_order', '-pub_time'], name='article_order_pubtime_idx'),
]
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
@ -127,44 +137,77 @@ class Article(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
# zhq: 获取文章所属分类的完整树形结构
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):
# 判断是否为新创建的文章
is_new = self.pk is None
# 检查是否需要创建版本(不是只更新浏览量)
should_create_version = not ('update_fields' in kwargs and
kwargs.get('update_fields') == ['views'])
super().save(*args, **kwargs)
# 如果需要创建版本且不是新文章,则创建版本记录
if should_create_version and not is_new:
try:
from blog.models_version import ArticleVersion
# 创建版本记录
ArticleVersion.create_version(
article=self,
user=self.author,
change_summary=kwargs.get('change_summary', ''),
is_auto_save=True
)
except Exception as e:
logger.error(f"Failed to create article version: {e}")
def viewed(self):
# zhq: 增加文章浏览量
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
# zhq: 获取文章的评论列表,带缓存功能
# 性能优化:预加载评论的作者和父评论,避免 N+1 查询
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
comments = self.comment_set.filter(is_enable=True) \
.select_related('author', 'parent_comment') \
.order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
# zhq: 获取文章在Admin后台的编辑链接
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
# 性能优化:预加载关联对象
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
id__gt=self.id, status='p') \
.select_related('author', 'category') \
.order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
# 性能优化:预加载关联对象
return Article.objects.filter(id__lt=self.id, status='p') \
.select_related('author', 'category') \
.first()
def get_first_image_url(self):
"""
@ -176,7 +219,7 @@ class Article(BaseModel):
return match.group(1)
return ""
#zhq: 分类模型 - 支持多级分类结构
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
@ -185,8 +228,8 @@ class Category(BaseModel):
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
on_delete=models.CASCADE) #zhq: 自关联,支持多级分类
slug = models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
@ -239,11 +282,11 @@ class Category(BaseModel):
parse(self)
return categorys
#zhq: 标签模型 - 简单的标签管理
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True, db_index=True)
def __str__(self):
return self.name
@ -253,6 +296,7 @@ class Tag(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
# zhq: 获取该标签下的文章数量
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
@ -260,32 +304,32 @@ class Tag(BaseModel):
verbose_name = _('tag')
verbose_name_plural = verbose_name
#zhq: 友情链接模型
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
sequence = models.IntegerField(_('order'), unique=True)
sequence = models.IntegerField(_('order'), unique=True) #zhq: 链接显示顺序
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
default=LinkShowType.I) #zhq: 链接显示类型
creation_time = models.DateTimeField(_('creation time'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
ordering = ['sequence'] #zhq: 按顺序排列
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
#zhq: 侧边栏模型 - 支持自定义HTML内容
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
@ -303,7 +347,7 @@ class SideBar(models.Model):
def __str__(self):
return self.name
#zhq: 博客设置模型 - 单例模式,存储全局配置
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
@ -326,11 +370,11 @@ class BlogSettings(models.Model):
null=False,
blank=False,
default='')
article_sub_length = models.IntegerField(_('article sub length'), default=300)
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
article_sub_length = models.IntegerField(_('article sub length'), default=300) #zhq: 文章摘要长度
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) #zhq: 侧边栏文章数量
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) #zhq: 侧边栏评论数量
article_comment_count = models.IntegerField(_('article comment count'), default=5) #zhq: 文章页评论数量
show_google_adsense = models.BooleanField(_('show adsense'), default=False) #zhq:是否显示Google广告
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True)
@ -341,13 +385,13 @@ class BlogSettings(models.Model):
max_length=2000,
null=True,
blank=True,
default='')
default='') #zhq: ICP备案号
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
default='') #zhq: 网站统计代码
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
gongan_beiancode = models.TextField(
@ -357,7 +401,7 @@ class BlogSettings(models.Model):
blank=True,
default='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
'评论是否需要审核', default=False, null=False) #zhq: 评论审核开关
class Meta:
verbose_name = _('Website configuration')
@ -367,6 +411,7 @@ class BlogSettings(models.Model):
return self.site_name
def clean(self):
# zhq: 确保配置表只有一条记录(单例模式)
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
@ -374,3 +419,6 @@ class BlogSettings(models.Model):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()
# 导入多媒体管理模型
from blog.models_media import MediaFile, MediaFolder, MediaFileFolder

@ -0,0 +1,242 @@
# 文章草稿自动保存模型
# 用于在编辑文章时自动保存草稿,防止内容丢失
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from blog.models import Article, Category
class ArticleDraft(models.Model):
"""
文章草稿模型
用于自动保存编辑中的文章防止内容丢失
"""
# 关联的文章(可为空,表示新建文章的草稿)
article = models.ForeignKey(
Article,
verbose_name='文章',
on_delete=models.CASCADE,
related_name='drafts',
null=True,
blank=True
)
# 草稿创建者
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='作者',
on_delete=models.CASCADE,
related_name='article_drafts'
)
# 草稿标题
title = models.CharField('标题', max_length=200, blank=True, default='')
# 草稿正文
body = models.TextField('正文', blank=True, default='')
# 分类ID保存时的分类
category_id = models.IntegerField('分类ID', null=True, blank=True)
# 标签JSON 格式保存标签ID列表
tags_data = models.JSONField('标签数据', default=list, blank=True)
# 状态
status = models.CharField('状态', max_length=1, default='d')
# 评论状态
comment_status = models.CharField('评论状态', max_length=1, default='o')
# 类型
type = models.CharField('类型', max_length=1, default='a')
# 创建时间
creation_time = models.DateTimeField('创建时间', default=now, db_index=True)
# 最后更新时间
last_update_time = models.DateTimeField('最后更新时间', auto_now=True, db_index=True)
# 草稿会话ID用于区分不同的编辑会话
session_id = models.CharField('会话ID', max_length=64, blank=True, default='')
# 是否已发布(草稿应用到文章后标记为已发布)
is_published = models.BooleanField('已发布', default=False)
class Meta:
ordering = ['-last_update_time']
verbose_name = '文章草稿'
verbose_name_plural = '文章草稿'
indexes = [
models.Index(fields=['author', '-last_update_time'], name='draft_author_time_idx'),
models.Index(fields=['article', '-last_update_time'], name='draft_article_time_idx'),
models.Index(fields=['is_published', '-last_update_time'], name='draft_published_time_idx'),
]
def __str__(self):
title = self.title or '(无标题)'
return f"{title} - {self.author.username} - {self.last_update_time.strftime('%Y-%m-%d %H:%M')}"
@classmethod
def save_draft(cls, user, title='', body='', article_id=None, category_id=None,
tags_data=None, status='d', comment_status='o', type='a', session_id=''):
"""
保存或更新草稿
Args:
user: 用户对象
title: 文章标题
body: 文章正文
article_id: 文章ID如果是编辑现有文章
category_id: 分类ID
tags_data: 标签数据列表
status: 文章状态
comment_status: 评论状态
type: 文章类型
session_id: 会话ID
Returns:
ArticleDraft 实例
"""
if tags_data is None:
tags_data = []
# 查找是否存在相同会话的草稿
if session_id:
draft = cls.objects.filter(
author=user,
session_id=session_id,
is_published=False
).first()
elif article_id:
# 如果是编辑现有文章,查找该文章的最新未发布草稿
draft = cls.objects.filter(
author=user,
article_id=article_id,
is_published=False
).first()
else:
# 新建文章,查找最新的未关联文章的草稿
draft = cls.objects.filter(
author=user,
article__isnull=True,
is_published=False
).first()
# 更新或创建草稿
if draft:
draft.title = title
draft.body = body
draft.category_id = category_id
draft.tags_data = tags_data
draft.status = status
draft.comment_status = comment_status
draft.type = type
if session_id:
draft.session_id = session_id
draft.save()
else:
article = None
if article_id:
try:
article = Article.objects.get(id=article_id)
except Article.DoesNotExist:
pass
draft = cls.objects.create(
author=user,
article=article,
title=title,
body=body,
category_id=category_id,
tags_data=tags_data,
status=status,
comment_status=comment_status,
type=type,
session_id=session_id
)
return draft
def apply_to_article(self, article=None):
"""
将草稿应用到文章
Args:
article: Article 实例如果为空则使用关联的文章
Returns:
Article 实例
"""
if article is None:
article = self.article
if article is None:
# 创建新文章
from blog.models import Category
category = None
if self.category_id:
try:
category = Category.objects.get(id=self.category_id)
except Category.DoesNotExist:
# 如果分类不存在,使用第一个分类
category = Category.objects.first()
if category is None:
raise ValueError("必须指定文章分类")
article = Article.objects.create(
title=self.title,
body=self.body,
author=self.author,
category=category,
status=self.status,
comment_status=self.comment_status,
type=self.type
)
else:
# 更新现有文章
article.title = self.title
article.body = self.body
if self.category_id:
try:
from blog.models import Category
category = Category.objects.get(id=self.category_id)
article.category = category
except Category.DoesNotExist:
pass
article.status = self.status
article.comment_status = self.comment_status
article.type = self.type
article.save()
# 应用标签
if self.tags_data:
from blog.models import Tag
tags = Tag.objects.filter(id__in=self.tags_data)
article.tags.set(tags)
# 标记草稿为已发布
self.is_published = True
self.article = article
self.save()
return article
def get_preview_text(self, length=100):
"""
获取草稿预览文本
Args:
length: 预览文本长度
Returns:
str: 预览文本
"""
if self.body:
if len(self.body) > length:
return self.body[:length] + '...'
return self.body
return '(空草稿)'

@ -0,0 +1,268 @@
# 多媒体管理模型
# 提供图片、文件的上传、管理和优化功能
import os
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from PIL import Image
import hashlib
class MediaFile(models.Model):
"""
媒体文件模型
存储所有上传的图片和文件信息
"""
FILE_TYPE_CHOICES = [
('image', '图片'),
('file', '文件'),
]
# 文件名(原始文件名)
original_filename = models.CharField('原始文件名', max_length=255)
# 存储文件名(实际存储的唯一文件名)
stored_filename = models.CharField('存储文件名', max_length=255, unique=True)
# 文件类型(图片或普通文件)
file_type = models.CharField('文件类型', max_length=10, choices=FILE_TYPE_CHOICES)
# 文件大小(字节)
file_size = models.BigIntegerField('文件大小')
# 文件MD5哈希用于去重
file_hash = models.CharField('文件哈希', max_length=32, db_index=True)
# MIME类型
mime_type = models.CharField('MIME类型', max_length=100)
# 文件路径(相对路径)
file_path = models.CharField('文件路径', max_length=500)
# 上传用户
uploader = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='上传者',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='uploaded_files'
)
# 上传时间
upload_time = models.DateTimeField('上传时间', default=now, db_index=True)
# 图片专属字段
# 图片宽度
width = models.IntegerField('宽度', null=True, blank=True)
# 图片高度
height = models.IntegerField('高度', null=True, blank=True)
# 缩略图路径
thumbnail_path = models.CharField('缩略图路径', max_length=500, blank=True)
# 描述/备注
description = models.TextField('描述', blank=True)
# 是否公开(私有文件只有上传者可访问)
is_public = models.BooleanField('是否公开', default=True)
# 引用计数(被多少篇文章引用)
reference_count = models.IntegerField('引用次数', default=0)
class Meta:
ordering = ['-upload_time']
verbose_name = '媒体文件'
verbose_name_plural = '媒体文件'
indexes = [
models.Index(fields=['file_type', '-upload_time'], name='media_type_time_idx'),
models.Index(fields=['uploader', '-upload_time'], name='media_uploader_time_idx'),
models.Index(fields=['file_hash'], name='media_hash_idx'),
]
def __str__(self):
return self.original_filename
def get_absolute_url(self):
"""获取文件的访问URL"""
from django.templatetags.static import static
return static(self.file_path)
def get_thumbnail_url(self):
"""获取缩略图URL"""
if self.thumbnail_path:
from django.templatetags.static import static
return static(self.thumbnail_path)
return self.get_absolute_url()
def get_file_extension(self):
"""获取文件扩展名"""
return os.path.splitext(self.original_filename)[1].lower()
def is_image(self):
"""判断是否为图片"""
return self.file_type == 'image'
def delete(self, *args, **kwargs):
"""删除文件时同时删除物理文件"""
# 删除主文件
full_path = os.path.join(settings.STATICFILES, self.file_path)
if os.path.exists(full_path):
try:
os.remove(full_path)
except Exception as e:
import logging
logging.error(f"删除文件失败 {full_path}: {e}")
# 删除缩略图
if self.thumbnail_path:
thumb_path = os.path.join(settings.STATICFILES, self.thumbnail_path)
if os.path.exists(thumb_path):
try:
os.remove(thumb_path)
except Exception as e:
import logging
logging.error(f"删除缩略图失败 {thumb_path}: {e}")
super().delete(*args, **kwargs)
@classmethod
def get_file_hash(cls, file_content):
"""计算文件MD5哈希"""
md5 = hashlib.md5()
for chunk in file_content.chunks():
md5.update(chunk)
return md5.hexdigest()
@classmethod
def check_duplicate(cls, file_hash):
"""检查是否已存在相同文件"""
return cls.objects.filter(file_hash=file_hash).first()
def generate_thumbnail(self, max_size=(300, 300)):
"""生成缩略图"""
if not self.is_image():
return False
try:
full_path = os.path.join(settings.STATICFILES, self.file_path)
if not os.path.exists(full_path):
return False
# 打开图片
img = Image.open(full_path)
# 转换RGBA为RGB处理PNG透明背景
if img.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
# 生成缩略图
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# 保存缩略图
thumb_filename = f"thumb_{self.stored_filename}"
thumb_dir = os.path.dirname(full_path)
thumb_path = os.path.join(thumb_dir, thumb_filename)
img.save(thumb_path, quality=85, optimize=True)
# 更新缩略图路径
self.thumbnail_path = os.path.join(
os.path.dirname(self.file_path),
thumb_filename
)
self.save(update_fields=['thumbnail_path'])
return True
except Exception as e:
import logging
logging.error(f"生成缩略图失败: {e}", exc_info=True)
return False
class MediaFolder(models.Model):
"""
媒体文件夹模型
用于组织和分类媒体文件
"""
# 文件夹名称
name = models.CharField('文件夹名称', max_length=100)
# 父文件夹(支持嵌套)
parent = models.ForeignKey(
'self',
verbose_name='父文件夹',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='subfolders'
)
# 所有者
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='所有者',
on_delete=models.CASCADE,
related_name='media_folders'
)
# 创建时间
created_time = models.DateTimeField('创建时间', default=now)
# 描述
description = models.TextField('描述', blank=True)
class Meta:
ordering = ['name']
verbose_name = '媒体文件夹'
verbose_name_plural = '媒体文件夹'
unique_together = [['name', 'parent', 'owner']]
def __str__(self):
return self.get_full_path()
def get_full_path(self):
"""获取完整路径"""
if self.parent:
return f"{self.parent.get_full_path()}/{self.name}"
return self.name
def get_files_count(self):
"""获取文件夹中的文件数量"""
return self.files.count()
class MediaFileFolder(models.Model):
"""
媒体文件和文件夹的关联表
支持一个文件属于多个文件夹
"""
file = models.ForeignKey(
MediaFile,
on_delete=models.CASCADE,
related_name='folder_relations'
)
folder = models.ForeignKey(
MediaFolder,
on_delete=models.CASCADE,
related_name='file_relations'
)
added_time = models.DateTimeField('添加时间', default=now)
class Meta:
unique_together = [['file', 'folder']]
verbose_name = '文件-文件夹关联'
verbose_name_plural = '文件-文件夹关联'
def __str__(self):
return f"{self.file.original_filename} -> {self.folder.name}"

@ -0,0 +1,401 @@
# 用户关注和收藏功能模型
# 实现用户间的关注关系和文章收藏功能
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from blog.models import Article
class UserFollow(models.Model):
"""
用户关注模型
记录用户之间的关注关系
"""
# 关注者(谁关注了别人)
follower = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='关注者',
on_delete=models.CASCADE,
related_name='following'
)
# 被关注者(被谁关注)
following = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='被关注者',
on_delete=models.CASCADE,
related_name='followers'
)
# 关注时间
creation_time = models.DateTimeField('创建时间', default=now, db_index=True)
class Meta:
ordering = ['-creation_time']
verbose_name = '用户关注'
verbose_name_plural = '用户关注'
unique_together = [['follower', 'following']]
indexes = [
models.Index(fields=['follower', '-creation_time'], name='follow_follower_time_idx'),
models.Index(fields=['following', '-creation_time'], name='follow_following_time_idx'),
]
def __str__(self):
return f"{self.follower.username} -> {self.following.username}"
def clean(self):
"""验证:不能关注自己"""
from django.core.exceptions import ValidationError
if self.follower == self.following:
raise ValidationError(_('Cannot follow yourself'))
@classmethod
def is_following(cls, follower, following):
"""
检查是否已关注
Args:
follower: 关注者用户对象
following: 被关注者用户对象
Returns:
bool: 是否已关注
"""
return cls.objects.filter(follower=follower, following=following).exists()
@classmethod
def follow(cls, follower, following):
"""
关注用户
Args:
follower: 关注者用户对象
following: 被关注者用户对象
Returns:
UserFollow 实例或 None
"""
if follower == following:
return None
follow, created = cls.objects.get_or_create(
follower=follower,
following=following
)
return follow if created else None
@classmethod
def unfollow(cls, follower, following):
"""
取消关注
Args:
follower: 关注者用户对象
following: 被关注者用户对象
Returns:
bool: 是否成功取消关注
"""
deleted_count, _ = cls.objects.filter(
follower=follower,
following=following
).delete()
return deleted_count > 0
@classmethod
def get_following_list(cls, user, limit=None):
"""
获取用户关注的人列表
Args:
user: 用户对象
limit: 返回数量限制
Returns:
QuerySet: 关注的用户列表
"""
queryset = cls.objects.filter(follower=user).select_related('following')
if limit:
queryset = queryset[:limit]
return [f.following for f in queryset]
@classmethod
def get_followers_list(cls, user, limit=None):
"""
获取用户的粉丝列表
Args:
user: 用户对象
limit: 返回数量限制
Returns:
QuerySet: 粉丝用户列表
"""
queryset = cls.objects.filter(following=user).select_related('follower')
if limit:
queryset = queryset[:limit]
return [f.follower for f in queryset]
@classmethod
def get_following_count(cls, user):
"""获取关注数量"""
return cls.objects.filter(follower=user).count()
@classmethod
def get_followers_count(cls, user):
"""获取粉丝数量"""
return cls.objects.filter(following=user).count()
class ArticleFavorite(models.Model):
"""
文章收藏模型
记录用户收藏的文章
"""
# 用户
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='用户',
on_delete=models.CASCADE,
related_name='favorites'
)
# 文章
article = models.ForeignKey(
Article,
verbose_name='文章',
on_delete=models.CASCADE,
related_name='favorited_by'
)
# 收藏时间
creation_time = models.DateTimeField('创建时间', default=now, db_index=True)
# 收藏备注(可选)
note = models.CharField('备注', max_length=200, blank=True, default='')
class Meta:
ordering = ['-creation_time']
verbose_name = '文章收藏'
verbose_name_plural = '文章收藏'
unique_together = [['user', 'article']]
indexes = [
models.Index(fields=['user', '-creation_time'], name='favorite_user_time_idx'),
models.Index(fields=['article', '-creation_time'], name='favorite_article_time_idx'),
]
def __str__(self):
return f"{self.user.username} -> {self.article.title}"
@classmethod
def is_favorited(cls, user, article):
"""
检查是否已收藏
Args:
user: 用户对象
article: 文章对象
Returns:
bool: 是否已收藏
"""
return cls.objects.filter(user=user, article=article).exists()
@classmethod
def add_favorite(cls, user, article, note=''):
"""
收藏文章
Args:
user: 用户对象
article: 文章对象
note: 收藏备注
Returns:
ArticleFavorite 实例或 None
"""
favorite, created = cls.objects.get_or_create(
user=user,
article=article,
defaults={'note': note}
)
return favorite if created else None
@classmethod
def remove_favorite(cls, user, article):
"""
取消收藏
Args:
user: 用户对象
article: 文章对象
Returns:
bool: 是否成功取消收藏
"""
deleted_count, _ = cls.objects.filter(
user=user,
article=article
).delete()
return deleted_count > 0
@classmethod
def get_user_favorites(cls, user, limit=None):
"""
获取用户的收藏列表
Args:
user: 用户对象
limit: 返回数量限制
Returns:
QuerySet: 收藏的文章列表
"""
queryset = cls.objects.filter(user=user).select_related('article', 'article__author', 'article__category')
if limit:
queryset = queryset[:limit]
return queryset
@classmethod
def get_favorite_count(cls, user):
"""获取用户收藏数量"""
return cls.objects.filter(user=user).count()
@classmethod
def get_article_favorite_count(cls, article):
"""获取文章被收藏次数"""
return cls.objects.filter(article=article).count()
class ArticleLike(models.Model):
"""
文章点赞模型
记录用户对文章的点赞
"""
# 用户
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='用户',
on_delete=models.CASCADE,
related_name='likes'
)
# 文章
article = models.ForeignKey(
Article,
verbose_name='文章',
on_delete=models.CASCADE,
related_name='liked_by'
)
# 点赞时间
creation_time = models.DateTimeField('创建时间', default=now, db_index=True)
class Meta:
ordering = ['-creation_time']
verbose_name = '文章点赞'
verbose_name_plural = '文章点赞'
unique_together = [['user', 'article']]
indexes = [
models.Index(fields=['user', '-creation_time'], name='like_user_time_idx'),
models.Index(fields=['article', '-creation_time'], name='like_article_time_idx'),
]
def __str__(self):
return f"{self.user.username} 👍 {self.article.title}"
@classmethod
def is_liked(cls, user, article):
"""
检查是否已点赞
Args:
user: 用户对象
article: 文章对象
Returns:
bool: 是否已点赞
"""
return cls.objects.filter(user=user, article=article).exists()
@classmethod
def add_like(cls, user, article):
"""
点赞文章
Args:
user: 用户对象
article: 文章对象
Returns:
ArticleLike 实例或 None
"""
like, created = cls.objects.get_or_create(
user=user,
article=article
)
return like if created else None
@classmethod
def remove_like(cls, user, article):
"""
取消点赞
Args:
user: 用户对象
article: 文章对象
Returns:
bool: 是否成功取消点赞
"""
deleted_count, _ = cls.objects.filter(
user=user,
article=article
).delete()
return deleted_count > 0
@classmethod
def get_article_like_count(cls, article):
"""
获取文章点赞数
Args:
article: 文章对象
Returns:
int: 点赞数
"""
return cls.objects.filter(article=article).count()
@classmethod
def get_user_like_count(cls, user):
"""
获取用户点赞数
Args:
user: 用户对象
Returns:
int: 用户点赞的文章数量
"""
return cls.objects.filter(user=user).count()
@classmethod
def get_user_likes(cls, user, limit=None):
"""
获取用户点赞的文章列表
Args:
user: 用户对象
limit: 返回数量限制
Returns:
QuerySet: 点赞的文章列表
"""
queryset = cls.objects.filter(user=user).select_related('article', 'article__author', 'article__category')
if limit:
queryset = queryset[:limit]
return queryset

@ -0,0 +1,170 @@
# 文章版本管理模型
# 用于追踪文章的修改历史,支持版本对比和回滚
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from blog.models import Article
class ArticleVersion(models.Model):
"""
文章版本模型
每次文章保存时自动创建版本记录
"""
# 关联的文章
article = models.ForeignKey(
Article,
verbose_name='文章',
on_delete=models.CASCADE,
related_name='versions'
)
# 版本号(自动递增)
version_number = models.PositiveIntegerField('版本号', default=1)
# 文章标题(保存时的标题)
title = models.CharField('标题', max_length=200)
# 文章正文(保存时的内容)
body = models.TextField('正文')
# 发布时间
pub_time = models.DateTimeField('发布时间')
# 状态
status = models.CharField('状态', max_length=1)
# 评论状态
comment_status = models.CharField('评论状态', max_length=1)
# 类型
type = models.CharField('类型', max_length=1)
# 分类保存时的分类ID
category_id = models.IntegerField('分类ID')
category_name = models.CharField('分类名称', max_length=30)
# 创建版本的用户
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name='创建者',
on_delete=models.SET_NULL,
null=True,
related_name='article_versions_created'
)
# 版本创建时间
creation_time = models.DateTimeField('创建时间', default=now, db_index=True)
# 变更说明(可选)
change_summary = models.CharField(
'变更说明',
max_length=200,
blank=True,
default=''
)
# 是否为自动保存
is_auto_save = models.BooleanField('自动保存', default=True)
class Meta:
ordering = ['-version_number']
verbose_name = '文章版本'
verbose_name_plural = '文章版本'
unique_together = [['article', 'version_number']]
indexes = [
models.Index(fields=['article', '-version_number'], name='version_article_num_idx'),
models.Index(fields=['creation_time'], name='version_creation_time_idx'),
]
def __str__(self):
return f"{self.article.title} - v{self.version_number}"
@classmethod
def create_version(cls, article, user=None, change_summary='', is_auto_save=True):
"""
创建文章版本
Args:
article: Article 实例
user: 创建版本的用户
change_summary: 变更说明
is_auto_save: 是否为自动保存
Returns:
ArticleVersion 实例
"""
# 获取最新版本号
latest_version = cls.objects.filter(article=article).first()
version_number = (latest_version.version_number + 1) if latest_version else 1
# 创建版本记录
version = cls.objects.create(
article=article,
version_number=version_number,
title=article.title,
body=article.body,
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
category_id=article.category_id,
category_name=article.category.name,
created_by=user or article.author,
change_summary=change_summary,
is_auto_save=is_auto_save
)
return version
def restore_to_article(self):
"""
将此版本恢复到文章
Returns:
bool: 是否成功恢复
"""
try:
article = self.article
article.title = self.title
article.body = self.body
article.pub_time = self.pub_time
article.status = self.status
article.comment_status = self.comment_status
article.type = self.type
# 尝试恢复分类如果分类ID仍存在
from blog.models import Category
try:
category = Category.objects.get(id=self.category_id)
article.category = category
except Category.DoesNotExist:
# 如果原分类已删除,保持当前分类不变
pass
article.save()
return True
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to restore version {self.id}: {e}")
return False
def get_diff_with_current(self):
"""
获取与当前文章的差异
Returns:
dict: 包含差异信息的字典
"""
article = self.article
diff = {
'title_changed': self.title != article.title,
'body_changed': self.body != article.body,
'status_changed': self.status != article.status,
'category_changed': self.category_id != article.category_id,
}
return diff

@ -0,0 +1,316 @@
# API 速率限制
# 提供灵活的API请求频率限制功能
# 支持按IP、按用户、按API端点限制
import logging
import time
from functools import wraps
from django.core.cache import cache
from django.http import JsonResponse
from django.conf import settings
logger = logging.getLogger(__name__)
class RateLimitExceeded(Exception):
"""速率限制超出异常"""
pass
class RateLimiter:
"""速率限制器"""
# 默认限制配置
DEFAULT_LIMITS = {
'default': {'requests': 100, 'window': 60}, # 100次/分钟
'strict': {'requests': 10, 'window': 60}, # 10次/分钟
'loose': {'requests': 1000, 'window': 60}, # 1000次/分钟
}
@classmethod
def get_client_ip(cls, request):
"""获取客户端IP地址"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR', '')
return ip
@classmethod
def get_cache_key(cls, identifier, scope='default'):
"""
生成缓存键
Args:
identifier: 标识符IP用户ID等
scope: 作用域API端点名称等
Returns:
str: 缓存键
"""
return f"rate_limit:{scope}:{identifier}"
@classmethod
def is_rate_limited(cls, identifier, limit_config, scope='default'):
"""
检查是否超过速率限制
Args:
identifier: 标识符
limit_config: 限制配置 {'requests': 100, 'window': 60}
scope: 作用域
Returns:
tuple: (是否限制, 剩余请求数, 重置时间)
"""
requests_limit = limit_config['requests']
time_window = limit_config['window']
cache_key = cls.get_cache_key(identifier, scope)
# 获取当前窗口的请求记录
current_time = int(time.time())
window_start = current_time - time_window
# 使用列表存储请求时间戳
request_times = cache.get(cache_key, [])
# 过滤掉窗口外的请求
request_times = [t for t in request_times if t > window_start]
# 检查是否超限
if len(request_times) >= requests_limit:
# 计算重置时间
oldest_request = min(request_times)
reset_time = oldest_request + time_window
remaining = 0
is_limited = True
else:
# 添加当前请求
request_times.append(current_time)
remaining = requests_limit - len(request_times)
reset_time = current_time + time_window
is_limited = False
# 更新缓存
cache.set(cache_key, request_times, time_window + 10)
return is_limited, remaining, reset_time
@classmethod
def get_limit_config(cls, limit_name='default'):
"""
获取限制配置
Args:
limit_name: 限制名称或自定义配置
Returns:
dict: 限制配置
"""
if isinstance(limit_name, dict):
return limit_name
# 从设置中获取
custom_limits = getattr(settings, 'RATE_LIMITS', {})
if limit_name in custom_limits:
return custom_limits[limit_name]
if limit_name in cls.DEFAULT_LIMITS:
return cls.DEFAULT_LIMITS[limit_name]
# 默认限制
return cls.DEFAULT_LIMITS['default']
def rate_limit(limit='default', key_func=None, scope=None):
"""
API速率限制装饰器
Args:
limit: 限制配置名称或字典 {'requests': 100, 'window': 60}
key_func: 自定义键函数 func(request) -> str
scope: 作用域名称默认使用视图函数名
使用示例:
@rate_limit(limit='strict')
def my_api_view(request):
...
@rate_limit(limit={'requests': 50, 'window': 60})
def another_view(request):
...
@rate_limit(key_func=lambda req: req.user.id, scope='user_actions')
def user_api(request):
...
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request, *args, **kwargs):
# 获取限制配置
limit_config = RateLimiter.get_limit_config(limit)
# 确定作用域
view_scope = scope or view_func.__name__
# 确定标识符
if key_func:
identifier = key_func(request)
elif request.user.is_authenticated:
# 已登录用户使用用户ID
identifier = f"user_{request.user.id}"
else:
# 未登录用户使用IP
identifier = f"ip_{RateLimiter.get_client_ip(request)}"
# 检查速率限制
is_limited, remaining, reset_time = RateLimiter.is_rate_limited(
identifier,
limit_config,
view_scope
)
# 添加速率限制头
response_headers = {
'X-RateLimit-Limit': str(limit_config['requests']),
'X-RateLimit-Remaining': str(remaining),
'X-RateLimit-Reset': str(reset_time),
}
if is_limited:
# 超过限制
retry_after = reset_time - int(time.time())
logger.warning(
f"Rate limit exceeded for {identifier} "
f"on {view_scope}: {limit_config}"
)
response = JsonResponse({
'success': False,
'error': 'rate_limit_exceeded',
'message': f'请求过于频繁,请在 {retry_after} 秒后重试',
'retry_after': retry_after
}, status=429)
response_headers['Retry-After'] = str(retry_after)
# 设置响应头
for header, value in response_headers.items():
response[header] = value
return response
# 执行视图
response = view_func(request, *args, **kwargs)
# 添加速率限制头到响应
for header, value in response_headers.items():
response[header] = value
return response
return wrapped_view
return decorator
def ip_rate_limit(limit='default', scope=None):
"""
基于IP的速率限制装饰器
Args:
limit: 限制配置
scope: 作用域
示例:
@ip_rate_limit(limit='strict')
def api_view(request):
...
"""
return rate_limit(
limit=limit,
key_func=lambda req: f"ip_{RateLimiter.get_client_ip(req)}",
scope=scope
)
def user_rate_limit(limit='default', scope=None):
"""
基于用户的速率限制装饰器
Args:
limit: 限制配置
scope: 作用域
示例:
@user_rate_limit(limit={'requests': 50, 'window': 3600})
def api_view(request):
...
"""
def key_func(request):
if request.user.is_authenticated:
return f"user_{request.user.id}"
# 未登录用户降级到IP限制
return f"ip_{RateLimiter.get_client_ip(request)}"
return rate_limit(
limit=limit,
key_func=key_func,
scope=scope
)
# ==================== 中间件 ====================
class RateLimitMiddleware:
"""
全局速率限制中间件
settings.py 中配置:
MIDDLEWARE = [
...
'blog.rate_limit.RateLimitMiddleware',
]
RATE_LIMIT_ENABLED = True
GLOBAL_RATE_LIMIT = {'requests': 1000, 'window': 60}
"""
def __init__(self, get_response):
self.get_response = get_response
self.enabled = getattr(settings, 'RATE_LIMIT_ENABLED', False)
self.limit = getattr(settings, 'GLOBAL_RATE_LIMIT', {'requests': 1000, 'window': 60})
def __call__(self, request):
if self.enabled and request.path.startswith('/blog/api/'):
# 只对API请求进行全局限制
identifier = f"global_ip_{RateLimiter.get_client_ip(request)}"
is_limited, remaining, reset_time = RateLimiter.is_rate_limited(
identifier,
self.limit,
'global'
)
if is_limited:
retry_after = reset_time - int(time.time())
response = JsonResponse({
'success': False,
'error': 'rate_limit_exceeded',
'message': '全局请求频率超限,请稍后再试',
'retry_after': retry_after
}, status=429)
response['X-RateLimit-Limit'] = str(self.limit['requests'])
response['X-RateLimit-Remaining'] = '0'
response['X-RateLimit-Reset'] = str(reset_time)
response['Retry-After'] = str(retry_after)
return response
response = self.get_response(request)
return response

@ -0,0 +1,457 @@
/* 深色模式修复 - 覆盖 style.css 中的硬编码白色背景 */
/* 覆盖所有白色背景为使用CSS变量 */
[data-theme="dark"] .site-header,
[data-theme="dark"] #masthead {
background-color: var(--nav-bg) !important;
color: var(--nav-text) !important;
}
[data-theme="dark"] .site-content,
[data-theme="dark"] #content {
background-color: var(--bg-primary) !important;
}
[data-theme="dark"] .widget-area,
[data-theme="dark"] #secondary {
background-color: var(--sidebar-bg) !important;
}
[data-theme="dark"] .entry-content,
[data-theme="dark"] .entry-summary,
[data-theme="dark"] .page-content,
[data-theme="dark"] article {
background-color: var(--article-bg) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] .site {
background-color: var(--bg-primary) !important;
}
[data-theme="dark"] body {
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
/* 修复所有白色背景的元素 */
[data-theme="dark"] *[style*="background: #fff"],
[data-theme="dark"] *[style*="background-color: #fff"],
[data-theme="dark"] *[style*="background: white"],
[data-theme="dark"] *[style*="background-color: white"] {
background-color: var(--bg-primary) !important;
}
/* 修复所有白色文字的元素(排除按钮和链接) */
[data-theme="dark"] *[style*="color: #fff"]:not(.btn):not(a),
[data-theme="dark"] *[style*="color: white"]:not(.btn):not(a) {
color: var(--text-primary) !important;
}
/* 评论区修复 */
[data-theme="dark"] #comments,
[data-theme="dark"] .comment-list,
[data-theme="dark"] .comment,
[data-theme="dark"] .comment-body,
[data-theme="dark"] .comment-content {
background-color: var(--comment-bg) !important;
color: var(--text-primary) !important;
border-color: var(--comment-border) !important;
}
/* 导航菜单修复 */
[data-theme="dark"] .nav-menu,
[data-theme="dark"] .main-navigation,
[data-theme="dark"] #site-navigation {
background-color: var(--nav-bg) !important;
}
[data-theme="dark"] .nav-menu li,
[data-theme="dark"] .main-navigation li {
background-color: transparent !important;
}
[data-theme="dark"] .nav-menu a,
[data-theme="dark"] .main-navigation a {
color: var(--nav-text) !important;
}
[data-theme="dark"] .nav-menu a:hover,
[data-theme="dark"] .main-navigation a:hover {
background-color: var(--nav-hover-bg) !important;
color: var(--link-hover) !important;
}
/* Widget 修复 */
[data-theme="dark"] .widget {
background-color: var(--sidebar-bg) !important;
color: var(--text-primary) !important;
border-color: var(--sidebar-border) !important;
}
[data-theme="dark"] .widget-title {
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
[data-theme="dark"] .widget ul,
[data-theme="dark"] .widget ol {
background-color: transparent !important;
}
[data-theme="dark"] .widget a {
color: var(--link-color) !important;
}
/* 文章列表修复 */
[data-theme="dark"] .hentry,
[data-theme="dark"] .post,
[data-theme="dark"] .page {
background-color: var(--card-bg) !important;
color: var(--text-primary) !important;
border-color: var(--card-border) !important;
}
[data-theme="dark"] .entry-header {
background-color: transparent !important;
}
[data-theme="dark"] .entry-title a {
color: var(--text-primary) !important;
}
[data-theme="dark"] .entry-title a:hover {
color: var(--link-hover) !important;
}
[data-theme="dark"] .entry-meta,
[data-theme="dark"] .entry-footer {
color: var(--text-secondary) !important;
background-color: transparent !important;
}
/* 搜索框修复 */
[data-theme="dark"] #searchform,
[data-theme="dark"] .search-form {
background-color: var(--input-bg) !important;
}
[data-theme="dark"] #s,
[data-theme="dark"] .search-field {
background-color: var(--input-bg) !important;
color: var(--input-text) !important;
border-color: var(--input-border) !important;
}
/* 分页修复 */
[data-theme="dark"] .pagination,
[data-theme="dark"] .page-links,
[data-theme="dark"] .nav-links {
background-color: transparent !important;
}
[data-theme="dark"] .pagination a,
[data-theme="dark"] .page-links a,
[data-theme="dark"] .nav-links a {
background-color: var(--card-bg) !important;
color: var(--link-color) !important;
border-color: var(--border-primary) !important;
}
[data-theme="dark"] .pagination a:hover,
[data-theme="dark"] .page-links a:hover,
[data-theme="dark"] .nav-links a:hover {
background-color: var(--bg-hover) !important;
}
[data-theme="dark"] .pagination .current,
[data-theme="dark"] .page-links > .current {
background-color: var(--accent-primary) !important;
color: var(--text-inverse) !important;
}
/* 面包屑导航修复 */
[data-theme="dark"] .breadcrumbs,
[data-theme="dark"] .breadcrumb {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
}
/* 侧边栏小工具特定修复 */
[data-theme="dark"] #calendar_wrap {
background-color: var(--card-bg) !important;
}
[data-theme="dark"] #calendar_wrap table,
[data-theme="dark"] #calendar_wrap th,
[data-theme="dark"] #calendar_wrap td {
background-color: transparent !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
/* 标签云修复 */
[data-theme="dark"] .tagcloud a,
[data-theme="dark"] .wp_widget_tag_cloud a {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
[data-theme="dark"] .tagcloud a:hover,
[data-theme="dark"] .wp_widget_tag_cloud a:hover {
background-color: var(--bg-hover) !important;
color: var(--link-hover) !important;
}
/* 最近评论修复 */
[data-theme="dark"] .recentcomments {
background-color: transparent !important;
color: var(--text-primary) !important;
}
/* RSS 链接修复 */
[data-theme="dark"] .rss-date,
[data-theme="dark"] .rssSummary {
color: var(--text-secondary) !important;
}
/* 存档页面修复 */
[data-theme="dark"] .archive-meta,
[data-theme="dark"] .page-header {
background-color: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
/* 404 页面修复 */
[data-theme="dark"] .error404 .widget {
background-color: var(--card-bg) !important;
}
/* 图片说明修复 */
[data-theme="dark"] .wp-caption,
[data-theme="dark"] .gallery-caption {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
}
[data-theme="dark"] .wp-caption-text {
color: var(--text-secondary) !important;
}
/* 嵌入内容修复 */
[data-theme="dark"] embed,
[data-theme="dark"] iframe,
[data-theme="dark"] object {
border-color: var(--border-primary) !important;
}
/* 按钮修复 - 确保按钮上的白色文字不被改变 */
[data-theme="dark"] .btn,
[data-theme="dark"] button,
[data-theme="dark"] input[type="submit"],
[data-theme="dark"] input[type="button"],
[data-theme="dark"] .comment-reply-link {
color: inherit;
}
[data-theme="dark"] .btn-primary,
[data-theme="dark"] .btn-success,
[data-theme="dark"] .btn-info,
[data-theme="dark"] .btn-warning,
[data-theme="dark"] .btn-danger {
color: var(--text-inverse) !important;
}
/* Sticky post 修复 */
[data-theme="dark"] .sticky {
background-color: var(--bg-secondary) !important;
border-color: var(--accent-primary) !important;
}
/* 引用文字修复 */
[data-theme="dark"] cite {
color: var(--text-secondary) !important;
}
/* 列表修复 */
[data-theme="dark"] ul,
[data-theme="dark"] ol,
[data-theme="dark"] dl {
color: var(--text-primary) !important;
}
/* 定义列表修复 */
[data-theme="dark"] dt {
color: var(--text-primary) !important;
}
[data-theme="dark"] dd {
color: var(--text-secondary) !important;
}
/* 强调文本修复 */
[data-theme="dark"] strong,
[data-theme="dark"] b {
color: var(--text-primary) !important;
}
[data-theme="dark"] em,
[data-theme="dark"] i {
color: var(--text-primary) !important;
}
/* 删除线修复 */
[data-theme="dark"] del,
[data-theme="dark"] s {
color: var(--text-tertiary) !important;
}
/* 下划线修复 */
[data-theme="dark"] ins,
[data-theme="dark"] u {
color: var(--text-primary) !important;
background-color: var(--bg-tertiary) !important;
}
/* 小号文字修复 */
[data-theme="dark"] small {
color: var(--text-secondary) !important;
}
/* 标记文字修复 */
[data-theme="dark"] mark {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
}
/* Pygments 代码高亮修复 */
[data-theme="dark"] .highlight,
[data-theme="dark"] .codehilite {
background-color: var(--code-block-bg) !important;
}
[data-theme="dark"] .highlight pre,
[data-theme="dark"] .codehilite pre {
background-color: transparent !important;
}
/* 站点标题和描述修复 */
[data-theme="dark"] .site-title,
[data-theme="dark"] .site-description {
color: var(--nav-text) !important;
}
[data-theme="dark"] .site-title a {
color: var(--nav-text) !important;
}
[data-theme="dark"] .site-title a:hover {
color: var(--link-hover) !important;
}
/* 页面容器修复 */
[data-theme="dark"] #page,
[data-theme="dark"] .site,
[data-theme="dark"] #main,
[data-theme="dark"] .wrapper {
background-color: var(--bg-primary) !important;
}
/* 修复特定的警告框背景 */
[data-theme="dark"] *[style*="background: #fff9c0"],
[data-theme="dark"] *[style*="background-color: #fff9c0"] {
background-color: rgba(255, 249, 192, 0.2) !important;
color: var(--text-primary) !important;
}
/* 修复特定的警告框背景 */
[data-theme="dark"] *[style*="background: #fff3cd"],
[data-theme="dark"] *[style*="background-color: #fff3cd"] {
background-color: rgba(255, 243, 205, 0.2) !important;
color: var(--text-primary) !important;
}
/* 补充:文章卡片内部元素修复 */
[data-theme="dark"] .post-thumbnail,
[data-theme="dark"] .entry-thumbnail {
background-color: transparent !important;
}
/* 补充:作者信息框修复 */
[data-theme="dark"] .author-info,
[data-theme="dark"] .author-bio {
background-color: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
/* 补充:相关文章修复 */
[data-theme="dark"] .related-posts,
[data-theme="dark"] .related-articles {
background-color: var(--bg-secondary) !important;
}
/* 补充:分类和标签显示修复 */
[data-theme="dark"] .cat-links,
[data-theme="dark"] .tags-links {
color: var(--text-secondary) !important;
}
[data-theme="dark"] .cat-links a,
[data-theme="dark"] .tags-links a {
color: var(--link-color) !important;
background-color: var(--bg-tertiary) !important;
}
/* 补充:阅读更多链接修复 */
[data-theme="dark"] .more-link {
color: var(--link-color) !important;
}
[data-theme="dark"] .more-link:hover {
color: var(--link-hover) !important;
}
/* 补充:表单元素标签修复 */
[data-theme="dark"] label {
color: var(--text-primary) !important;
}
/* 补充:占位符修复 */
[data-theme="dark"] ::placeholder {
color: var(--input-placeholder) !important;
opacity: 1;
}
[data-theme="dark"] :-ms-input-placeholder {
color: var(--input-placeholder) !important;
}
[data-theme="dark"] ::-ms-input-placeholder {
color: var(--input-placeholder) !important;
}
/* 补充:选中文本修复 */
[data-theme="dark"] ::selection {
background-color: var(--accent-primary) !important;
color: var(--text-inverse) !important;
}
[data-theme="dark"] ::-moz-selection {
background-color: var(--accent-primary) !important;
color: var(--text-inverse) !important;
}
/* 修复 hfeed 类容器 */
[data-theme="dark"] .hfeed {
background-color: var(--bg-primary) !important;
}
/* 修复所有可能的白色背景覆盖 */
[data-theme="dark"] .site-header,
[data-theme="dark"] .site-content,
[data-theme="dark"] .site-footer {
background-color: transparent !important;
}

@ -266,9 +266,19 @@ def load_article_metas(article, user):
:param article:
:return:
"""
# 获取关注状态
is_following = False
if user.is_authenticated:
try:
from blog.models_social import UserFollow
is_following = UserFollow.is_following(user, article.author)
except Exception:
pass
return {
'article': article,
'user': user
'user': user,
'is_following': is_following
}
@ -352,11 +362,32 @@ def load_article_detail(article, isindex, user):
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
# 获取点赞和收藏数据
like_count = 0
favorite_count = 0
is_liked = False
is_favorited = False
try:
from blog.models_social import ArticleLike, ArticleFavorite
like_count = ArticleLike.get_article_like_count(article)
favorite_count = ArticleFavorite.get_article_favorite_count(article)
if user.is_authenticated:
is_liked = ArticleLike.is_liked(user, article)
is_favorited = ArticleFavorite.is_favorited(user, article)
except Exception:
pass
return {
'article': article,
'isindex': isindex,
'user': user,
'open_site_comment': blogsetting.open_site_comment,
'like_count': like_count,
'favorite_count': favorite_count,
'is_liked': is_liked,
'is_favorited': is_favorited,
}

@ -2,61 +2,183 @@ from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
from . import views_draft # 导入草稿视图
from . import views_social # 导入社交功能视图
from . import views_media # 导入多媒体管理视图
app_name = "blog"
app_name = "blog" #zhq: 应用命名空间用于URL反向解析
urlpatterns = [
#zhq: 首页路由 - 显示文章列表
path(
r'',
views.IndexView.as_view(),
name='index'),
#zhq: 首页分页路由 - 支持分页浏览
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
#zhq: 友情链接页路由 - 显示所有启用的友情链接
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
#zhq: 友情链接页路由 - 显示所有启用的友情链接
path(
r'upload',
views.fileupload,
name='upload'),
# zhq: 缓存清理路由 - 手动清理系统缓存
path(
r'clean',
views.clean_cache_view,
name='clean'),
# 草稿 API 路由
path(
'api/draft/save/',
views_draft.save_draft_api,
name='save_draft'),
path(
'api/draft/get/',
views_draft.get_draft_api,
name='get_draft'),
path(
'api/draft/list/',
views_draft.list_drafts_api,
name='list_drafts'),
path(
'api/draft/delete/',
views_draft.delete_draft_api,
name='delete_draft'),
path(
'api/draft/apply/',
views_draft.apply_draft_api,
name='apply_draft'),
# 关注 API 路由
path(
'api/follow/',
views_social.follow_user_api,
name='follow_user'),
path(
'api/unfollow/',
views_social.unfollow_user_api,
name='unfollow_user'),
path(
'api/check-following/',
views_social.check_following_api,
name='check_following'),
path(
'api/following-list/',
views_social.following_list_api,
name='following_list'),
path(
'api/followers-list/',
views_social.followers_list_api,
name='followers_list'),
# 收藏 API 路由
path(
'api/favorite/',
views_social.favorite_article_api,
name='favorite_article'),
path(
'api/unfavorite/',
views_social.unfavorite_article_api,
name='unfavorite_article'),
path(
'api/check-favorite/',
views_social.check_favorite_api,
name='check_favorite'),
path(
'api/favorites-list/',
views_social.favorites_list_api,
name='favorites_list'),
# 点赞 API 路由
path(
'api/like/',
views_social.like_article_api,
name='like_article'),
path(
'api/unlike/',
views_social.unlike_article_api,
name='unlike_article'),
path(
'api/check-like/',
views_social.check_like_api,
name='check_like'),
path(
'api/likes-list/',
views_social.likes_list_api,
name='likes_list'),
# 多媒体管理 API 路由
path(
'api/media/upload/',
views_media.upload_media_api,
name='upload_media'),
path(
'api/media/list/',
views_media.list_media_api,
name='list_media'),
path(
'api/media/delete/',
views_media.delete_media_api,
name='delete_media'),
path(
'api/media/update/',
views_media.update_media_api,
name='update_media'),
path(
'api/media/folder/create/',
views_media.create_folder_api,
name='create_media_folder'),
path(
'api/media/folder/list/',
views_media.list_folders_api,
name='list_media_folders'),
]

@ -23,7 +23,7 @@ from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
#zhq: 文章列表基类视图 - 提供通用的列表功能和缓存机制
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
@ -33,7 +33,7 @@ class ArticleListView(ListView):
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
paginate_by = settings.PAGINATE_BY #zhq: 从配置获取分页大小
page_kwarg = 'page'
link_type = LinkShowType.L
@ -42,6 +42,7 @@ class ArticleListView(ListView):
@property
def page_number(self):
# zhq: 获取当前页码支持URL参数和GET参数
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
@ -88,7 +89,7 @@ class ArticleListView(ListView):
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
#zhq: 获取当前页码支持URL参数和GET参数
class IndexView(ArticleListView):
'''
首页
@ -97,14 +98,19 @@ class IndexView(ArticleListView):
link_type = LinkShowType.I
def get_queryset_data(self):
article_list = Article.objects.filter(type='a', status='p')
# zhq: 获取已发布的普通文章
# 性能优化:使用 select_related 预加载外键关系,避免 N+1 查询
# 使用 prefetch_related 预加载多对多关系(标签)
article_list = Article.objects.filter(type='a', status='p') \
.select_related('author', 'category') \
.prefetch_related('tags')
return article_list
def get_queryset_cache_key(self):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
#zhq: 文章详情页视图 - 显示单篇文章内容和评论
class ArticleDetailView(DetailView):
'''
文章详情页面
@ -117,6 +123,7 @@ class ArticleDetailView(DetailView):
def get_context_data(self, **kwargs):
comment_form = CommentForm()
# zhq: 获取文章评论并进行分页处理
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
@ -135,6 +142,7 @@ class ArticleDetailView(DetailView):
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# zhq: 获取文章评论并进行分页处理
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
@ -161,7 +169,7 @@ class ArticleDetailView(DetailView):
hooks.run_action('after_article_body_get', article=article, request=self.request)
return context
#zhq: 分类详情页视图 - 显示指定分类下的文章
class CategoryDetailView(ArticleListView):
'''
分类目录列表
@ -174,10 +182,14 @@ class CategoryDetailView(ArticleListView):
categoryname = category.name
self.categoryname = categoryname
# zhq: 获取分类及其所有子分类
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
# 性能优化:预加载关联对象
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
category__name__in=categorynames, status='p') \
.select_related('author', 'category') \
.prefetch_related('tags')
return article_list
def get_queryset_cache_key(self):
@ -200,7 +212,7 @@ class CategoryDetailView(ArticleListView):
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
#zhq: 作者详情页视图 - 显示指定作者的文章
class AuthorDetailView(ArticleListView):
'''
作者详情页
@ -216,8 +228,11 @@ class AuthorDetailView(ArticleListView):
def get_queryset_data(self):
author_name = self.kwargs['author_name']
# 性能优化:预加载关联对象
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
author__username=author_name, type='a', status='p') \
.select_related('author', 'category') \
.prefetch_related('tags')
return article_list
def get_context_data(self, **kwargs):
@ -226,7 +241,7 @@ class AuthorDetailView(ArticleListView):
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(**kwargs)
#zhq: 标签详情页视图 - 显示指定标签的文章
class TagDetailView(ArticleListView):
'''
标签列表页面
@ -238,8 +253,11 @@ class TagDetailView(ArticleListView):
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
# 性能优化预加载关联对象tags 使用 prefetch_related
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
tags__name=tag_name, type='a', status='p') \
.select_related('author', 'category') \
.prefetch_related('tags')
return article_list
def get_queryset_cache_key(self):
@ -258,7 +276,7 @@ class TagDetailView(ArticleListView):
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
#zhq: 文章归档页视图 - 显示所有文章的按时间归档
class ArchivesView(ArticleListView):
'''
文章归档页面
@ -269,13 +287,16 @@ class ArchivesView(ArticleListView):
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
return Article.objects.filter(status='p').all()
# 性能优化:预加载关联对象
return Article.objects.filter(status='p') \
.select_related('author', 'category') \
.prefetch_related('tags').all()
def get_queryset_cache_key(self):
cache_key = 'archives'
return cache_key
#zhq: 友情链接页视图
class LinkListView(ListView):
model = Links
template_name = 'blog/links_list.html'
@ -283,7 +304,7 @@ class LinkListView(ListView):
def get_queryset(self):
return Links.objects.filter(is_enable=True)
#zhq: Elasticsearch搜索视图
class EsSearchView(SearchView):
def get_context(self):
paginator, page = self.build_page()
@ -300,7 +321,7 @@ class EsSearchView(SearchView):
return context
#zhq: 文件上传视图 - 支持图片和其他文件上传
@csrf_exempt
def fileupload(request):
"""
@ -309,6 +330,7 @@ def fileupload(request):
:return:
"""
if request.method == 'POST':
# zhq: 验证上传签名,确保安全性
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
@ -323,6 +345,7 @@ def fileupload(request):
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)
# zhq: 使用UUID生成唯一文件名避免冲突
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")
@ -330,6 +353,7 @@ def fileupload(request):
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
if isimage:
# zhq: 对图片进行压缩优化
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
@ -340,7 +364,7 @@ def fileupload(request):
else:
return HttpResponse("only for post")
#zhq: 404错误页面视图
def page_not_found_view(
request,
exception,
@ -354,7 +378,7 @@ def page_not_found_view(
'statuscode': '404'},
status=404)
#zhq: 500服务器错误页面视图
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
@ -362,7 +386,7 @@ def server_error_view(request, template_name='blog/error_page.html'):
'statuscode': '500'},
status=500)
#zhq: 403权限拒绝页面视图
def permission_denied_view(
request,
exception,
@ -374,7 +398,7 @@ def permission_denied_view(
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
#zhq: 清理缓存视图
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')

@ -0,0 +1,292 @@
# 文章草稿 API 视图
# 提供草稿的自动保存、获取和恢复功能
import json
import logging
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from blog.models_draft import ArticleDraft
from blog.models import Article
logger = logging.getLogger(__name__)
@login_required
@require_http_methods(["POST"])
def save_draft_api(request):
"""
自动保存草稿 API
POST 参数
- title: 文章标题
- body: 文章正文
- article_id: 文章ID编辑现有文章时
- category_id: 分类ID
- tags: 标签ID列表JSON
- status: 状态
- comment_status: 评论状态
- type: 类型
- session_id: 会话ID
返回
JSON: {success: true, draft_id: xxx, message: xxx}
"""
try:
data = json.loads(request.body)
title = data.get('title', '')
body = data.get('body', '')
article_id = data.get('article_id')
category_id = data.get('category_id')
tags_data = data.get('tags', [])
status = data.get('status', 'd')
comment_status = data.get('comment_status', 'o')
type_value = data.get('type', 'a')
session_id = data.get('session_id', '')
# 保存草稿
draft = ArticleDraft.save_draft(
user=request.user,
title=title,
body=body,
article_id=article_id,
category_id=category_id,
tags_data=tags_data,
status=status,
comment_status=comment_status,
type=type_value,
session_id=session_id
)
return JsonResponse({
'success': True,
'draft_id': draft.id,
'message': '草稿已自动保存',
'last_update': draft.last_update_time.strftime('%Y-%m-%d %H:%M:%S')
})
except Exception as e:
logger.error(f"保存草稿失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'保存草稿失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def get_draft_api(request):
"""
获取草稿 API
GET 参数
- article_id: 文章ID获取该文章的草稿
- session_id: 会话ID获取该会话的草稿
- draft_id: 草稿ID直接获取指定草稿
返回
JSON: {success: true, draft: {...}}
"""
try:
article_id = request.GET.get('article_id')
session_id = request.GET.get('session_id')
draft_id = request.GET.get('draft_id')
draft = None
if draft_id:
draft = ArticleDraft.objects.filter(
id=draft_id,
author=request.user,
is_published=False
).first()
elif session_id:
draft = ArticleDraft.objects.filter(
author=request.user,
session_id=session_id,
is_published=False
).first()
elif article_id:
draft = ArticleDraft.objects.filter(
author=request.user,
article_id=article_id,
is_published=False
).first()
else:
# 获取最新的未发布草稿
draft = ArticleDraft.objects.filter(
author=request.user,
is_published=False
).first()
if draft:
return JsonResponse({
'success': True,
'draft': {
'id': draft.id,
'title': draft.title,
'body': draft.body,
'article_id': draft.article_id,
'category_id': draft.category_id,
'tags': draft.tags_data,
'status': draft.status,
'comment_status': draft.comment_status,
'type': draft.type,
'last_update': draft.last_update_time.strftime('%Y-%m-%d %H:%M:%S'),
'session_id': draft.session_id
}
})
else:
return JsonResponse({
'success': False,
'message': '未找到草稿'
}, status=404)
except Exception as e:
logger.error(f"获取草稿失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取草稿失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def list_drafts_api(request):
"""
获取用户的所有草稿列表 API
返回
JSON: {success: true, drafts: [...]}
"""
try:
drafts = ArticleDraft.objects.filter(
author=request.user,
is_published=False
).order_by('-last_update_time')[:20]
drafts_data = []
for draft in drafts:
drafts_data.append({
'id': draft.id,
'title': draft.title or '(无标题)',
'preview': draft.get_preview_text(50),
'article_id': draft.article_id,
'last_update': draft.last_update_time.strftime('%Y-%m-%d %H:%M:%S'),
'creation_time': draft.creation_time.strftime('%Y-%m-%d %H:%M:%S')
})
return JsonResponse({
'success': True,
'drafts': drafts_data,
'count': len(drafts_data)
})
except Exception as e:
logger.error(f"获取草稿列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取草稿列表失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def delete_draft_api(request):
"""
删除草稿 API
POST 参数
- draft_id: 草稿ID
返回
JSON: {success: true, message: xxx}
"""
try:
data = json.loads(request.body)
draft_id = data.get('draft_id')
if not draft_id:
return JsonResponse({
'success': False,
'message': '缺少 draft_id 参数'
}, status=400)
draft = ArticleDraft.objects.filter(
id=draft_id,
author=request.user
).first()
if draft:
draft.delete()
return JsonResponse({
'success': True,
'message': '草稿已删除'
})
else:
return JsonResponse({
'success': False,
'message': '未找到草稿'
}, status=404)
except Exception as e:
logger.error(f"删除草稿失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'删除草稿失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def apply_draft_api(request):
"""
应用草稿到文章 API
POST 参数
- draft_id: 草稿ID
返回
JSON: {success: true, article_id: xxx, message: xxx}
"""
try:
data = json.loads(request.body)
draft_id = data.get('draft_id')
if not draft_id:
return JsonResponse({
'success': False,
'message': '缺少 draft_id 参数'
}, status=400)
draft = ArticleDraft.objects.filter(
id=draft_id,
author=request.user,
is_published=False
).first()
if not draft:
return JsonResponse({
'success': False,
'message': '未找到草稿'
}, status=404)
# 应用草稿到文章
article = draft.apply_to_article()
return JsonResponse({
'success': True,
'article_id': article.id,
'message': '草稿已应用到文章',
'article_url': article.get_absolute_url()
})
except Exception as e:
logger.error(f"应用草稿失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'应用草稿失败: {str(e)}'
}, status=500)

@ -0,0 +1,463 @@
# 多媒体管理 API 视图
# 提供图片上传、管理、删除等功能
import os
import uuid
import mimetypes
import logging
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.http import require_http_methods
from django.core.paginator import Paginator
from PIL import Image
from blog.models_media import MediaFile, MediaFolder, MediaFileFolder
from djangoblog.utils import get_sha256
logger = logging.getLogger(__name__)
# ==================== 文件上传 API ====================
@login_required
@require_http_methods(["POST"])
def upload_media_api(request):
"""
上传媒体文件 API
POST 参数
- file: 上传的文件
- folder_id: 文件夹ID可选
- description: 文件描述可选
- is_public: 是否公开可选默认True
返回
JSON: {success: true, file: {...}}
"""
try:
# 检查是否有文件
if 'file' not in request.FILES:
return JsonResponse({
'success': False,
'message': '没有上传文件'
}, status=400)
uploaded_file = request.FILES['file']
folder_id = request.POST.get('folder_id')
description = request.POST.get('description', '')
is_public = request.POST.get('is_public', 'true').lower() == 'true'
# 验证文件大小默认最大10MB
max_size = getattr(settings, 'MAX_UPLOAD_SIZE', 10 * 1024 * 1024)
if uploaded_file.size > max_size:
return JsonResponse({
'success': False,
'message': f'文件大小超过限制(最大{max_size // (1024*1024)}MB'
}, status=400)
# 计算文件哈希
file_hash = MediaFile.get_file_hash(uploaded_file)
# 检查是否已存在相同文件
existing_file = MediaFile.check_duplicate(file_hash)
if existing_file:
# 如果已存在,直接返回已有文件
return JsonResponse({
'success': True,
'message': '文件已存在,使用已有文件',
'file': _serialize_media_file(existing_file),
'is_duplicate': True
})
# 判断文件类型
original_filename = uploaded_file.name
file_ext = os.path.splitext(original_filename)[1].lower()
mime_type = uploaded_file.content_type or mimetypes.guess_type(original_filename)[0] or 'application/octet-stream'
# 判断是否为图片
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
is_image = file_ext in image_extensions
# 生成存储文件名
from django.utils import timezone
timestr = timezone.now().strftime('%Y/%m/%d')
stored_filename = f"{uuid.uuid4().hex}{file_ext}"
# 确定存储路径
file_type = 'image' if is_image else 'files'
base_dir = os.path.join(settings.STATICFILES, file_type, timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
# 保存文件
save_path = os.path.join(base_dir, stored_filename)
with open(save_path, 'wb+') as destination:
for chunk in uploaded_file.chunks():
destination.write(chunk)
# 相对路径
relative_path = os.path.join(file_type, timestr, stored_filename).replace('\\', '/')
# 获取图片尺寸
width, height = None, None
if is_image:
try:
with Image.open(save_path) as img:
width, height = img.size
# 如果图片太大,进行压缩
max_dimension = 2000
if width > max_dimension or height > max_dimension:
img.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS)
img.save(save_path, quality=85, optimize=True)
width, height = img.size
else:
# 优化图片
img.save(save_path, quality=85, optimize=True)
except Exception as e:
logger.error(f"处理图片失败: {e}", exc_info=True)
# 创建数据库记录
media_file = MediaFile.objects.create(
original_filename=original_filename,
stored_filename=stored_filename,
file_type='image' if is_image else 'file',
file_size=uploaded_file.size,
file_hash=file_hash,
mime_type=mime_type,
file_path=relative_path,
uploader=request.user,
width=width,
height=height,
description=description,
is_public=is_public
)
# 生成缩略图
if is_image:
media_file.generate_thumbnail()
# 添加到文件夹
if folder_id:
try:
folder = MediaFolder.objects.get(id=folder_id, owner=request.user)
MediaFileFolder.objects.create(file=media_file, folder=folder)
except MediaFolder.DoesNotExist:
pass
return JsonResponse({
'success': True,
'message': '上传成功',
'file': _serialize_media_file(media_file)
})
except Exception as e:
logger.error(f"上传文件失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'上传失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def list_media_api(request):
"""
获取媒体文件列表 API
GET 参数
- page: 页码可选默认1
- page_size: 每页数量可选默认20
- file_type: 文件类型筛选可选image/file
- folder_id: 文件夹ID可选
- search: 搜索关键词可选
返回
JSON: {success: true, files: [...], total: xxx, page: xxx}
"""
try:
page = int(request.GET.get('page', 1))
page_size = int(request.GET.get('page_size', 20))
file_type = request.GET.get('file_type')
folder_id = request.GET.get('folder_id')
search = request.GET.get('search', '').strip()
# 基础查询(只显示用户自己的文件)
queryset = MediaFile.objects.filter(uploader=request.user)
# 文件类型筛选
if file_type in ['image', 'file']:
queryset = queryset.filter(file_type=file_type)
# 文件夹筛选
if folder_id:
try:
folder = MediaFolder.objects.get(id=folder_id, owner=request.user)
file_ids = MediaFileFolder.objects.filter(folder=folder).values_list('file_id', flat=True)
queryset = queryset.filter(id__in=file_ids)
except MediaFolder.DoesNotExist:
pass
# 搜索
if search:
queryset = queryset.filter(original_filename__icontains=search)
# 排序
queryset = queryset.order_by('-upload_time')
# 分页
paginator = Paginator(queryset, page_size)
page_obj = paginator.get_page(page)
files_data = [_serialize_media_file(f) for f in page_obj]
return JsonResponse({
'success': True,
'files': files_data,
'total': paginator.count,
'page': page,
'page_size': page_size,
'total_pages': paginator.num_pages
})
except Exception as e:
logger.error(f"获取文件列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["DELETE"])
def delete_media_api(request):
"""
删除媒体文件 API
DELETE 参数JSON
- file_id: 文件ID
返回
JSON: {success: true, message: xxx}
"""
try:
import json
data = json.loads(request.body)
file_id = data.get('file_id')
if not file_id:
return JsonResponse({
'success': False,
'message': '缺少 file_id 参数'
}, status=400)
# 获取文件(只能删除自己的文件)
try:
media_file = MediaFile.objects.get(id=file_id, uploader=request.user)
except MediaFile.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文件不存在或无权限删除'
}, status=404)
# 检查引用计数
if media_file.reference_count > 0:
return JsonResponse({
'success': False,
'message': f'文件正被{media_file.reference_count}处引用,无法删除'
}, status=400)
filename = media_file.original_filename
media_file.delete()
return JsonResponse({
'success': True,
'message': f'已删除文件:{filename}'
})
except Exception as e:
logger.error(f"删除文件失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'删除失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def update_media_api(request):
"""
更新媒体文件信息 API
POST 参数JSON
- file_id: 文件ID
- description: 描述可选
- is_public: 是否公开可选
返回
JSON: {success: true, file: {...}}
"""
try:
import json
data = json.loads(request.body)
file_id = data.get('file_id')
if not file_id:
return JsonResponse({
'success': False,
'message': '缺少 file_id 参数'
}, status=400)
# 获取文件
try:
media_file = MediaFile.objects.get(id=file_id, uploader=request.user)
except MediaFile.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文件不存在或无权限修改'
}, status=404)
# 更新字段
if 'description' in data:
media_file.description = data['description']
if 'is_public' in data:
media_file.is_public = data['is_public']
media_file.save()
return JsonResponse({
'success': True,
'message': '更新成功',
'file': _serialize_media_file(media_file)
})
except Exception as e:
logger.error(f"更新文件失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'更新失败: {str(e)}'
}, status=500)
# ==================== 文件夹管理 API ====================
@login_required
@require_http_methods(["POST"])
def create_folder_api(request):
"""创建文件夹"""
try:
import json
data = json.loads(request.body)
name = data.get('name', '').strip()
parent_id = data.get('parent_id')
description = data.get('description', '')
if not name:
return JsonResponse({
'success': False,
'message': '文件夹名称不能为空'
}, status=400)
# 获取父文件夹
parent = None
if parent_id:
try:
parent = MediaFolder.objects.get(id=parent_id, owner=request.user)
except MediaFolder.DoesNotExist:
return JsonResponse({
'success': False,
'message': '父文件夹不存在'
}, status=404)
# 创建文件夹
folder = MediaFolder.objects.create(
name=name,
parent=parent,
owner=request.user,
description=description
)
return JsonResponse({
'success': True,
'message': '创建成功',
'folder': {
'id': folder.id,
'name': folder.name,
'full_path': folder.get_full_path(),
'parent_id': folder.parent_id,
'created_time': folder.created_time.strftime('%Y-%m-%d %H:%M:%S'),
'description': folder.description
}
})
except Exception as e:
logger.error(f"创建文件夹失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'创建失败: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def list_folders_api(request):
"""获取文件夹列表"""
try:
folders = MediaFolder.objects.filter(owner=request.user).order_by('name')
folders_data = []
for folder in folders:
folders_data.append({
'id': folder.id,
'name': folder.name,
'full_path': folder.get_full_path(),
'parent_id': folder.parent_id,
'created_time': folder.created_time.strftime('%Y-%m-%d %H:%M:%S'),
'files_count': folder.file_relations.count()
})
return JsonResponse({
'success': True,
'folders': folders_data
})
except Exception as e:
logger.error(f"获取文件夹列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取失败: {str(e)}'
}, status=500)
# ==================== 工具函数 ====================
def _serialize_media_file(media_file):
"""序列化媒体文件对象"""
return {
'id': media_file.id,
'original_filename': media_file.original_filename,
'file_type': media_file.file_type,
'file_size': media_file.file_size,
'file_size_readable': _format_file_size(media_file.file_size),
'mime_type': media_file.mime_type,
'url': media_file.get_absolute_url(),
'thumbnail_url': media_file.get_thumbnail_url(),
'width': media_file.width,
'height': media_file.height,
'upload_time': media_file.upload_time.strftime('%Y-%m-%d %H:%M:%S'),
'description': media_file.description,
'is_public': media_file.is_public,
'reference_count': media_file.reference_count
}
def _format_file_size(size_bytes):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"

@ -0,0 +1,744 @@
# 用户关注和收藏 API 视图
# 提供关注、取消关注、收藏、取消收藏等功能
import json
import logging
from functools import wraps
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.shortcuts import get_object_or_404
from blog.models import Article
from blog.models_social import UserFollow, ArticleFavorite, ArticleLike
from blog.rate_limit import rate_limit, user_rate_limit
logger = logging.getLogger(__name__)
User = get_user_model()
def ajax_login_required(view_func):
"""
自定义装饰器AJAX请求时返回JSON响应而不是重定向
"""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return JsonResponse({
'success': False,
'message': '请先登录',
'login_required': True
}, status=401)
return view_func(request, *args, **kwargs)
return wrapper
# ==================== 关注相关 API ====================
@ajax_login_required
@require_http_methods(["POST"])
@user_rate_limit(limit={'requests': 20, 'window': 60}, scope='follow') # 20次/分钟
def follow_user_api(request):
"""
关注用户 API
POST 参数
- user_id: 要关注的用户ID
返回
JSON: {success: true, message: xxx}
"""
try:
data = json.loads(request.body)
user_id = data.get('user_id')
if not user_id:
return JsonResponse({
'success': False,
'message': '缺少 user_id 参数'
}, status=400)
# 获取要关注的用户
following_user = get_object_or_404(User, id=user_id)
# 不能关注自己
if following_user == request.user:
return JsonResponse({
'success': False,
'message': '不能关注自己'
}, status=400)
# 执行关注
follow = UserFollow.follow(request.user, following_user)
if follow:
return JsonResponse({
'success': True,
'message': f'已关注 {following_user.username}',
'following_count': UserFollow.get_following_count(request.user),
'is_following': True
})
else:
return JsonResponse({
'success': False,
'message': '已经关注过该用户'
}, status=400)
except User.DoesNotExist:
return JsonResponse({
'success': False,
'message': '用户不存在'
}, status=404)
except Exception as e:
logger.error(f"关注用户失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'关注失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["POST"])
@user_rate_limit(limit={'requests': 20, 'window': 60}, scope='unfollow') # 20次/分钟
def unfollow_user_api(request):
"""
取消关注用户 API
POST 参数
- user_id: 要取消关注的用户ID
返回
JSON: {success: true, message: xxx}
"""
try:
data = json.loads(request.body)
user_id = data.get('user_id')
if not user_id:
return JsonResponse({
'success': False,
'message': '缺少 user_id 参数'
}, status=400)
# 获取要取消关注的用户
following_user = get_object_or_404(User, id=user_id)
# 执行取消关注
success = UserFollow.unfollow(request.user, following_user)
if success:
return JsonResponse({
'success': True,
'message': f'已取消关注 {following_user.username}',
'following_count': UserFollow.get_following_count(request.user),
'is_following': False
})
else:
return JsonResponse({
'success': False,
'message': '未关注该用户'
}, status=400)
except User.DoesNotExist:
return JsonResponse({
'success': False,
'message': '用户不存在'
}, status=404)
except Exception as e:
logger.error(f"取消关注失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'取消关注失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 100, 'window': 60}, scope='check_following') # 100次/分钟
def check_following_api(request):
"""
检查是否已关注某用户 API
GET 参数
- user_id: 用户ID
返回
JSON: {success: true, is_following: true/false}
"""
try:
user_id = request.GET.get('user_id')
if not user_id:
return JsonResponse({
'success': False,
'message': '缺少 user_id 参数'
}, status=400)
following_user = get_object_or_404(User, id=user_id)
is_following = UserFollow.is_following(request.user, following_user)
return JsonResponse({
'success': True,
'is_following': is_following
})
except User.DoesNotExist:
return JsonResponse({
'success': False,
'message': '用户不存在'
}, status=404)
except Exception as e:
logger.error(f"检查关注状态失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'检查失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='following_list') # 50次/分钟
def following_list_api(request):
"""
获取关注列表 API
GET 参数
- user_id: 用户ID可选默认为当前用户
- limit: 返回数量限制可选
返回
JSON: {success: true, following: [...], count: xxx}
"""
try:
user_id = request.GET.get('user_id')
limit = request.GET.get('limit', 20)
try:
limit = int(limit)
except ValueError:
limit = 20
# 获取用户
if user_id:
user = get_object_or_404(User, id=user_id)
else:
user = request.user
# 获取关注列表
following_list = UserFollow.get_following_list(user, limit)
following_data = []
for u in following_list:
following_data.append({
'id': u.id,
'username': u.username,
'email': u.email if u == request.user else None,
'date_joined': u.date_joined.strftime('%Y-%m-%d')
})
return JsonResponse({
'success': True,
'following': following_data,
'count': UserFollow.get_following_count(user),
'total_count': len(following_data)
})
except User.DoesNotExist:
return JsonResponse({
'success': False,
'message': '用户不存在'
}, status=404)
except Exception as e:
logger.error(f"获取关注列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='followers_list') # 50次/分钟
def followers_list_api(request):
"""
获取粉丝列表 API
GET 参数
- user_id: 用户ID可选默认为当前用户
- limit: 返回数量限制可选
返回
JSON: {success: true, followers: [...], count: xxx}
"""
try:
user_id = request.GET.get('user_id')
limit = request.GET.get('limit', 20)
try:
limit = int(limit)
except ValueError:
limit = 20
# 获取用户
if user_id:
user = get_object_or_404(User, id=user_id)
else:
user = request.user
# 获取粉丝列表
followers_list = UserFollow.get_followers_list(user, limit)
followers_data = []
for u in followers_list:
followers_data.append({
'id': u.id,
'username': u.username,
'date_joined': u.date_joined.strftime('%Y-%m-%d')
})
return JsonResponse({
'success': True,
'followers': followers_data,
'count': UserFollow.get_followers_count(user),
'total_count': len(followers_data)
})
except User.DoesNotExist:
return JsonResponse({
'success': False,
'message': '用户不存在'
}, status=404)
except Exception as e:
logger.error(f"获取粉丝列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取失败: {str(e)}'
}, status=500)
# ==================== 收藏相关 API ====================
@ajax_login_required
@require_http_methods(["POST"])
@user_rate_limit(limit={'requests': 30, 'window': 60}, scope='favorite') # 30次/分钟
def favorite_article_api(request):
"""
收藏文章 API
POST 参数
- article_id: 文章ID
- note: 收藏备注可选
返回
JSON: {success: true, message: xxx}
"""
try:
data = json.loads(request.body)
article_id = data.get('article_id')
note = data.get('note', '')
if not article_id:
return JsonResponse({
'success': False,
'message': '缺少 article_id 参数'
}, status=400)
# 获取文章
article = get_object_or_404(Article, id=article_id)
# 执行收藏
favorite = ArticleFavorite.add_favorite(request.user, article, note)
if favorite:
return JsonResponse({
'success': True,
'message': f'已收藏文章《{article.title}',
'favorite_count': ArticleFavorite.get_article_favorite_count(article), # 返回文章被收藏次数
'user_favorite_count': ArticleFavorite.get_favorite_count(request.user), # 返回用户收藏总数
'is_favorited': True
})
else:
return JsonResponse({
'success': False,
'message': '已经收藏过该文章'
}, status=400)
except Article.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文章不存在'
}, status=404)
except Exception as e:
logger.error(f"收藏文章失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'收藏失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["POST"])
@user_rate_limit(limit={'requests': 30, 'window': 60}, scope='unfavorite') # 30次/分钟
def unfavorite_article_api(request):
"""
取消收藏文章 API
POST 参数
- article_id: 文章ID
返回
JSON: {success: true, message: xxx}
"""
try:
data = json.loads(request.body)
article_id = data.get('article_id')
if not article_id:
return JsonResponse({
'success': False,
'message': '缺少 article_id 参数'
}, status=400)
# 获取文章
article = get_object_or_404(Article, id=article_id)
# 执行取消收藏
success = ArticleFavorite.remove_favorite(request.user, article)
if success:
return JsonResponse({
'success': True,
'message': f'已取消收藏《{article.title}',
'favorite_count': ArticleFavorite.get_article_favorite_count(article), # 返回文章被收藏次数
'user_favorite_count': ArticleFavorite.get_favorite_count(request.user), # 返回用户收藏总数
'is_favorited': False
})
else:
return JsonResponse({
'success': False,
'message': '未收藏该文章'
}, status=400)
except Article.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文章不存在'
}, status=404)
except Exception as e:
logger.error(f"取消收藏失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'取消收藏失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 100, 'window': 60}, scope='check_favorite') # 100次/分钟
def check_favorite_api(request):
"""
检查是否已收藏某文章 API
GET 参数
- article_id: 文章ID
返回
JSON: {success: true, is_favorited: true/false}
"""
try:
article_id = request.GET.get('article_id')
if not article_id:
return JsonResponse({
'success': False,
'message': '缺少 article_id 参数'
}, status=400)
article = get_object_or_404(Article, id=article_id)
is_favorited = ArticleFavorite.is_favorited(request.user, article)
return JsonResponse({
'success': True,
'is_favorited': is_favorited,
'favorite_count': ArticleFavorite.get_article_favorite_count(article), # 文章被收藏次数
'user_favorite_count': ArticleFavorite.get_favorite_count(request.user) # 用户收藏总数
})
except Article.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文章不存在'
}, status=404)
except Exception as e:
logger.error(f"检查收藏状态失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'检查失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='favorites_list') # 50次/分钟
def favorites_list_api(request):
"""
获取收藏列表 API
GET 参数
- limit: 返回数量限制可选
返回
JSON: {success: true, favorites: [...], count: xxx}
"""
try:
limit = request.GET.get('limit', 20)
try:
limit = int(limit)
except ValueError:
limit = 20
# 获取收藏列表
favorites = ArticleFavorite.get_user_favorites(request.user, limit)
favorites_data = []
for fav in favorites:
article = fav.article
favorites_data.append({
'id': fav.id,
'article_id': article.id,
'article_title': article.title,
'article_url': article.get_absolute_url(),
'author': article.author.username,
'category': article.category.name,
'pub_time': article.pub_time.strftime('%Y-%m-%d'),
'favorited_time': fav.creation_time.strftime('%Y-%m-%d %H:%M:%S'),
'note': fav.note
})
return JsonResponse({
'success': True,
'favorites': favorites_data,
'count': ArticleFavorite.get_favorite_count(request.user),
'total_count': len(favorites_data)
})
except Exception as e:
logger.error(f"获取收藏列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取失败: {str(e)}'
}, status=500)
# ==================== 点赞相关 API ====================
@ajax_login_required
@require_http_methods(["POST"])
@user_rate_limit(limit={'requests': 60, 'window': 60}, scope='like') # 60次/分钟
def like_article_api(request):
"""
点赞文章 API
POST 参数
- article_id: 文章ID
返回
JSON: {success: true, message: xxx, like_count: xxx}
"""
try:
data = json.loads(request.body)
article_id = data.get('article_id')
if not article_id:
return JsonResponse({
'success': False,
'message': '缺少 article_id 参数'
}, status=400)
# 获取文章
article = get_object_or_404(Article, id=article_id)
# 执行点赞
like = ArticleLike.add_like(request.user, article)
if like:
like_count = ArticleLike.get_article_like_count(article)
return JsonResponse({
'success': True,
'message': f'已点赞《{article.title}',
'like_count': like_count,
'is_liked': True
})
else:
return JsonResponse({
'success': False,
'message': '已经点赞过该文章'
}, status=400)
except Article.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文章不存在'
}, status=404)
except Exception as e:
logger.error(f"点赞文章失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'点赞失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["POST"])
@user_rate_limit(limit={'requests': 60, 'window': 60}, scope='unlike') # 60次/分钟
def unlike_article_api(request):
"""
取消点赞文章 API
POST 参数
- article_id: 文章ID
返回
JSON: {success: true, message: xxx, like_count: xxx}
"""
try:
data = json.loads(request.body)
article_id = data.get('article_id')
if not article_id:
return JsonResponse({
'success': False,
'message': '缺少 article_id 参数'
}, status=400)
# 获取文章
article = get_object_or_404(Article, id=article_id)
# 执行取消点赞
success = ArticleLike.remove_like(request.user, article)
if success:
like_count = ArticleLike.get_article_like_count(article)
return JsonResponse({
'success': True,
'message': f'已取消点赞《{article.title}',
'like_count': like_count,
'is_liked': False
})
else:
return JsonResponse({
'success': False,
'message': '未点赞该文章'
}, status=400)
except Article.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文章不存在'
}, status=404)
except Exception as e:
logger.error(f"取消点赞失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'取消点赞失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 100, 'window': 60}, scope='check_like') # 100次/分钟
def check_like_api(request):
"""
检查是否已点赞某文章 API
GET 参数
- article_id: 文章ID
返回
JSON: {success: true, is_liked: true/false, like_count: xxx}
"""
try:
article_id = request.GET.get('article_id')
if not article_id:
return JsonResponse({
'success': False,
'message': '缺少 article_id 参数'
}, status=400)
article = get_object_or_404(Article, id=article_id)
is_liked = ArticleLike.is_liked(request.user, article)
like_count = ArticleLike.get_article_like_count(article)
return JsonResponse({
'success': True,
'is_liked': is_liked,
'like_count': like_count
})
except Article.DoesNotExist:
return JsonResponse({
'success': False,
'message': '文章不存在'
}, status=404)
except Exception as e:
logger.error(f"检查点赞状态失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'检查失败: {str(e)}'
}, status=500)
@ajax_login_required
@require_http_methods(["GET"])
@user_rate_limit(limit={'requests': 50, 'window': 60}, scope='likes_list') # 50次/分钟
def likes_list_api(request):
"""
获取点赞列表 API
GET 参数
- limit: 返回数量限制可选
返回
JSON: {success: true, likes: [...], count: xxx}
"""
try:
limit = request.GET.get('limit', 20)
try:
limit = int(limit)
except ValueError:
limit = 20
# 获取点赞列表
likes = ArticleLike.get_user_likes(request.user, limit)
likes_data = []
for like in likes:
article = like.article
likes_data.append({
'id': like.id,
'article_id': article.id,
'article_title': article.title,
'article_url': article.get_absolute_url(),
'author': article.author.username,
'category': article.category.name,
'pub_time': article.pub_time.strftime('%Y-%m-%d'),
'liked_time': like.creation_time.strftime('%Y-%m-%d %H:%M:%S')
})
return JsonResponse({
'success': True,
'likes': likes_data,
'count': ArticleLike.get_user_like_count(request.user),
'total_count': len(likes_data)
})
except Exception as e:
logger.error(f"获取点赞列表失败: {e}", exc_info=True)
return JsonResponse({
'success': False,
'message': f'获取失败: {str(e)}'
}, status=500)

@ -0,0 +1,26 @@
#!/usr/bin/env python
import django
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings')
django.setup()
from django.contrib import admin
print("=" * 60)
print("检查 Django Admin 注册情况")
print("=" * 60)
blog_models = []
for model in admin.site._registry.keys():
if model._meta.app_label == 'blog':
blog_models.append(model)
print(f"\n已注册的 Blog 模型数量: {len(blog_models)}")
print("\n模型列表:")
for model in sorted(blog_models, key=lambda m: m.__name__):
verbose = model._meta.verbose_name
verbose_plural = model._meta.verbose_name_plural
print(f" {model.__name__}")
print(f" 显示名称: {verbose} / {verbose_plural}")
print("\n" + "=" * 60)

@ -0,0 +1,26 @@
#!/usr/bin/env python
import django
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings')
django.setup()
from djangoblog.admin_site import admin_site
print("=" * 60)
print("检查自定义 Admin Site 注册情况")
print("=" * 60)
blog_models = []
for model in admin_site._registry.keys():
if model._meta.app_label == 'blog':
blog_models.append(model)
print(f"\n已注册的 Blog 模型数量: {len(blog_models)}")
print("\n模型列表:")
for model in sorted(blog_models, key=lambda m: m.__name__):
verbose = model._meta.verbose_name
verbose_plural = model._meta.verbose_name_plural
print(f" {model.__name__}")
print(f" 显示名称: {verbose} / {verbose_plural}")
print("\n" + "=" * 60)

@ -0,0 +1 @@
# Management commands module

@ -0,0 +1,69 @@
# 评论管理命令
# 提供评论反垃圾功能的管理工具
from django.core.management.base import BaseCommand
from django.core.cache import cache
class Command(BaseCommand):
help = '评论系统管理工具'
def add_arguments(self, parser):
parser.add_argument(
'action',
type=str,
choices=['clear_cache', 'unblock_ip', 'show_blocked'],
help='操作类型clear_cache(清除缓存), unblock_ip(解封IP), show_blocked(显示被封IP)'
)
parser.add_argument(
'--ip',
type=str,
help='IP地址用于unblock_ip'
)
def handle(self, *args, **options):
action = options['action']
if action == 'clear_cache':
self.clear_comment_cache()
elif action == 'unblock_ip':
ip = options.get('ip')
if not ip:
self.stdout.write(self.style.ERROR('请使用 --ip 参数指定IP地址'))
return
self.unblock_ip(ip)
elif action == 'show_blocked':
self.show_blocked_ips()
def clear_comment_cache(self):
"""清除所有评论相关的缓存"""
self.stdout.write('正在清除评论缓存...')
# 无法直接遍历所有缓存键,所以只能提示
self.stdout.write(self.style.WARNING(
'注意:由于缓存系统限制,无法自动清除所有评论缓存。\n'
'如需完全清除,请使用以下命令:\n'
' python manage.py shell\n'
' >>> from django.core.cache import cache\n'
' >>> cache.clear() # 清除所有缓存\n'
))
self.stdout.write(self.style.SUCCESS('提示已显示'))
def unblock_ip(self, ip):
"""解封被封禁的IP"""
blacklist_key = f'ip_blacklist_{ip}'
count_key = f'ip_comment_count_{ip}'
# 删除黑名单和计数
cache.delete(blacklist_key)
cache.delete(count_key)
self.stdout.write(self.style.SUCCESS(f'已解封IP: {ip}'))
def show_blocked_ips(self):
"""显示被封禁的IP列表"""
self.stdout.write(self.style.WARNING(
'由于缓存系统限制无法列出所有被封IP。\n'
'可以尝试查看应用日志了解被封IP信息。\n'
))

@ -0,0 +1,23 @@
# 性能优化:为评论模型添加数据库索引
# Generated manually for performance optimization
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [
# 为 Comment 模型添加组合索引
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['article', 'is_enable'], name='comment_article_enable_idx'),
),
migrations.AddIndex(
model_name='comment',
index=models.Index(fields=['is_enable', '-id'], name='comment_enable_id_idx'),
),
]

@ -0,0 +1,14 @@
# Generated by Django 5.2.7 on 2025-11-24 02:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comments', '0002_add_performance_indexes'),
('comments', '0003_alter_comment_options_remove_comment_created_time_and_more'),
]
operations = [
]

@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2025-11-25 13:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comments', '0004_merge_20251124_0221'),
]
operations = [
migrations.RemoveIndex(
model_name='comment',
name='comment_article_enable_idx',
),
migrations.RemoveIndex(
model_name='comment',
name='comment_enable_id_idx',
),
]

@ -0,0 +1,479 @@
# 评论反垃圾和邮件通知工具
# 提供评论垃圾检测、频率限制和邮件通知功能
import hashlib
import logging
from datetime import timedelta
from django.core.cache import cache
from django.core.mail import send_mail, EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.timezone import now
from django.conf import settings
logger = logging.getLogger(__name__)
# ==================== 反垃圾评论功能 ====================
class CommentSpamChecker:
"""评论垃圾检测器"""
# 垃圾关键词列表(可从数据库或配置文件加载)
SPAM_KEYWORDS = [
'赌博', '博彩', '色情', '黄色', '成人',
'代开发票', '办证', '贷款', '信用卡套现',
'私服', '外挂', '刷钻', '代刷',
'六合彩', '时时彩', '北京赛车',
'免费领取', '点击领取', '加QQ', '加微信',
'viagra', 'casino', 'poker', 'cialis',
]
# 允许重复评论的最小间隔时间(秒)
MIN_COMMENT_INTERVAL = 10
# 单个用户每小时最大评论数
MAX_COMMENTS_PER_HOUR = 20
# 单个IP每小时最大评论数
MAX_COMMENTS_PER_IP_HOUR = 30
@classmethod
def check_spam_keywords(cls, content):
"""
检查评论内容是否包含垃圾关键词
Args:
content: 评论内容
Returns:
(bool, str): (是否包含垃圾词, 匹配到的关键词)
"""
content_lower = content.lower()
for keyword in cls.SPAM_KEYWORDS:
if keyword.lower() in content_lower:
return True, keyword
return False, None
@classmethod
def check_duplicate(cls, user, content, article_id):
"""
检查是否为重复评论
Args:
user: 用户对象
content: 评论内容
article_id: 文章ID
Returns:
bool: 是否为重复评论
"""
# 生成内容哈希
content_hash = hashlib.md5(content.encode('utf-8')).hexdigest()
cache_key = f'comment_hash_{user.id}_{article_id}_{content_hash}'
# 检查缓存中是否存在5分钟内
if cache.get(cache_key):
return True
# 设置缓存标记5分钟过期
cache.set(cache_key, '1', 300)
return False
@classmethod
def check_rate_limit_user(cls, user):
"""
检查用户评论频率限制
Args:
user: 用户对象
Returns:
(bool, str): (是否超过限制, 错误信息)
"""
from comments.models import Comment
# 检查最近一条评论的时间
last_comment_key = f'user_last_comment_{user.id}'
last_comment_time = cache.get(last_comment_key)
if last_comment_time:
time_diff = (now() - last_comment_time).total_seconds()
if time_diff < cls.MIN_COMMENT_INTERVAL:
wait_time = int(cls.MIN_COMMENT_INTERVAL - time_diff)
return True, f'评论太频繁,请等待 {wait_time} 秒后再试'
# 检查每小时评论数
one_hour_ago = now() - timedelta(hours=1)
hour_count = Comment.objects.filter(
author=user,
creation_time__gte=one_hour_ago
).count()
if hour_count >= cls.MAX_COMMENTS_PER_HOUR:
return True, f'您在1小时内发表评论过多请稍后再试'
# 更新最后评论时间
cache.set(last_comment_key, now(), 3600)
return False, None
@classmethod
def check_rate_limit_ip(cls, ip_address):
"""
检查IP评论频率限制
Args:
ip_address: IP地址
Returns:
(bool, str): (是否超过限制, 错误信息)
"""
# 检查IP是否在黑名单
blacklist_key = f'ip_blacklist_{ip_address}'
if cache.get(blacklist_key):
return True, '您的IP已被限制评论'
# 检查IP每小时评论数
ip_count_key = f'ip_comment_count_{ip_address}'
ip_count = cache.get(ip_count_key, 0)
if ip_count >= cls.MAX_COMMENTS_PER_IP_HOUR:
# 将IP加入黑名单1小时
cache.set(blacklist_key, '1', 3600)
return True, '该IP地址评论过于频繁已被暂时限制'
# 增加计数
cache.set(ip_count_key, ip_count + 1, 3600)
return False, None
@classmethod
def is_spam(cls, user, content, article_id, ip_address):
"""
综合检查评论是否为垃圾评论
Args:
user: 用户对象
content: 评论内容
article_id: 文章ID
ip_address: IP地址
Returns:
(bool, str): (是否为垃圾评论, 原因)
"""
# 检查垃圾关键词
has_spam, keyword = cls.check_spam_keywords(content)
if has_spam:
return True, f'评论包含敏感词: {keyword}'
# 检查重复评论
if cls.check_duplicate(user, content, article_id):
return True, '请不要发表重复评论'
# 检查用户频率限制
is_limited, msg = cls.check_rate_limit_user(user)
if is_limited:
return True, msg
# 检查IP频率限制
is_limited, msg = cls.check_rate_limit_ip(ip_address)
if is_limited:
return True, msg
return False, None
# ==================== 邮件通知功能 ====================
class CommentNotifier:
"""评论邮件通知器"""
@classmethod
def get_client_ip(cls, request):
"""获取客户端IP地址"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
@classmethod
def send_comment_notification(cls, comment, site_url=''):
"""
发送评论通知邮件
Args:
comment: 评论对象
site_url: 网站URL可选
"""
try:
# 通知文章作者
if comment.article.author.email:
cls._notify_article_author(comment, site_url)
# 如果是回复评论,通知被回复者
if comment.parent_comment and comment.parent_comment.author.email:
cls._notify_parent_comment_author(comment, site_url)
# 如果需要审核,通知管理员
if not comment.is_enable:
cls._notify_admin_for_review(comment, site_url)
except Exception as e:
logger.error(f"发送评论通知失败: {e}", exc_info=True)
@classmethod
def _notify_article_author(cls, comment, site_url):
"""通知文章作者有新评论"""
# 不要通知自己
if comment.author == comment.article.author:
return
subject = f'您的文章《{comment.article.title}》有新评论'
article_url = site_url + comment.article.get_absolute_url()
# 构建邮件内容
context = {
'comment': comment,
'article': comment.article,
'article_url': article_url,
'site_url': site_url,
}
# 纯文本版本
text_content = f"""
您好 {comment.article.author.username}
您的文章{comment.article.title}收到了新评论
评论者{comment.author.username}
评论内容
{comment.body}
查看评论{article_url}#div-comment-{comment.id}
---
此邮件由系统自动发送请勿回复
""".strip()
# HTML版本可以更美观
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #4CAF50; color: white; padding: 10px 20px; border-radius: 5px; }}
.content {{ background-color: #f9f9f9; padding: 20px; margin: 20px 0; border-left: 4px solid #4CAF50; }}
.footer {{ color: #666; font-size: 12px; margin-top: 20px; }}
a {{ color: #4CAF50; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>新评论通知</h2>
</div>
<p>您好 {comment.article.author.username}</p>
<p>您的文章<strong>{comment.article.title}</strong>收到了新评论</p>
<div class="content">
<p><strong>评论者</strong>{comment.author.username}</p>
<p><strong>评论内容</strong></p>
<p>{comment.body}</p>
</div>
<p><a href="{article_url}#div-comment-{comment.id}">点击查看评论</a></p>
<div class="footer">
<p>此邮件由系统自动发送请勿回复</p>
</div>
</div>
</body>
</html>
""".strip()
# 发送邮件
msg = EmailMultiAlternatives(
subject,
text_content,
settings.DEFAULT_FROM_EMAIL,
[comment.article.author.email]
)
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
logger.info(f"已向文章作者 {comment.article.author.username} 发送评论通知")
@classmethod
def _notify_parent_comment_author(cls, comment, site_url):
"""通知被回复的评论作者"""
# 不要通知自己
if comment.author == comment.parent_comment.author:
return
# 如果父评论作者就是文章作者,已经通知过了
if comment.parent_comment.author == comment.article.author:
return
subject = f'{comment.author.username} 回复了您的评论'
article_url = site_url + comment.article.get_absolute_url()
text_content = f"""
您好 {comment.parent_comment.author.username}
{comment.author.username} 回复了您在文章{comment.article.title}中的评论
您的评论
{comment.parent_comment.body}
回复内容
{comment.body}
查看回复{article_url}#div-comment-{comment.id}
---
此邮件由系统自动发送请勿回复
""".strip()
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #2196F3; color: white; padding: 10px 20px; border-radius: 5px; }}
.content {{ background-color: #f9f9f9; padding: 20px; margin: 20px 0; border-left: 4px solid #2196F3; }}
.reply {{ background-color: #e3f2fd; padding: 15px; margin-top: 10px; border-left: 4px solid #2196F3; }}
.footer {{ color: #666; font-size: 12px; margin-top: 20px; }}
a {{ color: #2196F3; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>评论回复通知</h2>
</div>
<p>您好 {comment.parent_comment.author.username}</p>
<p><strong>{comment.author.username}</strong> 回复了您在文章{comment.article.title}中的评论</p>
<div class="content">
<p><strong>您的评论</strong></p>
<p>{comment.parent_comment.body}</p>
</div>
<div class="reply">
<p><strong>回复内容</strong></p>
<p>{comment.body}</p>
</div>
<p><a href="{article_url}#div-comment-{comment.id}">点击查看回复</a></p>
<div class="footer">
<p>此邮件由系统自动发送请勿回复</p>
</div>
</div>
</body>
</html>
""".strip()
msg = EmailMultiAlternatives(
subject,
text_content,
settings.DEFAULT_FROM_EMAIL,
[comment.parent_comment.author.email]
)
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
logger.info(f"已向评论者 {comment.parent_comment.author.username} 发送回复通知")
@classmethod
def _notify_admin_for_review(cls, comment, site_url):
"""通知管理员审核评论"""
# 获取所有管理员邮箱
from django.contrib.auth import get_user_model
User = get_user_model()
admin_emails = list(User.objects.filter(
is_staff=True,
is_active=True,
email__isnull=False
).exclude(email='').values_list('email', flat=True))
if not admin_emails:
return
subject = f'新评论待审核 - {comment.article.title}'
article_url = site_url + comment.article.get_absolute_url()
admin_url = site_url + '/admin/comments/comment/'
text_content = f"""
管理员您好
有新评论需要审核
文章{comment.article.title}
评论者{comment.author.username}
评论时间{comment.creation_time.strftime('%Y-%m-%d %H:%M:%S')}
评论内容
{comment.body}
审核评论{admin_url}{comment.id}/change/
查看文章{article_url}
---
此邮件由系统自动发送请勿回复
""".strip()
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #FF9800; color: white; padding: 10px 20px; border-radius: 5px; }}
.content {{ background-color: #fff3e0; padding: 20px; margin: 20px 0; border-left: 4px solid #FF9800; }}
.footer {{ color: #666; font-size: 12px; margin-top: 20px; }}
a {{ color: #FF9800; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.button {{ display: inline-block; padding: 10px 20px; background-color: #FF9800; color: white; border-radius: 5px; margin: 10px 5px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>新评论待审核</h2>
</div>
<p>管理员您好</p>
<p>有新评论需要审核</p>
<div class="content">
<p><strong>文章</strong>{comment.article.title}</p>
<p><strong>评论者</strong>{comment.author.username}</p>
<p><strong>评论时间</strong>{comment.creation_time.strftime('%Y-%m-%d %H:%M:%S')}</p>
<p><strong>评论内容</strong></p>
<p>{comment.body}</p>
</div>
<p>
<a href="{admin_url}{comment.id}/change/" class="button">审核评论</a>
<a href="{article_url}" class="button">查看文章</a>
</p>
<div class="footer">
<p>此邮件由系统自动发送请勿回复</p>
</div>
</div>
</body>
</html>
""".strip()
msg = EmailMultiAlternatives(
subject,
text_content,
settings.DEFAULT_FROM_EMAIL,
admin_emails
)
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
logger.info(f"已向 {len(admin_emails)} 位管理员发送审核通知")

@ -40,6 +40,7 @@ class DjangoBlogAdminSite(AdminSite):
admin_site = DjangoBlogAdminSite(name='admin')
# Blog 核心模型
admin_site.register(Article, ArticlelAdmin)
admin_site.register(Category, CategoryAdmin)
admin_site.register(Tag, TagAdmin)
@ -47,6 +48,25 @@ admin_site.register(Links, LinksAdmin)
admin_site.register(SideBar, SideBarAdmin)
admin_site.register(BlogSettings, BlogSettingsAdmin)
# Blog 新功能模型
from blog.models_version import ArticleVersion
from blog.models_draft import ArticleDraft
from blog.models_social import UserFollow, ArticleFavorite, ArticleLike
from blog.models_media import MediaFile, MediaFolder, MediaFileFolder
from blog.admin_version import ArticleVersionAdmin
from blog.admin_draft import ArticleDraftAdmin
from blog.admin_social import UserFollowAdmin, ArticleFavoriteAdmin, ArticleLikeAdmin
from blog.admin_media import MediaFileAdmin, MediaFolderAdmin, MediaFileFolderAdmin
admin_site.register(ArticleVersion, ArticleVersionAdmin)
admin_site.register(ArticleDraft, ArticleDraftAdmin)
admin_site.register(UserFollow, UserFollowAdmin)
admin_site.register(ArticleFavorite, ArticleFavoriteAdmin)
admin_site.register(ArticleLike, ArticleLikeAdmin)
admin_site.register(MediaFile, MediaFileAdmin)
admin_site.register(MediaFolder, MediaFolderAdmin)
admin_site.register(MediaFileFolder, MediaFileFolderAdmin)
admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)

@ -105,7 +105,6 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
if os.environ.get('DJANGO_DB', 'mysql') == 'sqlite':
DATABASES = {
'default': {

@ -0,0 +1,457 @@
/* 深色模式修复 - 覆盖 style.css 中的硬编码白色背景 */
/* 覆盖所有白色背景为使用CSS变量 */
[data-theme="dark"] .site-header,
[data-theme="dark"] #masthead {
background-color: var(--nav-bg) !important;
color: var(--nav-text) !important;
}
[data-theme="dark"] .site-content,
[data-theme="dark"] #content {
background-color: var(--bg-primary) !important;
}
[data-theme="dark"] .widget-area,
[data-theme="dark"] #secondary {
background-color: var(--sidebar-bg) !important;
}
[data-theme="dark"] .entry-content,
[data-theme="dark"] .entry-summary,
[data-theme="dark"] .page-content,
[data-theme="dark"] article {
background-color: var(--article-bg) !important;
color: var(--text-primary) !important;
}
[data-theme="dark"] .site {
background-color: var(--bg-primary) !important;
}
[data-theme="dark"] body {
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
/* 修复所有白色背景的元素 */
[data-theme="dark"] *[style*="background: #fff"],
[data-theme="dark"] *[style*="background-color: #fff"],
[data-theme="dark"] *[style*="background: white"],
[data-theme="dark"] *[style*="background-color: white"] {
background-color: var(--bg-primary) !important;
}
/* 修复所有白色文字的元素(排除按钮和链接) */
[data-theme="dark"] *[style*="color: #fff"]:not(.btn):not(a),
[data-theme="dark"] *[style*="color: white"]:not(.btn):not(a) {
color: var(--text-primary) !important;
}
/* 评论区修复 */
[data-theme="dark"] #comments,
[data-theme="dark"] .comment-list,
[data-theme="dark"] .comment,
[data-theme="dark"] .comment-body,
[data-theme="dark"] .comment-content {
background-color: var(--comment-bg) !important;
color: var(--text-primary) !important;
border-color: var(--comment-border) !important;
}
/* 导航菜单修复 */
[data-theme="dark"] .nav-menu,
[data-theme="dark"] .main-navigation,
[data-theme="dark"] #site-navigation {
background-color: var(--nav-bg) !important;
}
[data-theme="dark"] .nav-menu li,
[data-theme="dark"] .main-navigation li {
background-color: transparent !important;
}
[data-theme="dark"] .nav-menu a,
[data-theme="dark"] .main-navigation a {
color: var(--nav-text) !important;
}
[data-theme="dark"] .nav-menu a:hover,
[data-theme="dark"] .main-navigation a:hover {
background-color: var(--nav-hover-bg) !important;
color: var(--link-hover) !important;
}
/* Widget 修复 */
[data-theme="dark"] .widget {
background-color: var(--sidebar-bg) !important;
color: var(--text-primary) !important;
border-color: var(--sidebar-border) !important;
}
[data-theme="dark"] .widget-title {
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
[data-theme="dark"] .widget ul,
[data-theme="dark"] .widget ol {
background-color: transparent !important;
}
[data-theme="dark"] .widget a {
color: var(--link-color) !important;
}
/* 文章列表修复 */
[data-theme="dark"] .hentry,
[data-theme="dark"] .post,
[data-theme="dark"] .page {
background-color: var(--card-bg) !important;
color: var(--text-primary) !important;
border-color: var(--card-border) !important;
}
[data-theme="dark"] .entry-header {
background-color: transparent !important;
}
[data-theme="dark"] .entry-title a {
color: var(--text-primary) !important;
}
[data-theme="dark"] .entry-title a:hover {
color: var(--link-hover) !important;
}
[data-theme="dark"] .entry-meta,
[data-theme="dark"] .entry-footer {
color: var(--text-secondary) !important;
background-color: transparent !important;
}
/* 搜索框修复 */
[data-theme="dark"] #searchform,
[data-theme="dark"] .search-form {
background-color: var(--input-bg) !important;
}
[data-theme="dark"] #s,
[data-theme="dark"] .search-field {
background-color: var(--input-bg) !important;
color: var(--input-text) !important;
border-color: var(--input-border) !important;
}
/* 分页修复 */
[data-theme="dark"] .pagination,
[data-theme="dark"] .page-links,
[data-theme="dark"] .nav-links {
background-color: transparent !important;
}
[data-theme="dark"] .pagination a,
[data-theme="dark"] .page-links a,
[data-theme="dark"] .nav-links a {
background-color: var(--card-bg) !important;
color: var(--link-color) !important;
border-color: var(--border-primary) !important;
}
[data-theme="dark"] .pagination a:hover,
[data-theme="dark"] .page-links a:hover,
[data-theme="dark"] .nav-links a:hover {
background-color: var(--bg-hover) !important;
}
[data-theme="dark"] .pagination .current,
[data-theme="dark"] .page-links > .current {
background-color: var(--accent-primary) !important;
color: var(--text-inverse) !important;
}
/* 面包屑导航修复 */
[data-theme="dark"] .breadcrumbs,
[data-theme="dark"] .breadcrumb {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
}
/* 侧边栏小工具特定修复 */
[data-theme="dark"] #calendar_wrap {
background-color: var(--card-bg) !important;
}
[data-theme="dark"] #calendar_wrap table,
[data-theme="dark"] #calendar_wrap th,
[data-theme="dark"] #calendar_wrap td {
background-color: transparent !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
/* 标签云修复 */
[data-theme="dark"] .tagcloud a,
[data-theme="dark"] .wp_widget_tag_cloud a {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
[data-theme="dark"] .tagcloud a:hover,
[data-theme="dark"] .wp_widget_tag_cloud a:hover {
background-color: var(--bg-hover) !important;
color: var(--link-hover) !important;
}
/* 最近评论修复 */
[data-theme="dark"] .recentcomments {
background-color: transparent !important;
color: var(--text-primary) !important;
}
/* RSS 链接修复 */
[data-theme="dark"] .rss-date,
[data-theme="dark"] .rssSummary {
color: var(--text-secondary) !important;
}
/* 存档页面修复 */
[data-theme="dark"] .archive-meta,
[data-theme="dark"] .page-header {
background-color: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
/* 404 页面修复 */
[data-theme="dark"] .error404 .widget {
background-color: var(--card-bg) !important;
}
/* 图片说明修复 */
[data-theme="dark"] .wp-caption,
[data-theme="dark"] .gallery-caption {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
}
[data-theme="dark"] .wp-caption-text {
color: var(--text-secondary) !important;
}
/* 嵌入内容修复 */
[data-theme="dark"] embed,
[data-theme="dark"] iframe,
[data-theme="dark"] object {
border-color: var(--border-primary) !important;
}
/* 按钮修复 - 确保按钮上的白色文字不被改变 */
[data-theme="dark"] .btn,
[data-theme="dark"] button,
[data-theme="dark"] input[type="submit"],
[data-theme="dark"] input[type="button"],
[data-theme="dark"] .comment-reply-link {
color: inherit;
}
[data-theme="dark"] .btn-primary,
[data-theme="dark"] .btn-success,
[data-theme="dark"] .btn-info,
[data-theme="dark"] .btn-warning,
[data-theme="dark"] .btn-danger {
color: var(--text-inverse) !important;
}
/* Sticky post 修复 */
[data-theme="dark"] .sticky {
background-color: var(--bg-secondary) !important;
border-color: var(--accent-primary) !important;
}
/* 引用文字修复 */
[data-theme="dark"] cite {
color: var(--text-secondary) !important;
}
/* 列表修复 */
[data-theme="dark"] ul,
[data-theme="dark"] ol,
[data-theme="dark"] dl {
color: var(--text-primary) !important;
}
/* 定义列表修复 */
[data-theme="dark"] dt {
color: var(--text-primary) !important;
}
[data-theme="dark"] dd {
color: var(--text-secondary) !important;
}
/* 强调文本修复 */
[data-theme="dark"] strong,
[data-theme="dark"] b {
color: var(--text-primary) !important;
}
[data-theme="dark"] em,
[data-theme="dark"] i {
color: var(--text-primary) !important;
}
/* 删除线修复 */
[data-theme="dark"] del,
[data-theme="dark"] s {
color: var(--text-tertiary) !important;
}
/* 下划线修复 */
[data-theme="dark"] ins,
[data-theme="dark"] u {
color: var(--text-primary) !important;
background-color: var(--bg-tertiary) !important;
}
/* 小号文字修复 */
[data-theme="dark"] small {
color: var(--text-secondary) !important;
}
/* 标记文字修复 */
[data-theme="dark"] mark {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
}
/* Pygments 代码高亮修复 */
[data-theme="dark"] .highlight,
[data-theme="dark"] .codehilite {
background-color: var(--code-block-bg) !important;
}
[data-theme="dark"] .highlight pre,
[data-theme="dark"] .codehilite pre {
background-color: transparent !important;
}
/* 站点标题和描述修复 */
[data-theme="dark"] .site-title,
[data-theme="dark"] .site-description {
color: var(--nav-text) !important;
}
[data-theme="dark"] .site-title a {
color: var(--nav-text) !important;
}
[data-theme="dark"] .site-title a:hover {
color: var(--link-hover) !important;
}
/* 页面容器修复 */
[data-theme="dark"] #page,
[data-theme="dark"] .site,
[data-theme="dark"] #main,
[data-theme="dark"] .wrapper {
background-color: var(--bg-primary) !important;
}
/* 修复特定的警告框背景 */
[data-theme="dark"] *[style*="background: #fff9c0"],
[data-theme="dark"] *[style*="background-color: #fff9c0"] {
background-color: rgba(255, 249, 192, 0.2) !important;
color: var(--text-primary) !important;
}
/* 修复特定的警告框背景 */
[data-theme="dark"] *[style*="background: #fff3cd"],
[data-theme="dark"] *[style*="background-color: #fff3cd"] {
background-color: rgba(255, 243, 205, 0.2) !important;
color: var(--text-primary) !important;
}
/* 补充:文章卡片内部元素修复 */
[data-theme="dark"] .post-thumbnail,
[data-theme="dark"] .entry-thumbnail {
background-color: transparent !important;
}
/* 补充:作者信息框修复 */
[data-theme="dark"] .author-info,
[data-theme="dark"] .author-bio {
background-color: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-color: var(--border-primary) !important;
}
/* 补充:相关文章修复 */
[data-theme="dark"] .related-posts,
[data-theme="dark"] .related-articles {
background-color: var(--bg-secondary) !important;
}
/* 补充:分类和标签显示修复 */
[data-theme="dark"] .cat-links,
[data-theme="dark"] .tags-links {
color: var(--text-secondary) !important;
}
[data-theme="dark"] .cat-links a,
[data-theme="dark"] .tags-links a {
color: var(--link-color) !important;
background-color: var(--bg-tertiary) !important;
}
/* 补充:阅读更多链接修复 */
[data-theme="dark"] .more-link {
color: var(--link-color) !important;
}
[data-theme="dark"] .more-link:hover {
color: var(--link-hover) !important;
}
/* 补充:表单元素标签修复 */
[data-theme="dark"] label {
color: var(--text-primary) !important;
}
/* 补充:占位符修复 */
[data-theme="dark"] ::placeholder {
color: var(--input-placeholder) !important;
opacity: 1;
}
[data-theme="dark"] :-ms-input-placeholder {
color: var(--input-placeholder) !important;
}
[data-theme="dark"] ::-ms-input-placeholder {
color: var(--input-placeholder) !important;
}
/* 补充:选中文本修复 */
[data-theme="dark"] ::selection {
background-color: var(--accent-primary) !important;
color: var(--text-inverse) !important;
}
[data-theme="dark"] ::-moz-selection {
background-color: var(--accent-primary) !important;
color: var(--text-inverse) !important;
}
/* 修复 hfeed 类容器 */
[data-theme="dark"] .hfeed {
background-color: var(--bg-primary) !important;
}
/* 修复所有可能的白色背景覆盖 */
[data-theme="dark"] .site-header,
[data-theme="dark"] .site-content,
[data-theme="dark"] .site-footer {
background-color: transparent !important;
}

@ -0,0 +1,296 @@
/* 多媒体选择器样式 */
.media-picker-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10000;
}
.media-picker-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.media-picker-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 1000px;
height: 80%;
max-height: 700px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
}
.media-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.media-picker-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.media-picker-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
}
.media-picker-close:hover {
color: #333;
}
.media-picker-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #e0e0e0;
}
.media-picker-search input {
padding: 8px 15px;
border: 1px solid #ddd;
border-radius: 4px;
width: 250px;
font-size: 14px;
}
.btn-upload {
display: inline-block;
padding: 8px 20px;
background: #4CAF50;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-upload:hover {
background: #45a049;
}
.media-picker-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.media-files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
}
.media-file-item {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 10px;
cursor: pointer;
transition: all 0.3s;
position: relative;
background: white;
}
.media-file-item:hover {
border-color: #4CAF50;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.media-file-item.selected {
border-color: #4CAF50;
background: #f1f8f4;
}
.media-file-preview {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.media-file-preview img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
.file-icon {
font-size: 48px;
}
.media-file-info {
text-align: center;
}
.media-file-name {
font-size: 13px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.media-file-meta {
font-size: 12px;
color: #999;
}
.media-file-check {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
background: #4CAF50;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.media-file-item.selected .media-file-check {
display: flex;
}
.media-picker-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-top: 1px solid #e0e0e0;
}
.media-picker-pagination {
display: flex;
align-items: center;
gap: 15px;
}
.media-picker-pagination button {
padding: 6px 15px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.media-picker-pagination button:hover:not(:disabled) {
background: #f5f5f5;
}
.media-picker-pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.media-picker-actions {
display: flex;
gap: 10px;
}
.btn-cancel, .btn-select {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-cancel {
background: #f0f0f0;
color: #333;
}
.btn-cancel:hover {
background: #e0e0e0;
}
.btn-select {
background: #4CAF50;
color: white;
}
.btn-select:hover:not(:disabled) {
background: #45a049;
}
.btn-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading, .error, .empty {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #999;
font-size: 14px;
}
.error {
color: #F44336;
}
/* 响应式设计 */
@media (max-width: 768px) {
.media-picker-container {
width: 95%;
height: 90%;
}
.media-files-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
}
.media-file-preview {
height: 80px;
}
.media-picker-toolbar {
flex-direction: column;
gap: 10px;
}
.media-picker-search input {
width: 100%;
}
}

@ -0,0 +1,750 @@
/*
*
* Bootstrap
*/
/* ==================== 断点定义 ==================== */
/*
xs: <576px ( - )
sm: 576px ( - )
md: 768px ( - )
lg: 992px ( - /)
xl: 1200px ( - )
xxl: 1400px ( - )
*/
/* ==================== 基础布局 ==================== */
/* 容器最大宽度 */
.container {
width: 100%;
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
@media (min-width: 576px) {
.container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container {
max-width: 720px;
}
}
@media (min-width: 992px) {
.container {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container {
max-width: 1140px;
}
}
@media (min-width: 1400px) {
.container {
max-width: 1320px;
}
}
/* 流式容器 */
.container-fluid {
width: 100%;
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
/* ==================== 导航栏 ==================== */
.navbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
}
.navbar-brand {
font-size: 1.25rem;
white-space: nowrap;
}
.navbar-toggler {
display: none;
padding: 0.25rem 0.75rem;
font-size: 1.25rem;
border: 1px solid var(--border-primary, #e0e0e0);
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
}
.navbar-collapse {
flex-grow: 1;
align-items: center;
}
.navbar-nav {
display: flex;
flex-direction: row;
list-style: none;
padding-left: 0;
margin-bottom: 0;
}
.nav-item {
margin: 0 10px;
}
/* 移动端导航 */
@media (max-width: 991px) {
.navbar-toggler {
display: block;
}
.navbar-collapse {
display: none;
width: 100%;
}
.navbar-collapse.show {
display: block;
}
.navbar-nav {
flex-direction: column;
width: 100%;
}
.nav-item {
margin: 5px 0;
width: 100%;
}
.nav-link {
display: block;
padding: 10px 15px;
}
}
/* ==================== 文章列表 ==================== */
.article-list {
display: grid;
gap: 20px;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.article-list.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 992px) {
.article-list.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
}
/* 文章卡片 */
.article-card {
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
}
.article-card-img {
width: 100%;
height: 200px;
object-fit: cover;
}
@media (max-width: 767px) {
.article-card-img {
height: 150px;
}
}
.article-card-body {
padding: 15px;
}
.article-card-title {
font-size: 1.25rem;
margin-bottom: 10px;
}
@media (max-width: 575px) {
.article-card-title {
font-size: 1.1rem;
}
}
/* ==================== 侧边栏布局 ==================== */
.main-content-wrapper {
display: grid;
gap: 30px;
grid-template-columns: 1fr;
}
@media (min-width: 992px) {
.main-content-wrapper.with-sidebar {
grid-template-columns: 1fr 300px;
}
.main-content-wrapper.sidebar-left {
grid-template-columns: 300px 1fr;
}
}
.sidebar {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 移动端侧边栏 */
@media (max-width: 991px) {
.sidebar {
order: -1; /* 移到内容上方 */
}
.sidebar-widget {
margin-bottom: 20px;
}
}
/* ==================== 表格响应式 ==================== */
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 767px) {
table {
font-size: 0.875rem;
}
table th,
table td {
padding: 8px 4px;
}
/* 隐藏不太重要的列 */
table .hide-mobile {
display: none;
}
}
/* ==================== 表单 ==================== */
.form-group {
margin-bottom: 15px;
}
.form-control {
display: block;
width: 100%;
padding: 8px 12px;
font-size: 1rem;
line-height: 1.5;
border: 1px solid var(--input-border, #d0d0d0);
border-radius: 4px;
}
@media (max-width: 575px) {
.form-control {
font-size: 16px; /* 防止iOS自动缩放 */
}
}
/* 按钮组响应式 */
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 575px) {
.btn-group {
flex-direction: column;
}
.btn-group .btn {
width: 100%;
}
}
/* ==================== 图片响应式 ==================== */
img {
max-width: 100%;
height: auto;
}
.img-responsive {
display: block;
max-width: 100%;
height: auto;
}
/* 图片网格 */
.image-grid {
display: grid;
gap: 15px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
@media (max-width: 767px) {
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
}
@media (max-width: 575px) {
.image-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ==================== 文字排版 ==================== */
/* 标题大小 */
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
@media (max-width: 767px) {
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
}
@media (max-width: 575px) {
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
}
/* 段落间距 */
p {
margin-bottom: 1rem;
line-height: 1.6;
}
@media (max-width: 575px) {
p {
line-height: 1.7;
}
}
/* ==================== 间距工具类 ==================== */
/* 移动端减少间距 */
@media (max-width: 767px) {
.mt-md-5 {
margin-top: 3rem !important;
}
.mb-md-5 {
margin-bottom: 3rem !important;
}
.p-md-5 {
padding: 2rem !important;
}
}
@media (max-width: 575px) {
.mt-sm-4 {
margin-top: 1.5rem !important;
}
.mb-sm-4 {
margin-bottom: 1.5rem !important;
}
.p-sm-4 {
padding: 1rem !important;
}
}
/* ==================== 显示/隐藏工具类 ==================== */
/* 移动端隐藏 */
@media (max-width: 575px) {
.hide-xs {
display: none !important;
}
}
@media (max-width: 767px) {
.hide-sm {
display: none !important;
}
}
@media (max-width: 991px) {
.hide-md {
display: none !important;
}
}
/* 移动端显示 */
.show-xs {
display: none !important;
}
@media (max-width: 575px) {
.show-xs {
display: block !important;
}
}
.show-sm {
display: none !important;
}
@media (max-width: 767px) {
.show-sm {
display: block !important;
}
}
/* ==================== 评论区 ==================== */
.comment-list {
list-style: none;
padding-left: 0;
}
.comment-item {
padding: 15px;
margin-bottom: 15px;
border-radius: 8px;
}
.comment-reply {
margin-left: 40px;
}
@media (max-width: 575px) {
.comment-reply {
margin-left: 20px;
}
.comment-item {
padding: 10px;
}
}
/* ==================== 分页 ==================== */
.pagination {
display: flex;
justify-content: center;
list-style: none;
padding: 0;
gap: 5px;
}
.pagination li {
display: inline-block;
}
.pagination a,
.pagination span {
display: block;
padding: 8px 12px;
border: 1px solid var(--border-primary, #e0e0e0);
border-radius: 4px;
}
@media (max-width: 575px) {
.pagination a,
.pagination span {
padding: 6px 8px;
font-size: 0.875rem;
}
.pagination .page-text {
display: none; /* 隐藏"上一页"/"下一页"文字,只显示箭头 */
}
}
/* ==================== 模态框 ==================== */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
.modal-dialog {
position: relative;
margin: 30px auto;
max-width: 500px;
padding: 0 15px;
}
@media (min-width: 576px) {
.modal-dialog {
max-width: 500px;
}
}
@media (min-width: 992px) {
.modal-dialog.modal-lg {
max-width: 800px;
}
}
@media (max-width: 575px) {
.modal-dialog {
margin: 10px auto;
max-width: 100%;
}
.modal-content {
border-radius: 0;
}
}
/* ==================== 搜索框 ==================== */
.search-form {
display: flex;
gap: 10px;
}
@media (max-width: 575px) {
.search-form {
flex-direction: column;
}
.search-form input {
width: 100%;
}
.search-form button {
width: 100%;
}
}
/* ==================== 标签云 ==================== */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
display: inline-block;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.875rem;
}
@media (max-width: 575px) {
.tag {
font-size: 0.75rem;
padding: 3px 8px;
}
}
/* ==================== 面包屑 ==================== */
.breadcrumb {
display: flex;
flex-wrap: wrap;
list-style: none;
padding: 10px 15px;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: "/";
padding: 0 8px;
}
@media (max-width: 575px) {
.breadcrumb {
font-size: 0.875rem;
padding: 8px 10px;
}
.breadcrumb-item + .breadcrumb-item::before {
padding: 0 4px;
}
}
/* ==================== 卡片 ==================== */
.card {
border-radius: 8px;
overflow: hidden;
}
.card-header {
padding: 15px;
border-bottom: 1px solid var(--border-primary, #e0e0e0);
}
.card-body {
padding: 15px;
}
.card-footer {
padding: 15px;
border-top: 1px solid var(--border-primary, #e0e0e0);
}
@media (max-width: 575px) {
.card-header,
.card-body,
.card-footer {
padding: 10px;
}
}
/* ==================== 页脚 ==================== */
footer {
padding: 40px 0 20px;
}
.footer-content {
display: grid;
gap: 30px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
@media (max-width: 767px) {
footer {
padding: 30px 0 15px;
}
.footer-content {
grid-template-columns: 1fr;
gap: 20px;
text-align: center;
}
}
/* ==================== 触摸优化 ==================== */
/* 增大移动端可点击区域 */
@media (max-width: 767px) {
a,
button,
.clickable {
min-height: 44px;
min-width: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-link,
.btn {
padding: 12px 20px;
}
}
/* ==================== 性能优化 ==================== */
/* GPU加速 */
.transform-gpu {
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
/* 减少重绘 */
img,
video {
will-change: transform;
}
/* ==================== 打印样式 ==================== */
@media print {
.no-print,
.navbar,
.sidebar,
.comments,
footer {
display: none !important;
}
.container {
max-width: 100%;
}
a {
text-decoration: underline;
}
a[href]:after {
content: " (" attr(href) ")";
}
}

@ -0,0 +1,876 @@
/*
* /
*
* FOUC
*/
/* ==================== CSS变量定义 ==================== */
/* 浅色主题(默认) */
:root {
/* 主要颜色 */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--bg-hover: #f0f0f0;
--bg-active: #e0e0e0;
/* 文字颜色 */
--text-primary: #212529;
--text-secondary: #6c757d;
--text-tertiary: #adb5bd;
--text-inverse: #ffffff;
--text-muted: #868e96;
/* 边框颜色 */
--border-primary: #dee2e6;
--border-secondary: #ced4da;
--border-focus: #4CAF50;
/* 强调色 */
--accent-primary: #4CAF50;
--accent-secondary: #2196F3;
--accent-warning: #FF9800;
--accent-danger: #F44336;
--accent-info: #00BCD4;
--accent-success: #4CAF50;
/* 阴影 */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 12px 36px rgba(0, 0, 0, 0.18);
/* 卡片 */
--card-bg: #ffffff;
--card-border: #dee2e6;
/* 代码块 */
--code-bg: #f8f9fa;
--code-text: #e83e8c;
--code-block-bg: #2d2d2d;
--code-block-text: #f8f8f2;
/* 输入框 */
--input-bg: #ffffff;
--input-border: #ced4da;
--input-text: #495057;
--input-placeholder: #6c757d;
--input-disabled-bg: #e9ecef;
/* 导航栏 */
--nav-bg: #ffffff;
--nav-text: #495057;
--nav-hover-bg: #f8f9fa;
--nav-border: #dee2e6;
/* 侧边栏 */
--sidebar-bg: #f8f9fa;
--sidebar-border: #dee2e6;
/* 页脚 */
--footer-bg: #2c3e50;
--footer-text: #ecf0f1;
/* 链接 */
--link-color: #2196F3;
--link-hover: #1976D2;
--link-visited: #9C27B0;
/* 文章内容 */
--article-bg: #ffffff;
--article-border: #dee2e6;
/* 评论区 */
--comment-bg: #ffffff;
--comment-border: #dee2e6;
--comment-author-bg: #e7f3ff;
/* 其他 */
--overlay-bg: rgba(0, 0, 0, 0.5);
--scrollbar-bg: #f1f3f5;
--scrollbar-thumb: #adb5bd;
--scrollbar-thumb-hover: #868e96;
/* 图片 */
--img-opacity: 1;
--img-brightness: 1;
}
/* 深色主题 */
[data-theme="dark"] {
/* 主要颜色 */
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-tertiary: #2d2d2d;
--bg-hover: #252525;
--bg-active: #333333;
/* 文字颜色 */
--text-primary: #e4e6eb;
--text-secondary: #b0b3b8;
--text-tertiary: #8a8d91;
--text-inverse: #121212;
--text-muted: #9ca3af;
/* 边框颜色 */
--border-primary: #3a3a3a;
--border-secondary: #4a4a4a;
--border-focus: #66BB6A;
/* 强调色 */
--accent-primary: #66BB6A;
--accent-secondary: #42A5F5;
--accent-warning: #FFA726;
--accent-danger: #EF5350;
--accent-info: #26C6DA;
--accent-success: #66BB6A;
/* 阴影 */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
--shadow-xl: 0 12px 36px rgba(0, 0, 0, 0.7);
/* 卡片 */
--card-bg: #1e1e1e;
--card-border: #3a3a3a;
/* 代码块 */
--code-bg: #2d2d2d;
--code-text: #ff79c6;
--code-block-bg: #1a1a1a;
--code-block-text: #e6e6e6;
/* 输入框 */
--input-bg: #2d2d2d;
--input-border: #4a4a4a;
--input-text: #e4e6eb;
--input-placeholder: #8a8d91;
--input-disabled-bg: #252525;
/* 导航栏 */
--nav-bg: #1e1e1e;
--nav-text: #e4e6eb;
--nav-hover-bg: #252525;
--nav-border: #3a3a3a;
/* 侧边栏 */
--sidebar-bg: #1e1e1e;
--sidebar-border: #3a3a3a;
/* 页脚 */
--footer-bg: #1a1a1a;
--footer-text: #b0b3b8;
/* 链接 */
--link-color: #58a6ff;
--link-hover: #79c0ff;
--link-visited: #bc8cff;
/* 文章内容 */
--article-bg: #1e1e1e;
--article-border: #3a3a3a;
/* 评论区 */
--comment-bg: #1e1e1e;
--comment-border: #3a3a3a;
--comment-author-bg: #1a3a52;
/* 其他 */
--overlay-bg: rgba(0, 0, 0, 0.75);
--scrollbar-bg: #1e1e1e;
--scrollbar-thumb: #4a4a4a;
--scrollbar-thumb-hover: #5a5a5a;
/* 图片 */
--img-opacity: 0.85;
--img-brightness: 0.9;
}
/* ==================== 过渡动画 ==================== */
/* 平滑过渡 - 应用于根元素 */
html.theme-transitioning,
html.theme-transitioning *,
html.theme-transitioning *::before,
html.theme-transitioning *::after {
transition: background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
/* ==================== 基础样式应用 ==================== */
html {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
/* 链接 */
a {
color: var(--link-color);
transition: color 0.2s ease;
}
a:hover {
color: var(--link-hover);
}
a:visited {
color: var(--link-visited);
}
/* ==================== 布局元素 ==================== */
/* 容器 */
.container,
.container-fluid {
background-color: var(--bg-primary);
}
/* 行和列 */
.row {
color: var(--text-primary);
}
/* ==================== 卡片组件 ==================== */
.card,
.article-card,
.comment-card,
.panel,
.panel-default,
.well {
background-color: var(--card-bg);
border-color: var(--card-border);
box-shadow: var(--shadow-sm);
color: var(--text-primary);
}
.card:hover,
.article-card:hover {
box-shadow: var(--shadow-md);
}
.card-header,
.card-footer,
.panel-heading,
.panel-footer {
background-color: var(--bg-secondary);
border-color: var(--border-primary);
color: var(--text-primary);
}
.card-body,
.panel-body {
background-color: var(--card-bg);
color: var(--text-primary);
}
/* ==================== 代码样式 ==================== */
code {
background-color: var(--code-bg);
color: var(--code-text);
padding: 2px 6px;
border-radius: 4px;
}
pre {
background-color: var(--code-block-bg);
color: var(--code-block-text);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
border: 1px solid var(--border-primary);
}
pre code {
background-color: transparent;
color: inherit;
padding: 0;
}
/* ==================== 表单元素 ==================== */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="number"],
input[type="url"],
input[type="tel"],
input[type="date"],
textarea,
select,
.form-control {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--input-text);
transition: all 0.2s ease;
}
input::placeholder,
textarea::placeholder {
color: var(--input-placeholder);
}
input:focus,
textarea:focus,
select:focus,
.form-control:focus {
border-color: var(--border-focus);
outline: none;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
input:disabled,
textarea:disabled,
select:disabled,
.form-control:disabled {
background-color: var(--input-disabled-bg);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* ==================== 按钮 ==================== */
.btn {
transition: all 0.2s ease;
}
.btn-primary {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--text-inverse);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background-color: var(--accent-secondary);
border-color: var(--accent-secondary);
color: var(--text-inverse);
}
.btn-default,
.btn-outline {
background-color: var(--bg-secondary);
border-color: var(--border-primary);
color: var(--text-primary);
}
.btn-default:hover,
.btn-outline:hover {
background-color: var(--bg-hover);
}
/* ==================== 导航栏 ==================== */
.navbar,
.nav,
nav,
header {
background-color: var(--nav-bg);
color: var(--nav-text);
border-color: var(--nav-border);
}
.nav-link,
.navbar-nav a,
.menu-item a {
color: var(--nav-text);
transition: background-color 0.2s ease;
}
.nav-link:hover,
.navbar-nav a:hover,
.menu-item a:hover {
background-color: var(--nav-hover-bg);
color: var(--link-hover);
}
.navbar-brand {
color: var(--nav-text) !important;
}
/* ==================== 侧边栏 ==================== */
.sidebar,
.widget,
aside {
background-color: var(--sidebar-bg);
border-color: var(--sidebar-border);
color: var(--text-primary);
}
.sidebar-title,
.widget-title {
color: var(--text-primary);
border-bottom-color: var(--border-primary);
}
/* ==================== 页脚 ==================== */
footer,
.footer,
#footer {
background-color: var(--footer-bg);
color: var(--footer-text);
}
footer a,
.footer a {
color: var(--footer-text);
opacity: 0.8;
}
footer a:hover,
.footer a:hover {
opacity: 1;
}
/* ==================== 文章内容 ==================== */
.article,
.entry-content,
.post-content,
article {
background-color: var(--article-bg);
color: var(--text-primary);
}
.entry-title,
.post-title,
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
}
.entry-meta,
.post-meta {
color: var(--text-secondary);
}
/* ==================== 评论区 ==================== */
.comment,
.comment-body {
background-color: var(--comment-bg);
border-color: var(--comment-border);
color: var(--text-primary);
}
.comment-author {
color: var(--text-primary);
background-color: var(--comment-author-bg);
}
.comment-reply-link {
color: var(--link-color);
}
/* ==================== 边框和分隔线 ==================== */
.border,
hr {
border-color: var(--border-primary);
}
/* ==================== 表格 ==================== */
table {
border-color: var(--border-primary);
color: var(--text-primary);
}
table th {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-primary);
}
table td {
border-color: var(--border-primary);
background-color: var(--bg-primary);
}
table tr:hover td {
background-color: var(--bg-hover);
}
/* ==================== 引用 ==================== */
blockquote {
border-left: 4px solid var(--accent-primary);
background-color: var(--bg-secondary);
color: var(--text-secondary);
padding: 12px 20px;
margin: 16px 0;
}
/* ==================== 标签和徽章 ==================== */
.tag,
.badge,
.label {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
/* ==================== 提示框 ==================== */
.alert {
border-color: var(--border-primary);
}
.alert-success {
background-color: rgba(76, 175, 80, 0.1);
border-color: var(--accent-success);
color: var(--accent-success);
}
.alert-info {
background-color: rgba(33, 150, 243, 0.1);
border-color: var(--accent-info);
color: var(--accent-info);
}
.alert-warning {
background-color: rgba(255, 152, 0, 0.1);
border-color: var(--accent-warning);
color: var(--accent-warning);
}
.alert-danger {
background-color: rgba(244, 67, 54, 0.1);
border-color: var(--accent-danger);
color: var(--accent-danger);
}
/* ==================== 模态框 ==================== */
.modal-content {
background-color: var(--card-bg);
color: var(--text-primary);
}
.modal-header,
.modal-footer {
background-color: var(--bg-secondary);
border-color: var(--border-primary);
}
/* ==================== 下拉菜单 ==================== */
.dropdown-menu {
background-color: var(--card-bg);
border-color: var(--border-primary);
box-shadow: var(--shadow-md);
}
.dropdown-item {
color: var(--text-primary);
}
.dropdown-item:hover {
background-color: var(--bg-hover);
}
/* ==================== 分页 ==================== */
.pagination .page-link {
background-color: var(--card-bg);
border-color: var(--border-primary);
color: var(--link-color);
}
.pagination .page-link:hover {
background-color: var(--bg-hover);
}
.pagination .page-item.active .page-link {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
/* ==================== 面包屑 ==================== */
.breadcrumb {
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
.breadcrumb-item.active {
color: var(--text-primary);
}
/* ==================== 滚动条 ==================== */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-bg);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Firefox 滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg);
}
/* ==================== 图片优化 ==================== */
[data-theme="dark"] img:not([src*=".svg"]) {
opacity: var(--img-opacity);
filter: brightness(var(--img-brightness));
transition: opacity 0.3s ease, filter 0.3s ease;
}
[data-theme="dark"] img:hover {
opacity: 1;
filter: brightness(1);
}
/* SVG 图标在深色模式下的处理 */
[data-theme="dark"] svg {
filter: invert(0.9) hue-rotate(180deg);
}
[data-theme="dark"] .logo svg,
[data-theme="dark"] .icon svg {
filter: none;
}
/* ==================== 主题切换按钮 ==================== */
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
color: var(--text-inverse);
border: none;
box-shadow: var(--shadow-lg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.theme-toggle::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.1) rotate(15deg);
box-shadow: var(--shadow-xl);
}
.theme-toggle:hover::before {
opacity: 1;
}
.theme-toggle:active,
.theme-toggle-clicked {
transform: scale(0.95) rotate(-15deg);
}
/* 主题切换图标 */
.theme-toggle .icon-sun,
.theme-toggle .icon-moon {
position: absolute;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.theme-toggle .icon-sun {
opacity: 0;
transform: rotate(-90deg) scale(0.5);
}
.theme-toggle .icon-moon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
[data-theme="dark"] .theme-toggle .icon-sun {
opacity: 1;
transform: rotate(0deg) scale(1);
}
[data-theme="dark"] .theme-toggle .icon-moon {
opacity: 0;
transform: rotate(90deg) scale(0.5);
}
/* 按钮上的光晕效果 */
@keyframes pulse {
0%, 100% {
box-shadow: var(--shadow-lg), 0 0 0 0 rgba(76, 175, 80, 0.7);
}
50% {
box-shadow: var(--shadow-lg), 0 0 0 10px rgba(76, 175, 80, 0);
}
}
.theme-toggle-clicked {
animation: pulse 0.6s ease-out;
}
/* Auto模式特殊标识 */
.theme-toggle.theme-auto::after {
content: 'A';
position: absolute;
bottom: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(255, 255, 255, 0.9);
color: var(--accent-primary);
border-radius: 50%;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Auto模式下按钮有渐变边框效果 */
.theme-toggle.theme-auto {
box-shadow: var(--shadow-lg), 0 0 0 2px rgba(255, 215, 0, 0.6);
}
/* Auto模式下的动画 */
@keyframes autoGlow {
0%, 100% {
box-shadow: var(--shadow-lg), 0 0 0 2px rgba(255, 215, 0, 0.4);
}
50% {
box-shadow: var(--shadow-lg), 0 0 0 2px rgba(255, 215, 0, 0.8);
}
}
.theme-toggle.theme-auto {
animation: autoGlow 2s ease-in-out infinite;
}
/* ==================== 响应式 ==================== */
@media (max-width: 768px) {
.theme-toggle {
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
}
}
@media (max-width: 480px) {
.theme-toggle {
bottom: 16px;
right: 16px;
width: 46px;
height: 46px;
}
}
/* ==================== 打印样式 ==================== */
@media print {
[data-theme="dark"] {
/* 打印时强制使用浅色主题 */
color-scheme: light;
--bg-primary: #ffffff;
--text-primary: #000000;
--card-bg: #ffffff;
}
.theme-toggle {
display: none !important;
}
[data-theme="dark"] img {
opacity: 1;
filter: none;
}
}
/* ==================== 辅助类 ==================== */
.bg-primary { background-color: var(--bg-primary); }
.bg-secondary { background-color: var(--bg-secondary); }
.bg-tertiary { background-color: var(--bg-tertiary); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-muted { color: var(--text-muted); }
.border-primary { border-color: var(--border-primary); }
/* ==================== 可访问性 ==================== */
/* 减少动画(用户偏好) */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.theme-toggle {
transition: none;
}
}
/* 高对比度模式 */
@media (prefers-contrast: high) {
:root {
--border-primary: #000000;
--text-primary: #000000;
}
[data-theme="dark"] {
--border-primary: #ffffff;
--text-primary: #ffffff;
}
}

@ -0,0 +1,321 @@
/**
* 文章草稿自动保存功能
* 定时自动保存编辑中的文章防止内容丢失
*/
(function() {
'use strict';
// 配置
const CONFIG = {
AUTO_SAVE_INTERVAL: 30000, // 自动保存间隔30秒
MIN_CONTENT_LENGTH: 10, // 触发自动保存的最小内容长度
SESSION_STORAGE_KEY: 'article_draft_session_id',
LAST_CONTENT_KEY: 'article_draft_last_content'
};
// 生成唯一的会话ID
function generateSessionId() {
return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
// 获取或创建会话ID
function getSessionId() {
let sessionId = sessionStorage.getItem(CONFIG.SESSION_STORAGE_KEY);
if (!sessionId) {
sessionId = generateSessionId();
sessionStorage.setItem(CONFIG.SESSION_STORAGE_KEY, sessionId);
}
return sessionId;
}
// 获取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;
}
// 文章草稿自动保存类
class ArticleDraftAutoSave {
constructor(options) {
this.options = Object.assign({
titleSelector: '#id_title',
bodySelector: '#id_body',
categorySelector: '#id_category',
tagsSelector: '#id_tags',
statusSelector: '#id_status',
commentStatusSelector: '#id_comment_status',
typeSelector: '#id_type',
articleIdInput: '#article_id',
saveApiUrl: '/api/draft/save/',
getDraftApiUrl: '/api/draft/get/',
statusElement: '#draft-save-status'
}, options);
this.sessionId = getSessionId();
this.saveTimer = null;
this.lastContent = '';
this.isInit = false;
this.init();
}
init() {
if (this.isInit) return;
this.isInit = true;
// 检查是否在文章编辑页面
const titleElement = document.querySelector(this.options.titleSelector);
const bodyElement = document.querySelector(this.options.bodySelector);
if (!titleElement || !bodyElement) {
console.log('未找到文章编辑器元素,自动保存功能未启动');
return;
}
console.log('文章草稿自动保存功能已启动');
// 检查是否有未保存的草稿
this.checkExistingDraft();
// 启动自动保存
this.startAutoSave();
// 监听页面离开事件
this.setupBeforeUnloadHandler();
// 创建状态显示元素
this.createStatusElement();
}
// 检查是否有未保存的草稿
checkExistingDraft() {
const articleId = this.getArticleId();
const url = `${this.options.getDraftApiUrl}?${articleId ? 'article_id=' + articleId : 'session_id=' + this.sessionId}`;
fetch(url, {
method: 'GET',
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success && data.draft) {
this.showDraftRestorePrompt(data.draft);
}
})
.catch(error => {
console.error('检查草稿失败:', error);
});
}
// 显示草稿恢复提示
showDraftRestorePrompt(draft) {
const message = `发现未保存的草稿(最后更新: ${draft.last_update}),是否恢复?`;
if (confirm(message)) {
this.restoreDraft(draft);
}
}
// 恢复草稿
restoreDraft(draft) {
const titleElement = document.querySelector(this.options.titleSelector);
const bodyElement = document.querySelector(this.options.bodySelector);
const categoryElement = document.querySelector(this.options.categorySelector);
if (titleElement) titleElement.value = draft.title || '';
if (bodyElement) bodyElement.value = draft.body || '';
if (categoryElement && draft.category_id) {
categoryElement.value = draft.category_id;
}
this.showStatus('草稿已恢复', 'success');
}
// 启动自动保存
startAutoSave() {
this.saveTimer = setInterval(() => {
this.autoSave();
}, CONFIG.AUTO_SAVE_INTERVAL);
console.log(`自动保存已启动,间隔: ${CONFIG.AUTO_SAVE_INTERVAL / 1000}`);
}
// 停止自动保存
stopAutoSave() {
if (this.saveTimer) {
clearInterval(this.saveTimer);
this.saveTimer = null;
console.log('自动保存已停止');
}
}
// 执行自动保存
autoSave() {
const titleElement = document.querySelector(this.options.titleSelector);
const bodyElement = document.querySelector(this.options.bodySelector);
if (!titleElement || !bodyElement) return;
const title = titleElement.value || '';
const body = bodyElement.value || '';
const currentContent = title + body;
// 检查内容是否有变化且长度足够
if (currentContent === this.lastContent || currentContent.length < CONFIG.MIN_CONTENT_LENGTH) {
return;
}
this.saveDraft();
}
// 保存草稿
saveDraft() {
const titleElement = document.querySelector(this.options.titleSelector);
const bodyElement = document.querySelector(this.options.bodySelector);
const categoryElement = document.querySelector(this.options.categorySelector);
const tagsElement = document.querySelector(this.options.tagsSelector);
if (!titleElement || !bodyElement) return;
const title = titleElement.value || '';
const body = bodyElement.value || '';
const category_id = categoryElement ? categoryElement.value : null;
const article_id = this.getArticleId();
// 获取标签
let tags = [];
if (tagsElement) {
const selectedOptions = tagsElement.selectedOptions;
for (let i = 0; i < selectedOptions.length; i++) {
tags.push(parseInt(selectedOptions[i].value));
}
}
const data = {
title: title,
body: body,
category_id: category_id ? parseInt(category_id) : null,
tags: tags,
article_id: article_id ? parseInt(article_id) : null,
session_id: this.sessionId,
status: 'd',
comment_status: 'o',
type: 'a'
};
this.showStatus('正在保存...', 'info');
fetch(this.options.saveApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
credentials: 'same-origin',
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.lastContent = title + body;
this.showStatus(`已自动保存 (${data.last_update})`, 'success');
console.log('草稿已保存:', data);
} else {
this.showStatus('保存失败', 'error');
console.error('保存草稿失败:', data.message);
}
})
.catch(error => {
this.showStatus('保存失败', 'error');
console.error('保存草稿失败:', error);
});
}
// 获取文章ID
getArticleId() {
const articleIdInput = document.querySelector(this.options.articleIdInput);
if (articleIdInput) {
return articleIdInput.value;
}
// 尝试从URL获取
const match = window.location.pathname.match(/\/article\/\d+\/\d+\/\d+\/(\d+)\.html/);
if (match) {
return match[1];
}
return null;
}
// 创建状态显示元素
createStatusElement() {
let statusElement = document.querySelector(this.options.statusElement);
if (!statusElement) {
statusElement = document.createElement('div');
statusElement.id = this.options.statusElement.replace('#', '');
statusElement.style.cssText = 'position: fixed; bottom: 20px; right: 20px; padding: 10px 20px; ' +
'background: #f0f0f0; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); ' +
'font-size: 14px; z-index: 9999; display: none;';
document.body.appendChild(statusElement);
}
}
// 显示状态
showStatus(message, type) {
const statusElement = document.querySelector(this.options.statusElement);
if (!statusElement) return;
const colors = {
'info': '#2196F3',
'success': '#4CAF50',
'error': '#F44336'
};
statusElement.textContent = message;
statusElement.style.background = colors[type] || '#f0f0f0';
statusElement.style.color = type === 'info' ? '#333' : '#fff';
statusElement.style.display = 'block';
setTimeout(() => {
statusElement.style.display = 'none';
}, 3000);
}
// 设置页面离开前的警告
setupBeforeUnloadHandler() {
window.addEventListener('beforeunload', (e) => {
const titleElement = document.querySelector(this.options.titleSelector);
const bodyElement = document.querySelector(this.options.bodySelector);
if (titleElement && bodyElement) {
const content = titleElement.value + bodyElement.value;
if (content.length > CONFIG.MIN_CONTENT_LENGTH && content !== this.lastContent) {
e.preventDefault();
e.returnValue = '您有未保存的更改,确定要离开吗?';
return e.returnValue;
}
}
});
}
}
// 自动初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
window.articleDraftAutoSave = new ArticleDraftAutoSave();
});
} else {
window.articleDraftAutoSave = new ArticleDraftAutoSave();
}
})();

@ -0,0 +1,375 @@
/**
* 多媒体管理系统 - 图片选择器
* 提供图片上传选择和管理功能
*/
(function() {
'use strict';
// 获取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;
}
// 图片选择器类
class MediaPicker {
constructor(options = {}) {
this.options = {
multiple: false, // 是否允许多选
fileType: 'image', // 文件类型image/file/all
onSelect: null, // 选择回调
maxSize: 10, // 最大文件大小MB
...options
};
this.csrfToken = getCookie('csrftoken');
this.selectedFiles = [];
this.currentPage = 1;
this.totalPages = 1;
this.init();
}
init() {
this.createModal();
this.loadFiles();
}
createModal() {
// 创建模态框HTML
const modalHTML = `
<div id="media-picker-modal" class="media-picker-modal" style="display: none;">
<div class="media-picker-overlay"></div>
<div class="media-picker-container">
<div class="media-picker-header">
<h3>选择${this.options.fileType === 'image' ? '图片' : '文件'}</h3>
<button class="media-picker-close">&times;</button>
</div>
<div class="media-picker-toolbar">
<div class="media-picker-search">
<input type="text" id="media-search-input" placeholder="搜索文件...">
</div>
<div class="media-picker-upload">
<label for="media-file-input" class="btn-upload">
上传文件
</label>
<input type="file" id="media-file-input" ${this.options.multiple ? 'multiple' : ''}
accept="${this.getAcceptTypes()}" style="display: none;">
</div>
</div>
<div class="media-picker-content">
<div id="media-files-grid" class="media-files-grid">
<div class="loading">加载中...</div>
</div>
</div>
<div class="media-picker-footer">
<div class="media-picker-pagination">
<button id="prev-page" disabled>上一页</button>
<span id="page-info"> 1 </span>
<button id="next-page">下一页</button>
</div>
<div class="media-picker-actions">
<button id="btn-cancel" class="btn-cancel">取消</button>
<button id="btn-select" class="btn-select" disabled>选择</button>
</div>
</div>
</div>
</div>
`;
// 添加到body
document.body.insertAdjacentHTML('beforeend', modalHTML);
// 绑定事件
this.bindEvents();
}
getAcceptTypes() {
if (this.options.fileType === 'image') {
return 'image/*';
} else if (this.options.fileType === 'file') {
return '*/*';
}
return '*/*';
}
bindEvents() {
const modal = document.getElementById('media-picker-modal');
// 关闭按钮
modal.querySelector('.media-picker-close').addEventListener('click', () => {
this.close();
});
// 点击遮罩关闭
modal.querySelector('.media-picker-overlay').addEventListener('click', () => {
this.close();
});
// 取消按钮
modal.querySelector('#btn-cancel').addEventListener('click', () => {
this.close();
});
// 选择按钮
modal.querySelector('#btn-select').addEventListener('click', () => {
this.selectFiles();
});
// 文件上传
modal.querySelector('#media-file-input').addEventListener('change', (e) => {
this.uploadFiles(e.target.files);
});
// 搜索
let searchTimeout;
modal.querySelector('#media-search-input').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.currentPage = 1;
this.loadFiles(e.target.value);
}, 500);
});
// 分页
modal.querySelector('#prev-page').addEventListener('click', () => {
if (this.currentPage > 1) {
this.currentPage--;
this.loadFiles();
}
});
modal.querySelector('#next-page').addEventListener('click', () => {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.loadFiles();
}
});
}
async loadFiles(search = '') {
const grid = document.getElementById('media-files-grid');
grid.innerHTML = '<div class="loading">加载中...</div>';
try {
const params = new URLSearchParams({
page: this.currentPage,
page_size: 20
});
if (this.options.fileType !== 'all') {
params.append('file_type', this.options.fileType);
}
if (search) {
params.append('search', search);
}
const response = await fetch(`/blog/api/media/list/?${params}`);
const data = await response.json();
if (data.success) {
this.displayFiles(data.files);
this.totalPages = data.total_pages;
this.updatePagination();
} else {
grid.innerHTML = '<div class="error">加载失败</div>';
}
} catch (error) {
console.error('加载文件列表失败:', error);
grid.innerHTML = '<div class="error">加载失败</div>';
}
}
displayFiles(files) {
const grid = document.getElementById('media-files-grid');
if (files.length === 0) {
grid.innerHTML = '<div class="empty">暂无文件</div>';
return;
}
grid.innerHTML = files.map(file => {
const isSelected = this.selectedFiles.some(f => f.id === file.id);
return `
<div class="media-file-item ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<div class="media-file-preview">
${file.file_type === 'image'
? `<img src="${file.thumbnail_url}" alt="${file.original_filename}">`
: '<div class="file-icon">📄</div>'}
</div>
<div class="media-file-info">
<div class="media-file-name" title="${file.original_filename}">
${file.original_filename}
</div>
<div class="media-file-meta">
${file.file_size_readable}
</div>
</div>
<div class="media-file-check">
<span class="checkmark"></span>
</div>
</div>
`;
}).join('');
// 绑定点击事件
grid.querySelectorAll('.media-file-item').forEach(item => {
item.addEventListener('click', () => {
const fileId = parseInt(item.dataset.fileId);
const file = files.find(f => f.id === fileId);
this.toggleFileSelection(file, item);
});
});
}
toggleFileSelection(file, element) {
const index = this.selectedFiles.findIndex(f => f.id === file.id);
if (index > -1) {
// 取消选择
this.selectedFiles.splice(index, 1);
element.classList.remove('selected');
} else {
// 选择
if (!this.options.multiple) {
// 单选模式,清除之前的选择
this.selectedFiles = [file];
document.querySelectorAll('.media-file-item.selected').forEach(el => {
el.classList.remove('selected');
});
element.classList.add('selected');
} else {
// 多选模式
this.selectedFiles.push(file);
element.classList.add('selected');
}
}
// 更新选择按钮状态
const btnSelect = document.getElementById('btn-select');
btnSelect.disabled = this.selectedFiles.length === 0;
}
async uploadFiles(files) {
if (!files || files.length === 0) return;
const maxSize = this.options.maxSize * 1024 * 1024;
for (let file of files) {
if (file.size > maxSize) {
alert(`文件 ${file.name} 超过大小限制(${this.options.maxSize}MB`);
continue;
}
await this.uploadSingleFile(file);
}
// 重新加载文件列表
this.loadFiles();
// 清空文件输入
document.getElementById('media-file-input').value = '';
}
async uploadSingleFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('is_public', 'true');
try {
const response = await fetch('/blog/api/media/upload/', {
method: 'POST',
headers: {
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: formData
});
const data = await response.json();
if (data.success) {
this.showMessage('上传成功', 'success');
} else {
this.showMessage(data.message || '上传失败', 'error');
}
} catch (error) {
console.error('上传失败:', error);
this.showMessage('上传失败', 'error');
}
}
selectFiles() {
if (this.options.onSelect && typeof this.options.onSelect === 'function') {
this.options.onSelect(this.selectedFiles);
}
this.close();
}
updatePagination() {
const prevBtn = document.getElementById('prev-page');
const nextBtn = document.getElementById('next-page');
const pageInfo = document.getElementById('page-info');
prevBtn.disabled = this.currentPage === 1;
nextBtn.disabled = this.currentPage >= this.totalPages;
pageInfo.textContent = `${this.currentPage} / ${this.totalPages}`;
}
showMessage(message, type) {
// 简单的消息提示
const messageDiv = document.createElement('div');
messageDiv.className = `media-message media-message-${type}`;
messageDiv.textContent = message;
messageDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
background: ${type === 'success' ? '#4CAF50' : '#F44336'};
color: white;
border-radius: 5px;
z-index: 10001;
`;
document.body.appendChild(messageDiv);
setTimeout(() => {
messageDiv.remove();
}, 3000);
}
open() {
const modal = document.getElementById('media-picker-modal');
modal.style.display = 'block';
this.selectedFiles = [];
this.currentPage = 1;
this.loadFiles();
}
close() {
const modal = document.getElementById('media-picker-modal');
modal.style.display = 'none';
this.selectedFiles = [];
}
}
// 导出到全局
window.MediaPicker = MediaPicker;
})();

@ -0,0 +1,330 @@
/**
* 用户关注和文章收藏功能
* 提供关注/取消关注收藏/取消收藏的前端交互
*/
(function() {
'use strict';
// 获取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;
}
// 社交功能类
class SocialFeatures {
constructor() {
this.csrfToken = getCookie('csrftoken');
this.init();
}
init() {
// 绑定关注按钮
this.bindFollowButtons();
// 绑定收藏按钮
this.bindFavoriteButtons();
// 绑定点赞按钮
this.bindLikeButtons();
}
// ==================== 关注功能 ====================
bindFollowButtons() {
const followButtons = document.querySelectorAll('.btn-follow');
followButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const userId = button.dataset.userId;
const isFollowing = button.dataset.following === 'true';
if (isFollowing) {
this.unfollowUser(userId, button);
} else {
this.followUser(userId, button);
}
});
});
}
followUser(userId, button) {
fetch('/blog/api/follow/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ user_id: userId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
button.textContent = '已关注';
button.dataset.following = 'true';
button.classList.remove('btn-primary');
button.classList.add('btn-secondary');
this.showMessage(data.message, 'success');
this.updateFollowCount(data.following_count);
} else {
this.showMessage(data.message, 'error');
}
})
.catch(error => {
console.error('关注失败:', error);
this.showMessage('关注失败,请重试', 'error');
});
}
unfollowUser(userId, button) {
fetch('/blog/api/unfollow/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ user_id: userId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
button.textContent = '关注';
button.dataset.following = 'false';
button.classList.remove('btn-secondary');
button.classList.add('btn-primary');
this.showMessage(data.message, 'success');
this.updateFollowCount(data.following_count);
} else {
this.showMessage(data.message, 'error');
}
})
.catch(error => {
console.error('取消关注失败:', error);
this.showMessage('取消关注失败,请重试', 'error');
});
}
updateFollowCount(count) {
const countElement = document.querySelector('#following-count');
if (countElement) {
countElement.textContent = count;
}
}
// ==================== 收藏功能 ====================
bindFavoriteButtons() {
const favoriteButtons = document.querySelectorAll('.btn-favorite');
favoriteButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const articleId = button.dataset.articleId;
const isFavorited = button.dataset.favorited === 'true';
if (isFavorited) {
this.unfavoriteArticle(articleId, button);
} else {
this.favoriteArticle(articleId, button);
}
});
});
}
favoriteArticle(articleId, button) {
fetch('/blog/api/favorite/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ article_id: articleId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
button.innerHTML = '★ 已收藏';
button.dataset.favorited = 'true';
button.classList.add('favorited');
this.showMessage(data.message, 'success');
this.updateFavoriteCount(data.favorite_count);
} else {
this.showMessage(data.message, 'error');
}
})
.catch(error => {
console.error('收藏失败:', error);
this.showMessage('收藏失败,请重试', 'error');
});
}
unfavoriteArticle(articleId, button) {
fetch('/blog/api/unfavorite/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ article_id: articleId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
button.innerHTML = '☆ 收藏';
button.dataset.favorited = 'false';
button.classList.remove('favorited');
this.showMessage(data.message, 'success');
this.updateFavoriteCount(data.favorite_count);
} else {
this.showMessage(data.message, 'error');
}
})
.catch(error => {
console.error('取消收藏失败:', error);
this.showMessage('取消收藏失败,请重试', 'error');
});
}
updateFavoriteCount(count) {
const countElement = document.querySelector('#favorite-count');
if (countElement) {
countElement.textContent = count;
}
}
// ==================== 点赞功能 ====================
bindLikeButtons() {
const likeButtons = document.querySelectorAll('.btn-like');
likeButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const articleId = button.dataset.articleId;
const isLiked = button.dataset.liked === 'true';
if (isLiked) {
this.unlikeArticle(articleId, button);
} else {
this.likeArticle(articleId, button);
}
});
});
}
likeArticle(articleId, button) {
fetch('/blog/api/like/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ article_id: articleId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
button.innerHTML = '👍 ' + data.like_count;
button.dataset.liked = 'true';
button.classList.add('liked');
this.showMessage(data.message, 'success');
this.updateLikeCount(data.like_count, button);
} else {
this.showMessage(data.message, 'error');
}
})
.catch(error => {
console.error('点赞失败:', error);
this.showMessage('点赞失败,请重试', 'error');
});
}
unlikeArticle(articleId, button) {
fetch('/blog/api/unlike/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ article_id: articleId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
button.innerHTML = '👍 ' + data.like_count;
button.dataset.liked = 'false';
button.classList.remove('liked');
this.showMessage(data.message, 'success');
this.updateLikeCount(data.like_count, button);
} else {
this.showMessage(data.message, 'error');
}
})
.catch(error => {
console.error('取消点赞失败:', error);
this.showMessage('取消点赞失败,请重试', 'error');
});
}
updateLikeCount(count, button) {
// 更新按钮显示的点赞数
const countSpan = button.querySelector('.like-count');
if (countSpan) {
countSpan.textContent = count;
}
}
// ==================== 工具方法 ====================
showMessage(message, type) {
// 创建或获取消息提示元素
let messageBox = document.querySelector('#social-message');
if (!messageBox) {
messageBox = document.createElement('div');
messageBox.id = 'social-message';
messageBox.style.cssText = 'position: fixed; top: 20px; right: 20px; padding: 15px 20px; ' +
'border-radius: 5px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); ' +
'font-size: 14px; z-index: 9999; display: none;';
document.body.appendChild(messageBox);
}
const colors = {
'success': '#4CAF50',
'error': '#F44336',
'info': '#2196F3'
};
messageBox.textContent = message;
messageBox.style.background = colors[type] || '#f0f0f0';
messageBox.style.color = '#fff';
messageBox.style.display = 'block';
setTimeout(() => {
messageBox.style.display = 'none';
}, 3000);
}
}
// 自动初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
window.socialFeatures = new SocialFeatures();
});
} else {
window.socialFeatures = new SocialFeatures();
}
})();

@ -0,0 +1,321 @@
/**
* 深色主题切换功能
* 支持自动检测系统主题手动切换本地存储
* 优化版本防止FOUC更好的动画完整的适配
*/
(function() {
'use strict';
// 主题管理器类
class ThemeManager {
constructor() {
this.THEME_KEY = 'blog-theme';
this.THEMES = {
LIGHT: 'light',
DARK: 'dark',
AUTO: 'auto'
};
this.currentTheme = null;
this.systemTheme = null;
this.isInitialized = false;
this.init();
}
init() {
if (this.isInitialized) return;
this.isInitialized = true;
// 检测系统主题偏好
this.detectSystemTheme();
// 加载保存的主题
this.loadTheme();
// 立即应用主题(无动画,防止闪烁)
this.applyTheme(false);
// 等待DOM加载完成后创建按钮
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.createToggleButton();
this.watchSystemTheme();
});
} else {
this.createToggleButton();
this.watchSystemTheme();
}
}
/**
* 检测系统主题偏好
*/
detectSystemTheme() {
if (window.matchMedia) {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
this.systemTheme = isDark ? this.THEMES.DARK : this.THEMES.LIGHT;
} else {
this.systemTheme = this.THEMES.LIGHT;
}
}
/**
* 监听系统主题变化
*/
watchSystemTheme() {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
this.systemTheme = e.matches ? this.THEMES.DARK : this.THEMES.LIGHT;
// 如果当前是自动模式,更新主题
const savedTheme = localStorage.getItem(this.THEME_KEY);
if (!savedTheme || savedTheme === this.THEMES.AUTO) {
this.applyTheme(true);
}
};
// 使用新的addEventListener方法如果支持
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else if (mediaQuery.addListener) {
// 兼容旧版浏览器
mediaQuery.addListener(handleChange);
}
}
}
/**
* 从本地存储加载主题
*/
loadTheme() {
const savedTheme = localStorage.getItem(this.THEME_KEY);
if (savedTheme && Object.values(this.THEMES).includes(savedTheme)) {
this.currentTheme = savedTheme;
} else {
// 默认使用系统主题
this.currentTheme = this.THEMES.AUTO;
}
}
/**
* 保存主题到本地存储
*/
saveTheme(theme) {
localStorage.setItem(this.THEME_KEY, theme);
this.currentTheme = theme;
}
/**
* 获取实际应用的主题
*/
getEffectiveTheme() {
if (this.currentTheme === this.THEMES.AUTO) {
return this.systemTheme;
}
return this.currentTheme;
}
/**
* 应用主题
*/
applyTheme(animate = false) {
const effectiveTheme = this.getEffectiveTheme();
const root = document.documentElement;
// 添加过渡类
if (animate) {
root.classList.add('theme-transitioning');
}
// 设置主题
if (effectiveTheme === this.THEMES.DARK) {
root.setAttribute('data-theme', 'dark');
root.style.colorScheme = 'dark'; // 提示浏览器使用深色滚动条等
} else {
root.removeAttribute('data-theme');
root.style.colorScheme = 'light';
}
// 更新切换按钮
this.updateToggleButton();
// 移除过渡类
if (animate) {
setTimeout(() => {
root.classList.remove('theme-transitioning');
}, 400);
}
// 触发自定义事件
window.dispatchEvent(new CustomEvent('themeChanged', {
detail: {
theme: effectiveTheme,
isDark: effectiveTheme === this.THEMES.DARK
}
}));
}
/**
* 切换主题三态循环
*/
toggle() {
// 三态循环light -> dark -> auto -> light
if (this.currentTheme === this.THEMES.LIGHT) {
this.saveTheme(this.THEMES.DARK);
} else if (this.currentTheme === this.THEMES.DARK) {
this.saveTheme(this.THEMES.AUTO);
} else {
this.saveTheme(this.THEMES.LIGHT);
}
this.applyTheme(true);
}
/**
* 设置特定主题
*/
setTheme(theme) {
if (!Object.values(this.THEMES).includes(theme)) {
console.error('Invalid theme:', theme);
return;
}
this.saveTheme(theme);
this.applyTheme(true);
}
/**
* 创建主题切换按钮
*/
createToggleButton() {
// 检查是否已存在按钮
if (document.querySelector('.theme-toggle')) {
return;
}
const button = document.createElement('button');
button.className = 'theme-toggle';
button.setAttribute('aria-label', '切换主题');
button.setAttribute('title', '切换深色/浅色主题');
button.type = 'button';
// 添加图标使用SVG图标代替emoji更清晰
button.innerHTML = `
<svg class="icon-sun" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
`;
// 绑定点击事件
button.addEventListener('click', (e) => {
e.preventDefault();
this.toggle();
// 添加点击动画
button.classList.add('theme-toggle-clicked');
setTimeout(() => {
button.classList.remove('theme-toggle-clicked');
}, 400);
});
// 添加键盘支持
button.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggle();
}
});
// 添加到页面
document.body.appendChild(button);
}
/**
* 更新切换按钮状态
*/
updateToggleButton() {
const button = document.querySelector('.theme-toggle');
if (!button) return;
const effectiveTheme = this.getEffectiveTheme();
let titleText = '';
let ariaLabel = '';
// 根据当前主题设置提示文本
if (this.currentTheme === this.THEMES.LIGHT) {
titleText = '当前:浅色主题\n点击切换到深色主题';
ariaLabel = '切换到深色主题';
} else if (this.currentTheme === this.THEMES.DARK) {
titleText = '当前:深色主题\n点击切换到自动跟随系统';
ariaLabel = '切换到自动跟随系统';
} else {
// AUTO 模式
const systemModeText = this.systemTheme === this.THEMES.DARK ? '深色' : '浅色';
titleText = `当前:自动跟随系统(${systemModeText}\n点击切换到:浅色主题`;
ariaLabel = '切换到浅色主题';
}
button.setAttribute('title', titleText);
button.setAttribute('aria-label', ariaLabel);
// 添加当前模式类名
button.classList.remove('theme-light', 'theme-dark', 'theme-auto');
if (this.currentTheme === this.THEMES.AUTO) {
button.classList.add('theme-auto');
} else if (this.currentTheme === this.THEMES.DARK) {
button.classList.add('theme-dark');
} else {
button.classList.add('theme-light');
}
}
/**
* 获取当前主题
*/
getCurrentTheme() {
return this.currentTheme;
}
/**
* 获取实际主题考虑auto模式
*/
getActualTheme() {
return this.getEffectiveTheme();
}
}
// 创建全局实例
const themeManager = new ThemeManager();
// 暴露到全局
window.themeManager = themeManager;
// 提供便捷方法
window.toggleTheme = () => themeManager.toggle();
window.setTheme = (theme) => themeManager.setTheme(theme);
window.getTheme = () => themeManager.getCurrentTheme();
// 监听主题变化事件(供其他脚本使用)
// 使用示例:
// window.addEventListener('themeChanged', (e) => {
// console.log('Theme changed to:', e.detail.theme);
// console.log('Is dark:', e.detail.isDark);
// });
})();

@ -0,0 +1,340 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}
{{ block.super }}
<style>
.compare-container {
max-width: 1200px;
margin: 20px auto;
}
.summary-box {
background: #e7f3ff;
border: 1px solid #007bff;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.summary-box h3 {
margin-top: 0;
color: #004085;
}
.change-list {
list-style: none;
padding-left: 0;
}
.change-list li {
padding: 5px 0;
}
.change-list li.changed {
color: #856404;
}
.change-list li.changed:before {
content: "✏️ ";
}
.change-list li.unchanged {
color: #155724;
}
.change-list li.unchanged:before {
content: "✓ ";
}
.comparison-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
}
.comparison-section h3 {
margin-top: 0;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
.compare-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 15px;
}
.compare-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
}
.compare-item h4 {
margin-top: 0;
color: #495057;
font-size: 14px;
border-bottom: 1px solid #dee2e6;
padding-bottom: 8px;
}
.compare-item .value {
margin-top: 10px;
color: #212529;
}
.diff-highlight {
background: #fff3cd;
padding: 2px 4px;
border-radius: 3px;
}
.action-buttons {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
.btn-restore {
background: #28a745;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
text-decoration: none;
display: inline-block;
}
.btn-restore:hover {
background: #218838;
color: white;
}
.btn-back {
background: #6c757d;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-back:hover {
background: #5a6268;
color: white;
}
/* 差异表格样式 */
.diff-section {
margin-top: 20px;
overflow-x: auto;
}
.diff-section table {
font-size: 12px;
border-collapse: collapse;
width: 100%;
}
.diff-section table td {
padding: 2px 5px;
vertical-align: top;
}
.diff-section .diff_header {
background: #007bff;
color: white;
font-weight: bold;
text-align: center;
}
.diff-section .diff_next {
background: #f8f9fa;
}
.diff-section .diff_add {
background: #d4edda;
}
.diff-section .diff_chg {
background: #fff3cd;
}
.diff-section .diff_sub {
background: #f8d7da;
}
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:blog_articleversion_changelist' %}">文章版本</a>
&rsaquo; 对比版本 v{{ version.version_number }}
</div>
{% endblock %}
{% block content %}
<div class="compare-container">
<h1>版本对比</h1>
<div class="summary-box">
<h3>📊 变更摘要</h3>
<ul class="change-list">
<li class="{% if diff.title_changed %}changed{% else %}unchanged{% endif %}">
标题{% if diff.title_changed %} 已修改{% else %} 未变更{% endif %}
</li>
<li class="{% if diff.body_changed %}changed{% else %}unchanged{% endif %}">
正文{% if diff.body_changed %} 已修改{% else %} 未变更{% endif %}
</li>
<li class="{% if diff.status_changed %}changed{% else %}unchanged{% endif %}">
发布状态{% if diff.status_changed %} 已修改{% else %} 未变更{% endif %}
</li>
<li class="{% if diff.category_changed %}changed{% else %}unchanged{% endif %}">
分类{% if diff.category_changed %} 已修改{% else %} 未变更{% endif %}
</li>
</ul>
</div>
<div class="comparison-section">
<h3>基本信息对比</h3>
<div class="compare-grid">
<div class="compare-item">
<h4>版本 v{{ version.version_number }}</h4>
<div class="value">
<strong>标题:</strong><br>
{% if diff.title_changed %}<span class="diff-highlight">{{ version.title }}</span>
{% else %}{{ version.title }}{% endif %}
</div>
<div class="value">
<strong>创建时间:</strong><br>
{{ version.creation_time|date:"Y-m-d H:i:s" }}
</div>
<div class="value">
<strong>创建者:</strong><br>
{{ version.created_by|default:"系统" }}
</div>
{% if version.change_summary %}
<div class="value">
<strong>变更说明:</strong><br>
{{ version.change_summary }}
</div>
{% endif %}
</div>
<div class="compare-item">
<h4>当前版本</h4>
<div class="value">
<strong>标题:</strong><br>
{% if diff.title_changed %}<span class="diff-highlight">{{ current.title }}</span>
{% else %}{{ current.title }}{% endif %}
</div>
<div class="value">
<strong>最后修改:</strong><br>
{{ current.last_mod_time|date:"Y-m-d H:i:s" }}
</div>
<div class="value">
<strong>作者:</strong><br>
{{ current.author }}
</div>
</div>
</div>
</div>
<div class="comparison-section">
<h3>状态信息对比</h3>
<div class="compare-grid">
<div class="compare-item">
<h4>版本 v{{ version.version_number }}</h4>
<div class="value">
<strong>发布状态:</strong><br>
{% if diff.status_changed %}<span class="diff-highlight">
{% endif %}
{% if version.status == 'p' %}已发布
{% elif version.status == 'd' %}草稿
{% else %}{{ version.status }}
{% endif %}
{% if diff.status_changed %}</span>{% endif %}
</div>
<div class="value">
<strong>评论状态:</strong><br>
{% if version.comment_status == 'o' %}开放
{% elif version.comment_status == 'c' %}关闭
{% else %}{{ version.comment_status }}
{% endif %}
</div>
</div>
<div class="compare-item">
<h4>当前版本</h4>
<div class="value">
<strong>发布状态:</strong><br>
{% if diff.status_changed %}<span class="diff-highlight">
{% endif %}
{% if current.status == 'p' %}已发布
{% elif current.status == 'd' %}草稿
{% else %}{{ current.status }}
{% endif %}
{% if diff.status_changed %}</span>{% endif %}
</div>
<div class="value">
<strong>评论状态:</strong><br>
{% if current.comment_status == 'o' %}开放
{% elif current.comment_status == 'c' %}关闭
{% else %}{{ current.comment_status }}
{% endif %}
</div>
</div>
</div>
</div>
<div class="comparison-section">
<h3>分类对比</h3>
<div class="compare-grid">
<div class="compare-item">
<h4>版本 v{{ version.version_number }}</h4>
<div class="value">
{% if diff.category_changed %}<span class="diff-highlight">{{ version.category_name }}</span>
{% else %}{{ version.category_name }}{% endif %}
</div>
</div>
<div class="compare-item">
<h4>当前版本</h4>
<div class="value">
{% if diff.category_changed %}<span class="diff-highlight">{{ current.category.name }}</span>
{% else %}{{ current.category.name }}{% endif %}
</div>
</div>
</div>
</div>
{% if diff.body_changed and body_diff_html %}
<div class="comparison-section">
<h3>正文内容详细对比</h3>
<p style="color: #666; font-size: 13px; margin-bottom: 10px;">
🟨 黄色背景 = 修改的行 | 🟩 绿色背景 = 新增的行 | 🟥 红色背景 = 删除的行
</p>
<div class="diff-section">
{{ body_diff_html|safe }}
</div>
</div>
{% endif %}
<div class="action-buttons">
<a href="{% url 'admin:restore_article_version' version.id %}" class="btn-restore">🔄 恢复此版本</a>
<a href="{% url 'admin:blog_articleversion_changelist' %}" class="btn-back">返回列表</a>
</div>
</div>
{% endblock %}

@ -0,0 +1,199 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}
{{ block.super }}
<style>
.restore-container {
max-width: 900px;
margin: 20px auto;
}
.warning-box {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.warning-box h3 {
margin-top: 0;
color: #856404;
}
.version-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
}
.version-info h3 {
margin-top: 0;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
.info-grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 10px;
margin-bottom: 15px;
}
.info-label {
font-weight: bold;
color: #495057;
}
.content-preview {
background: #ffffff;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
max-height: 300px;
overflow-y: auto;
margin-top: 10px;
}
.action-buttons {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
.btn-restore {
background: #28a745;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
}
.btn-restore:hover {
background: #218838;
}
.btn-cancel {
background: #6c757d;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-cancel:hover {
background: #5a6268;
}
.btn-compare {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-compare:hover {
background: #0056b3;
color: white;
}
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:blog_articleversion_changelist' %}">文章版本</a>
&rsaquo; 恢复版本 v{{ version.version_number }}
</div>
{% endblock %}
{% block content %}
<div class="restore-container">
<h1>恢复文章版本</h1>
<div class="warning-box">
<h3>⚠️ 重要提示</h3>
<ul>
<li>恢复版本会将文章内容替换为所选版本的内容</li>
<li>当前版本的内容会被自动保存到历史记录中</li>
<li>此操作可以撤销(通过恢复到其他版本)</li>
</ul>
</div>
<div class="version-info">
<h3>版本信息</h3>
<div class="info-grid">
<div class="info-label">版本号:</div>
<div>v{{ version.version_number }}</div>
<div class="info-label">标题:</div>
<div>{{ version.title }}</div>
<div class="info-label">创建时间:</div>
<div>{{ version.creation_time|date:"Y-m-d H:i:s" }}</div>
<div class="info-label">创建者:</div>
<div>{{ version.created_by|default:"系统" }}</div>
<div class="info-label">变更说明:</div>
<div>{{ version.change_summary|default:"无" }}</div>
<div class="info-label">分类:</div>
<div>{{ version.category_name }}</div>
<div class="info-label">状态:</div>
<div>
{% if version.status == 'p' %}已发布
{% elif version.status == 'd' %}草稿
{% else %}{{ version.status }}
{% endif %}
</div>
</div>
<div>
<strong class="info-label">内容预览:</strong>
<div class="content-preview">
{{ version.body|truncatewords:200|safe }}
</div>
</div>
</div>
<div class="version-info">
<h3>当前版本信息</h3>
<div class="info-grid">
<div class="info-label">标题:</div>
<div>{{ current.title }}</div>
<div class="info-label">最后修改:</div>
<div>{{ current.last_mod_time|date:"Y-m-d H:i:s" }}</div>
<div class="info-label">分类:</div>
<div>{{ current.category.name }}</div>
</div>
</div>
<form method="post">
{% csrf_token %}
<div class="action-buttons">
<button type="submit" class="btn-restore">✓ 确认恢复此版本</button>
<a href="{% url 'admin:compare_article_version' version.id %}" class="btn-compare">📊 查看详细对比</a>
<a href="{% url 'admin:blog_articleversion_changelist' %}" class="btn-cancel">取消</a>
</div>
</form>
</div>
{% endblock %}

@ -69,6 +69,151 @@
</div><!-- .entry-content -->
<!-- 点赞和收藏按钮 - 仅在文章详情页显示 -->
{% if not isindex %}
<div class="article-actions" style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; display: flex; gap: 15px; align-items: center;">
<!-- 点赞按钮 -->
<button type="button" class="like-btn {% if is_liked %}liked{% endif %}"
data-article-id="{{ article.pk }}"
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border: 1px solid #ddd; border-radius: 20px; background: {% if is_liked %}#ff6b6b{% else %}white{% endif %}; color: {% if is_liked %}white{% else %}#333{% endif %}; cursor: pointer; font-size: 14px; transition: all 0.3s;">
<span class="like-icon">{% if is_liked %}❤️{% else %}🤍{% endif %}</span>
<span class="like-text">{% if is_liked %}已点赞{% else %}点赞{% endif %}</span>
<span class="like-count">({{ like_count }})</span>
</button>
<!-- 收藏按钮 -->
<button type="button" class="favorite-btn {% if is_favorited %}favorited{% endif %}"
data-article-id="{{ article.pk }}"
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border: 1px solid #ddd; border-radius: 20px; background: {% if is_favorited %}#ffc107{% else %}white{% endif %}; color: {% if is_favorited %}white{% else %}#333{% endif %}; cursor: pointer; font-size: 14px; transition: all 0.3s;">
<span class="favorite-icon">{% if is_favorited %}⭐{% else %}☆{% endif %}</span>
<span class="favorite-text">{% if is_favorited %}已收藏{% else %}收藏{% endif %}</span>
<span class="favorite-count">({{ favorite_count }})</span>
</button>
</div>
<script>
(function() {
// 获取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;
}
// 点赞功能
const likeBtn = document.querySelector('.like-btn');
if (likeBtn) {
likeBtn.addEventListener('click', async function() {
const articleId = this.dataset.articleId;
const isLiked = this.classList.contains('liked');
const url = isLiked ? '{% url "blog:unlike_article" %}' : '{% url "blog:like_article" %}';
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ article_id: articleId })
});
const data = await response.json();
if (response.status === 401 || response.status === 403 || response.status === 302) {
window.location.href = '{% url "account:login" %}?next=' + window.location.pathname;
return;
}
if (data.success) {
// 更新按钮状态
if (data.is_liked) {
this.classList.add('liked');
this.style.background = '#ff6b6b';
this.style.color = 'white';
this.querySelector('.like-icon').textContent = '❤️';
this.querySelector('.like-text').textContent = '已点赞';
} else {
this.classList.remove('liked');
this.style.background = 'white';
this.style.color = '#333';
this.querySelector('.like-icon').textContent = '🤍';
this.querySelector('.like-text').textContent = '点赞';
}
this.querySelector('.like-count').textContent = '(' + data.like_count + ')';
} else {
alert(data.message || '操作失败');
}
} catch (error) {
console.error('点赞请求失败:', error);
alert('网络错误,请重试');
}
});
}
// 收藏功能
const favoriteBtn = document.querySelector('.favorite-btn');
if (favoriteBtn) {
favoriteBtn.addEventListener('click', async function() {
const articleId = this.dataset.articleId;
const isFavorited = this.classList.contains('favorited');
const url = isFavorited ? '{% url "blog:unfavorite_article" %}' : '{% url "blog:favorite_article" %}';
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ article_id: articleId })
});
const data = await response.json();
if (response.status === 401 || response.status === 403 || response.status === 302) {
window.location.href = '{% url "account:login" %}?next=' + window.location.pathname;
return;
}
if (data.success) {
// 更新按钮状态
if (data.is_favorited) {
this.classList.add('favorited');
this.style.background = '#ffc107';
this.style.color = 'white';
this.querySelector('.favorite-icon').textContent = '⭐';
this.querySelector('.favorite-text').textContent = '已收藏';
} else {
this.classList.remove('favorited');
this.style.background = 'white';
this.style.color = '#333';
this.querySelector('.favorite-icon').textContent = '☆';
this.querySelector('.favorite-text').textContent = '收藏';
}
this.querySelector('.favorite-count').textContent = '(' + data.favorite_count + ')';
} else {
alert(data.message || '操作失败');
}
} catch (error) {
console.error('收藏请求失败:', error);
alert('网络错误,请重试');
}
});
}
})();
</script>
{% endif %}
{% load_article_metas article user %}
</article><!-- #post -->

@ -35,6 +35,13 @@
</span>
</span>
</a>
{% if user.is_authenticated and user != article.author %}
<button type="button" class="follow-btn {% if is_following %}following{% endif %}"
data-author-id="{{ article.author.id }}"
style="margin-left: 10px; padding: 5px 15px; border: 1px solid #ddd; border-radius: 15px; background: {% if is_following %}#28a745{% else %}white{% endif %}; color: {% if is_following %}white{% else %}#333{% endif %}; cursor: pointer; font-size: 12px; transition: all 0.3s;">
<span class="follow-text">{% if is_following %}已关注{% else %}+ 关注{% endif %}</span>
</button>
{% endif %}
</span>
@ -54,4 +61,107 @@
</span>
</footer><!-- .entry-meta -->
<!-- 关注功能JavaScript -->
<script>
(function() {
'use strict';
// 获取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;
}
// 使用事件委托,避免重复绑定
document.addEventListener('DOMContentLoaded', function() {
const followBtns = document.querySelectorAll('.follow-btn');
followBtns.forEach(function(followBtn) {
// 检查是否已经绑定过事件
if (followBtn.hasAttribute('data-follow-bound')) {
return;
}
followBtn.setAttribute('data-follow-bound', 'true');
followBtn.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
const btn = this;
const authorId = btn.dataset.authorId;
const isFollowing = btn.classList.contains('following');
const url = isFollowing ? '{% url "blog:unfollow_user" %}' : '{% url "blog:follow_user" %}';
// 禁用按钮,防止重复点击
btn.disabled = true;
console.log('关注请求:', { authorId, isFollowing, url });
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
body: JSON.stringify({ user_id: parseInt(authorId) })
});
console.log('关注响应状态:', response.status);
// 检查是否需要登录
if (response.status === 401) {
alert('请先登录后再关注');
window.location.href = '{% url "account:login" %}?next=' + window.location.pathname;
return;
}
const data = await response.json();
console.log('关注响应数据:', data);
if (data.success) {
// 更新所有相同作者的关注按钮状态
const allFollowBtns = document.querySelectorAll(`.follow-btn[data-author-id="${authorId}"]`);
allFollowBtns.forEach(function(targetBtn) {
if (data.is_following) {
targetBtn.classList.add('following');
targetBtn.style.background = '#28a745';
targetBtn.style.color = 'white';
targetBtn.querySelector('.follow-text').textContent = '已关注';
} else {
targetBtn.classList.remove('following');
targetBtn.style.background = 'white';
targetBtn.style.color = '#333';
targetBtn.querySelector('.follow-text').textContent = '+ 关注';
}
});
} else {
// 只显示一次alert
alert(data.message || '操作失败');
}
} catch (error) {
console.error('关注请求失败:', error);
alert('网络错误,请重试');
} finally {
// 重新启用按钮
btn.disabled = false;
}
}, { once: false });
});
});
})();
</script>

@ -15,6 +15,30 @@
<html lang="zh-CN" prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# article: http://ogp.me/ns/article#">
<!--<![endif]-->
<head>
<!-- 主题初始化脚本 - 必须在最前面以防止闪烁 -->
<script>
(function() {
const THEME_KEY = 'blog-theme';
const savedTheme = localStorage.getItem(THEME_KEY);
let theme = 'light';
if (savedTheme === 'dark' || savedTheme === 'light') {
theme = savedTheme;
} else if (savedTheme === 'auto' || !savedTheme) {
// 检测系统主题
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
}
// 立即应用主题
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.style.colorScheme = 'dark';
}
})();
</script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="format-detection" content="telephone=no"/>
@ -54,12 +78,16 @@
<![endif]-->
<link rel="stylesheet" href="{% static 'pygments/default.css' %}"/>
<link rel="stylesheet" href="{% static 'blog/css/nprogress.css' %}">
<link rel="stylesheet" href="{% static 'blog/css/theme.css' %}">
{% block compress_css %}
{% endblock %}
<!-- 插件CSS文件 - 集成到压缩系统 -->
{% plugin_compressed_css %}
{% endcompress %}
<!-- 深色模式修复 - 在compress之外以确保正确加载 -->
<link rel="stylesheet" href="{% static 'blog/css/dark-mode-fixes.css' %}">
{% if GLOBAL_HEADER %}
{{ GLOBAL_HEADER|safe }}
{% endif %}
@ -101,6 +129,7 @@
<script src="{% static 'blog/js/nprogress.js' %}"></script>
<script src="{% static 'blog/js/blog.js' %}"></script>
<script src="{% static 'blog/js/navigation.js' %}"></script>
<script src="{% static 'blog/js/theme-toggle.js' %}"></script>
{% block compress_js %}
{% endblock %}
<!-- 插件JS文件 - 集成到压缩系统 -->

Loading…
Cancel
Save