From 77abcb9a4263ca9a2018499077f59911def7b556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E6=8C=AF?= <3222854347@qq.com> Date: Tue, 25 Nov 2025 18:37:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BB=A3=E7=A0=81&=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/accounts/admin.py | 7 + src/blog/admin.py | 54 +- src/blog/admin_draft.py | 110 +++ src/blog/admin_media.py | 210 +++++ src/blog/admin_social.py | 168 ++++ src/blog/admin_version.py | 217 +++++ src/blog/apps.py | 19 +- src/blog/context_processors.py | 45 +- src/blog/forms.py | 7 +- src/blog/middleware.py | 10 +- .../0002_add_performance_indexes.py | 45 + .../migrations/0003_add_article_version.py | 55 ++ src/blog/migrations/0004_add_article_draft.py | 54 ++ .../migrations/0005_add_social_features.py | 78 ++ src/blog/migrations/0006_add_article_like.py | 43 + .../migrations/0007_add_media_management.py | 101 ++ .../migrations/0008_merge_20251124_0221.py | 14 + ...009_alter_articledraft_options_and_more.py | 359 +++++++ src/blog/models.py | 112 ++- src/blog/models_draft.py | 242 +++++ src/blog/models_media.py | 268 ++++++ src/blog/models_social.py | 401 ++++++++ src/blog/models_version.py | 170 ++++ src/blog/rate_limit.py | 316 +++++++ src/blog/static/blog/css/dark-mode-fixes.css | 457 +++++++++ src/blog/templatetags/blog_tags.py | 33 +- src/blog/urls.py | 124 ++- src/blog/views.py | 64 +- src/blog/views_draft.py | 292 ++++++ src/blog/views_media.py | 463 +++++++++ src/blog/views_social.py | 744 +++++++++++++++ src/check_admin.py | 26 + src/check_custom_admin.py | 26 + src/comments/management/__init__.py | 1 + src/comments/management/commands/__init__.py | 1 + .../management/commands/manage_comments.py | 69 ++ .../0002_add_performance_indexes.py | 23 + .../migrations/0004_merge_20251124_0221.py | 14 + ...ent_comment_article_enable_idx_and_more.py | 21 + src/comments/spam_checker.py | 479 ++++++++++ src/djangoblog/admin_site.py | 20 + src/djangoblog/settings.py | 1 - src/static/blog/css/dark-mode-fixes.css | 457 +++++++++ src/static/blog/css/media-picker.css | 296 ++++++ src/static/blog/css/responsive.css | 750 +++++++++++++++ src/static/blog/css/theme.css | 876 ++++++++++++++++++ src/static/blog/js/article-draft-autosave.js | 321 +++++++ src/static/blog/js/media-picker.js | 375 ++++++++ src/static/blog/js/social-features.js | 330 +++++++ src/static/blog/js/theme-toggle.js | 321 +++++++ .../admin/blog/articleversion/compare.html | 340 +++++++ .../articleversion/restore_confirmation.html | 199 ++++ src/templates/blog/tags/article_info.html | 145 +++ .../blog/tags/article_meta_info.html | 110 +++ src/templates/share_layout/base.html | 29 + 55 files changed, 10417 insertions(+), 95 deletions(-) create mode 100644 src/blog/admin_draft.py create mode 100644 src/blog/admin_media.py create mode 100644 src/blog/admin_social.py create mode 100644 src/blog/admin_version.py create mode 100644 src/blog/migrations/0002_add_performance_indexes.py create mode 100644 src/blog/migrations/0003_add_article_version.py create mode 100644 src/blog/migrations/0004_add_article_draft.py create mode 100644 src/blog/migrations/0005_add_social_features.py create mode 100644 src/blog/migrations/0006_add_article_like.py create mode 100644 src/blog/migrations/0007_add_media_management.py create mode 100644 src/blog/migrations/0008_merge_20251124_0221.py create mode 100644 src/blog/migrations/0009_alter_articledraft_options_and_more.py create mode 100644 src/blog/models_draft.py create mode 100644 src/blog/models_media.py create mode 100644 src/blog/models_social.py create mode 100644 src/blog/models_version.py create mode 100644 src/blog/rate_limit.py create mode 100644 src/blog/static/blog/css/dark-mode-fixes.css create mode 100644 src/blog/views_draft.py create mode 100644 src/blog/views_media.py create mode 100644 src/blog/views_social.py create mode 100644 src/check_admin.py create mode 100644 src/check_custom_admin.py create mode 100644 src/comments/management/__init__.py create mode 100644 src/comments/management/commands/__init__.py create mode 100644 src/comments/management/commands/manage_comments.py create mode 100644 src/comments/migrations/0002_add_performance_indexes.py create mode 100644 src/comments/migrations/0004_merge_20251124_0221.py create mode 100644 src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py create mode 100644 src/comments/spam_checker.py create mode 100644 src/static/blog/css/dark-mode-fixes.css create mode 100644 src/static/blog/css/media-picker.css create mode 100644 src/static/blog/css/responsive.css create mode 100644 src/static/blog/css/theme.css create mode 100644 src/static/blog/js/article-draft-autosave.js create mode 100644 src/static/blog/js/media-picker.js create mode 100644 src/static/blog/js/social-features.js create mode 100644 src/static/blog/js/theme-toggle.js create mode 100644 src/templates/admin/blog/articleversion/compare.html create mode 100644 src/templates/admin/blog/articleversion/restore_confirmation.html diff --git a/src/accounts/admin.py b/src/accounts/admin.py index 29d162a..42f2919 100644 --- a/src/accounts/admin.py +++ b/src/accounts/admin.py @@ -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', diff --git a/src/blog/admin.py b/src/blog/admin.py index 69d7f8e..c146e95 100644 --- a/src/blog/admin.py +++ b/src/blog/admin.py @@ -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'%s' % (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('{} 个版本', 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 diff --git a/src/blog/admin_draft.py b/src/blog/admin_draft.py new file mode 100644 index 0000000..cbd6b38 --- /dev/null +++ b/src/blog/admin_draft.py @@ -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('{}', url, obj.article.title) + return '-' + + article_link.short_description = '文章' + + def preview(self, obj): + """预览文本""" + preview_text = obj.get_preview_text(30) + return format_html('{}', 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( + ' ' + '', + obj.id, obj.id + ) + return '已发布' + + action_buttons.short_description = '操作' + + class Media: + js = ('admin/js/draft_actions.js',) diff --git a/src/blog/admin_media.py b/src/blog/admin_media.py new file mode 100644 index 0000000..9d83156 --- /dev/null +++ b/src/blog/admin_media.py @@ -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( + '', + obj.get_thumbnail_url() + ) + return '📄' + + thumbnail_preview.short_description = '预览' + + def thumbnail_preview_large(self, obj): + """缩略图预览(详情)""" + if obj.is_image(): + return format_html( + '', + 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('{}', 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('{}', 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('{}', 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('{}', url, obj.folder.name) + + folder_link.short_description = '文件夹' diff --git a/src/blog/admin_social.py b/src/blog/admin_social.py new file mode 100644 index 0000000..a0cde17 --- /dev/null +++ b/src/blog/admin_social.py @@ -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('{}', 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('{}', 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('{}', 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('{}', 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('{}', 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('{}', url, title) + + article_link.short_description = '文章' + diff --git a/src/blog/admin_version.py b/src/blog/admin_version.py new file mode 100644 index 0000000..f430f63 --- /dev/null +++ b/src/blog/admin_version.py @@ -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('{}', 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('自动保存') + + 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( + '恢复此版本 ' + '对比', + 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( + '/restore/', + self.admin_site.admin_view(self.restore_version), + name='restore_article_version', + ), + path( + '/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 + ) diff --git a/src/blog/apps.py b/src/blog/apps.py index 7930587..6336f0c 100644 --- a/src/blog/apps.py +++ b/src/blog/apps.py @@ -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 diff --git a/src/blog/context_processors.py b/src/blog/context_processors.py index 73e3088..6b24a31 100644 --- a/src/blog/context_processors.py +++ b/src/blog/context_processors.py @@ -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 diff --git a/src/blog/forms.py b/src/blog/forms.py index 715be76..7d703a1 100644 --- a/src/blog/forms.py +++ b/src/blog/forms.py @@ -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 diff --git a/src/blog/middleware.py b/src/blog/middleware.py index 94dd70c..8ad1e54 100644 --- a/src/blog/middleware.py +++ b/src/blog/middleware.py @@ -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'', str.encode(str(cast_time)[:5])) + b'', str.encode(str(cast_time)[:5])) #zhq: 保留前5位字符 except Exception as e: logger.error("Error OnlineMiddleware: %s" % e) diff --git a/src/blog/migrations/0002_add_performance_indexes.py b/src/blog/migrations/0002_add_performance_indexes.py new file mode 100644 index 0000000..43db885 --- /dev/null +++ b/src/blog/migrations/0002_add_performance_indexes.py @@ -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), + ), + ] diff --git a/src/blog/migrations/0003_add_article_version.py b/src/blog/migrations/0003_add_article_version.py new file mode 100644 index 0000000..8f7c0f0 --- /dev/null +++ b/src/blog/migrations/0003_add_article_version.py @@ -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')}, + ), + ] diff --git a/src/blog/migrations/0004_add_article_draft.py b/src/blog/migrations/0004_add_article_draft.py new file mode 100644 index 0000000..2bc2981 --- /dev/null +++ b/src/blog/migrations/0004_add_article_draft.py @@ -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'), + ), + ] diff --git a/src/blog/migrations/0005_add_social_features.py b/src/blog/migrations/0005_add_social_features.py new file mode 100644 index 0000000..c10159f --- /dev/null +++ b/src/blog/migrations/0005_add_social_features.py @@ -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')}, + ), + ] diff --git a/src/blog/migrations/0006_add_article_like.py b/src/blog/migrations/0006_add_article_like.py new file mode 100644 index 0000000..35a158e --- /dev/null +++ b/src/blog/migrations/0006_add_article_like.py @@ -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')}, + ), + ] diff --git a/src/blog/migrations/0007_add_media_management.py b/src/blog/migrations/0007_add_media_management.py new file mode 100644 index 0000000..3db8e16 --- /dev/null +++ b/src/blog/migrations/0007_add_media_management.py @@ -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')}, + ), + ] diff --git a/src/blog/migrations/0008_merge_20251124_0221.py b/src/blog/migrations/0008_merge_20251124_0221.py new file mode 100644 index 0000000..d73fb5a --- /dev/null +++ b/src/blog/migrations/0008_merge_20251124_0221.py @@ -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 = [ + ] diff --git a/src/blog/migrations/0009_alter_articledraft_options_and_more.py b/src/blog/migrations/0009_alter_articledraft_options_and_more.py new file mode 100644 index 0000000..3925ef6 --- /dev/null +++ b/src/blog/migrations/0009_alter_articledraft_options_and_more.py @@ -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'), + ), + ] diff --git a/src/blog/models.py b/src/blog/models.py index 083788b..06fb0cd 100644 --- a/src/blog/models.py +++ b/src/blog/models.py @@ -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 diff --git a/src/blog/models_draft.py b/src/blog/models_draft.py new file mode 100644 index 0000000..1a3c438 --- /dev/null +++ b/src/blog/models_draft.py @@ -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 '(空草稿)' diff --git a/src/blog/models_media.py b/src/blog/models_media.py new file mode 100644 index 0000000..387ada1 --- /dev/null +++ b/src/blog/models_media.py @@ -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}" diff --git a/src/blog/models_social.py b/src/blog/models_social.py new file mode 100644 index 0000000..8f39ff2 --- /dev/null +++ b/src/blog/models_social.py @@ -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 diff --git a/src/blog/models_version.py b/src/blog/models_version.py new file mode 100644 index 0000000..d7cc533 --- /dev/null +++ b/src/blog/models_version.py @@ -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 diff --git a/src/blog/rate_limit.py b/src/blog/rate_limit.py new file mode 100644 index 0000000..488327d --- /dev/null +++ b/src/blog/rate_limit.py @@ -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 diff --git a/src/blog/static/blog/css/dark-mode-fixes.css b/src/blog/static/blog/css/dark-mode-fixes.css new file mode 100644 index 0000000..f573713 --- /dev/null +++ b/src/blog/static/blog/css/dark-mode-fixes.css @@ -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; +} diff --git a/src/blog/templatetags/blog_tags.py b/src/blog/templatetags/blog_tags.py index 024f2c8..aa8f354 100644 --- a/src/blog/templatetags/blog_tags.py +++ b/src/blog/templatetags/blog_tags.py @@ -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, } diff --git a/src/blog/urls.py b/src/blog/urls.py index adf2703..6158ac6 100644 --- a/src/blog/urls.py +++ b/src/blog/urls.py @@ -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//', views.IndexView.as_view(), name='index_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'article////.html', views.ArticleDetailView.as_view(), name='detailbyid'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'category/.html', views.CategoryDetailView.as_view(), name='category_detail'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'category//.html', views.CategoryDetailView.as_view(), name='category_detail_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'author/.html', views.AuthorDetailView.as_view(), name='author_detail'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'author//.html', views.AuthorDetailView.as_view(), name='author_detail_page'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'tag/.html', views.TagDetailView.as_view(), name='tag_detail'), +#zhq: 文章详情页路由 - 包含年月日信息用于SEO优化 path( r'tag//.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'), ] diff --git a/src/blog/views.py b/src/blog/views.py index 773bb75..9cf3b33 100644 --- a/src/blog/views.py +++ b/src/blog/views.py @@ -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') diff --git a/src/blog/views_draft.py b/src/blog/views_draft.py new file mode 100644 index 0000000..18bd6c4 --- /dev/null +++ b/src/blog/views_draft.py @@ -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) diff --git a/src/blog/views_media.py b/src/blog/views_media.py new file mode 100644 index 0000000..0474b45 --- /dev/null +++ b/src/blog/views_media.py @@ -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" diff --git a/src/blog/views_social.py b/src/blog/views_social.py new file mode 100644 index 0000000..8e09175 --- /dev/null +++ b/src/blog/views_social.py @@ -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) diff --git a/src/check_admin.py b/src/check_admin.py new file mode 100644 index 0000000..dd53df9 --- /dev/null +++ b/src/check_admin.py @@ -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) diff --git a/src/check_custom_admin.py b/src/check_custom_admin.py new file mode 100644 index 0000000..61c09f9 --- /dev/null +++ b/src/check_custom_admin.py @@ -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) diff --git a/src/comments/management/__init__.py b/src/comments/management/__init__.py new file mode 100644 index 0000000..8b4cc36 --- /dev/null +++ b/src/comments/management/__init__.py @@ -0,0 +1 @@ +# Management commands module diff --git a/src/comments/management/commands/__init__.py b/src/comments/management/commands/__init__.py new file mode 100644 index 0000000..2c1c7c1 --- /dev/null +++ b/src/comments/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/src/comments/management/commands/manage_comments.py b/src/comments/management/commands/manage_comments.py new file mode 100644 index 0000000..23761c6 --- /dev/null +++ b/src/comments/management/commands/manage_comments.py @@ -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' + )) diff --git a/src/comments/migrations/0002_add_performance_indexes.py b/src/comments/migrations/0002_add_performance_indexes.py new file mode 100644 index 0000000..6e7a824 --- /dev/null +++ b/src/comments/migrations/0002_add_performance_indexes.py @@ -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'), + ), + ] diff --git a/src/comments/migrations/0004_merge_20251124_0221.py b/src/comments/migrations/0004_merge_20251124_0221.py new file mode 100644 index 0000000..15ae30f --- /dev/null +++ b/src/comments/migrations/0004_merge_20251124_0221.py @@ -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 = [ + ] diff --git a/src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py b/src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py new file mode 100644 index 0000000..313c8ad --- /dev/null +++ b/src/comments/migrations/0005_remove_comment_comment_article_enable_idx_and_more.py @@ -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', + ), + ] diff --git a/src/comments/spam_checker.py b/src/comments/spam_checker.py new file mode 100644 index 0000000..1f29a35 --- /dev/null +++ b/src/comments/spam_checker.py @@ -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""" + + + + + + + +
+
+

新评论通知

+
+

您好 {comment.article.author.username},

+

您的文章《{comment.article.title}》收到了新评论:

+
+

评论者:{comment.author.username}

+

评论内容:

+

{comment.body}

+
+

点击查看评论

+ +
+ + + """.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""" + + + + + + + +
+
+

评论回复通知

+
+

您好 {comment.parent_comment.author.username},

+

{comment.author.username} 回复了您在文章《{comment.article.title}》中的评论:

+
+

您的评论:

+

{comment.parent_comment.body}

+
+
+

回复内容:

+

{comment.body}

+
+

点击查看回复

+ +
+ + + """.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""" + + + + + + + +
+
+

新评论待审核

+
+

管理员您好,

+

有新评论需要审核:

+
+

文章:《{comment.article.title}》

+

评论者:{comment.author.username}

+

评论时间:{comment.creation_time.strftime('%Y-%m-%d %H:%M:%S')}

+

评论内容:

+

{comment.body}

+
+

+ 审核评论 + 查看文章 +

+ +
+ + + """.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)} 位管理员发送审核通知") diff --git a/src/djangoblog/admin_site.py b/src/djangoblog/admin_site.py index f120405..8574b36 100644 --- a/src/djangoblog/admin_site.py +++ b/src/djangoblog/admin_site.py @@ -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) diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index c9dc4c6..dec93b1 100644 --- a/src/djangoblog/settings.py +++ b/src/djangoblog/settings.py @@ -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': { diff --git a/src/static/blog/css/dark-mode-fixes.css b/src/static/blog/css/dark-mode-fixes.css new file mode 100644 index 0000000..f573713 --- /dev/null +++ b/src/static/blog/css/dark-mode-fixes.css @@ -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; +} diff --git a/src/static/blog/css/media-picker.css b/src/static/blog/css/media-picker.css new file mode 100644 index 0000000..14877bc --- /dev/null +++ b/src/static/blog/css/media-picker.css @@ -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%; + } +} diff --git a/src/static/blog/css/responsive.css b/src/static/blog/css/responsive.css new file mode 100644 index 0000000..9192b45 --- /dev/null +++ b/src/static/blog/css/responsive.css @@ -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) ")"; + } +} diff --git a/src/static/blog/css/theme.css b/src/static/blog/css/theme.css new file mode 100644 index 0000000..25eb971 --- /dev/null +++ b/src/static/blog/css/theme.css @@ -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; + } +} diff --git a/src/static/blog/js/article-draft-autosave.js b/src/static/blog/js/article-draft-autosave.js new file mode 100644 index 0000000..a60bccb --- /dev/null +++ b/src/static/blog/js/article-draft-autosave.js @@ -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(); + } + +})(); diff --git a/src/static/blog/js/media-picker.js b/src/static/blog/js/media-picker.js new file mode 100644 index 0000000..d08b460 --- /dev/null +++ b/src/static/blog/js/media-picker.js @@ -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 = ` + + `; + + // 添加到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 = '
加载中...
'; + + 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 = '
加载失败
'; + } + } catch (error) { + console.error('加载文件列表失败:', error); + grid.innerHTML = '
加载失败
'; + } + } + + displayFiles(files) { + const grid = document.getElementById('media-files-grid'); + + if (files.length === 0) { + grid.innerHTML = '
暂无文件
'; + return; + } + + grid.innerHTML = files.map(file => { + const isSelected = this.selectedFiles.some(f => f.id === file.id); + return ` +
+
+ ${file.file_type === 'image' + ? `${file.original_filename}` + : '
📄
'} +
+
+
+ ${file.original_filename} +
+
+ ${file.file_size_readable} +
+
+
+ +
+
+ `; + }).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; + +})(); diff --git a/src/static/blog/js/social-features.js b/src/static/blog/js/social-features.js new file mode 100644 index 0000000..ed94d0b --- /dev/null +++ b/src/static/blog/js/social-features.js @@ -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(); + } + +})(); diff --git a/src/static/blog/js/theme-toggle.js b/src/static/blog/js/theme-toggle.js new file mode 100644 index 0000000..d040ed9 --- /dev/null +++ b/src/static/blog/js/theme-toggle.js @@ -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 = ` + + + + + + + + + + + + + + + `; + + // 绑定点击事件 + 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); + // }); + +})(); diff --git a/src/templates/admin/blog/articleversion/compare.html b/src/templates/admin/blog/articleversion/compare.html new file mode 100644 index 0000000..ee28835 --- /dev/null +++ b/src/templates/admin/blog/articleversion/compare.html @@ -0,0 +1,340 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

版本对比

+ +
+

📊 变更摘要

+
    +
  • + 标题{% if diff.title_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
  • + 正文{% if diff.body_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
  • + 发布状态{% if diff.status_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
  • + 分类{% if diff.category_changed %} 已修改{% else %} 未变更{% endif %} +
  • +
+
+ +
+

基本信息对比

+
+
+

版本 v{{ version.version_number }}

+
+ 标题:
+ {% if diff.title_changed %}{{ version.title }} + {% else %}{{ version.title }}{% endif %} +
+
+ 创建时间:
+ {{ version.creation_time|date:"Y-m-d H:i:s" }} +
+
+ 创建者:
+ {{ version.created_by|default:"系统" }} +
+ {% if version.change_summary %} +
+ 变更说明:
+ {{ version.change_summary }} +
+ {% endif %} +
+ +
+

当前版本

+
+ 标题:
+ {% if diff.title_changed %}{{ current.title }} + {% else %}{{ current.title }}{% endif %} +
+
+ 最后修改:
+ {{ current.last_mod_time|date:"Y-m-d H:i:s" }} +
+
+ 作者:
+ {{ current.author }} +
+
+
+
+ +
+

状态信息对比

+
+
+

版本 v{{ version.version_number }}

+
+ 发布状态:
+ {% if diff.status_changed %} + {% endif %} + {% if version.status == 'p' %}已发布 + {% elif version.status == 'd' %}草稿 + {% else %}{{ version.status }} + {% endif %} + {% if diff.status_changed %}{% endif %} +
+
+ 评论状态:
+ {% if version.comment_status == 'o' %}开放 + {% elif version.comment_status == 'c' %}关闭 + {% else %}{{ version.comment_status }} + {% endif %} +
+
+ +
+

当前版本

+
+ 发布状态:
+ {% if diff.status_changed %} + {% endif %} + {% if current.status == 'p' %}已发布 + {% elif current.status == 'd' %}草稿 + {% else %}{{ current.status }} + {% endif %} + {% if diff.status_changed %}{% endif %} +
+
+ 评论状态:
+ {% if current.comment_status == 'o' %}开放 + {% elif current.comment_status == 'c' %}关闭 + {% else %}{{ current.comment_status }} + {% endif %} +
+
+
+
+ +
+

分类对比

+
+
+

版本 v{{ version.version_number }}

+
+ {% if diff.category_changed %}{{ version.category_name }} + {% else %}{{ version.category_name }}{% endif %} +
+
+ +
+

当前版本

+
+ {% if diff.category_changed %}{{ current.category.name }} + {% else %}{{ current.category.name }}{% endif %} +
+
+
+
+ + {% if diff.body_changed and body_diff_html %} +
+

正文内容详细对比

+

+ 🟨 黄色背景 = 修改的行 | 🟩 绿色背景 = 新增的行 | 🟥 红色背景 = 删除的行 +

+
+ {{ body_diff_html|safe }} +
+
+ {% endif %} + + +
+{% endblock %} diff --git a/src/templates/admin/blog/articleversion/restore_confirmation.html b/src/templates/admin/blog/articleversion/restore_confirmation.html new file mode 100644 index 0000000..ae75696 --- /dev/null +++ b/src/templates/admin/blog/articleversion/restore_confirmation.html @@ -0,0 +1,199 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

恢复文章版本

+ +
+

⚠️ 重要提示

+
    +
  • 恢复版本会将文章内容替换为所选版本的内容
  • +
  • 当前版本的内容会被自动保存到历史记录中
  • +
  • 此操作可以撤销(通过恢复到其他版本)
  • +
+
+ +
+

版本信息

+ +
+
版本号:
+
v{{ version.version_number }}
+ +
标题:
+
{{ version.title }}
+ +
创建时间:
+
{{ version.creation_time|date:"Y-m-d H:i:s" }}
+ +
创建者:
+
{{ version.created_by|default:"系统" }}
+ +
变更说明:
+
{{ version.change_summary|default:"无" }}
+ +
分类:
+
{{ version.category_name }}
+ +
状态:
+
+ {% if version.status == 'p' %}已发布 + {% elif version.status == 'd' %}草稿 + {% else %}{{ version.status }} + {% endif %} +
+
+ +
+ 内容预览: +
+ {{ version.body|truncatewords:200|safe }} +
+
+
+ +
+

当前版本信息

+ +
+
标题:
+
{{ current.title }}
+ +
最后修改:
+
{{ current.last_mod_time|date:"Y-m-d H:i:s" }}
+ +
分类:
+
{{ current.category.name }}
+
+
+ +
+ {% csrf_token %} +
+ + 📊 查看详细对比 + 取消 +
+
+
+{% endblock %} diff --git a/src/templates/blog/tags/article_info.html b/src/templates/blog/tags/article_info.html index 65b45fa..906707c 100644 --- a/src/templates/blog/tags/article_info.html +++ b/src/templates/blog/tags/article_info.html @@ -69,6 +69,151 @@ + + {% if not isindex %} +
+ + + + + +
+ + + {% endif %} + {% load_article_metas article user %} diff --git a/src/templates/blog/tags/article_meta_info.html b/src/templates/blog/tags/article_meta_info.html index ec8a0f9..392d15f 100644 --- a/src/templates/blog/tags/article_meta_info.html +++ b/src/templates/blog/tags/article_meta_info.html @@ -35,6 +35,13 @@ + {% if user.is_authenticated and user != article.author %} + + {% endif %} @@ -54,4 +61,107 @@ + + diff --git a/src/templates/share_layout/base.html b/src/templates/share_layout/base.html index bb17933..c55ec1a 100644 --- a/src/templates/share_layout/base.html +++ b/src/templates/share_layout/base.html @@ -15,6 +15,30 @@ + + + @@ -54,12 +78,16 @@ + {% block compress_css %} {% endblock %} {% plugin_compressed_css %} {% endcompress %} + + + {% if GLOBAL_HEADER %} {{ GLOBAL_HEADER|safe }} {% endif %} @@ -101,6 +129,7 @@ + {% block compress_js %} {% endblock %} -- 2.34.1