添加功能

djq_branch
djq 2 months ago
parent 6d9d624da2
commit 2087f67fa5

@ -4,109 +4,192 @@ from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
# Register your models here.
from .models import Article
# 导入 blog 应用下的所有模型
from .models import (
Article, Category, Tag, Links, SideBar, BlogSettings,
UserProfile, Favorite # 确保导入了 UserProfile 和 Favorite
)
# ------------------------------------------------------------------------------
# Article Admin
# ------------------------------------------------------------------------------
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
def makr_article_publish(modeladmin, request, queryset):
def make_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
make_article_publish.short_description = _('Publish selected articles')
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
draft_article.short_description = _('Set selected articles to draft')
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
close_article_commentstatus.short_description = _('Close comments for selected articles')
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
open_article_commentstatus.short_description = _('Open comments for selected articles')
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')
class ArticlelAdmin(admin.ModelAdmin):
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
'id',
'title',
'author',
'link_to_category',
'creation_time',
'views',
'status',
'type',
'article_order')
'id', 'title', 'author', 'link_to_category',
'pub_time', 'views', 'status', 'type', 'article_order'
)
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
list_filter = ('status', 'type', 'category', 'tags')
filter_horizontal = ('tags',)
# 使用 fieldsets 来组织编辑页面的字段布局,更清晰
fieldsets = (
(_('Basic Information'), {
'fields': ('title', 'author', 'category', 'tags', 'status', 'type')
}),
(_('Content'), {
'fields': ('body',)
}),
(_('Settings'), {
'fields': ('article_order', 'show_toc', 'comment_status'),
'classes': ('collapse',) # 默认折叠
}),
)
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
make_article_publish, draft_article,
close_article_commentstatus, open_article_commentstatus
]
def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('category')
if obj.category:
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'<a href="{}">{}</a>', link, obj.category.name)
return _('None')
link_to_category.short_description = _('Category')
def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
form = super().get_form(request, obj,** kwargs)
# 限制作者只能是超级管理员
form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True)
return form
def save_model(self, request, obj, form, change):
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
if obj:
url = obj.get_full_url()
return url
else:
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
return obj.get_full_url()
return super().get_view_on_site_url(obj)
# ------------------------------------------------------------------------------
# Category Admin
# ------------------------------------------------------------------------------
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
list_display = ('id', 'name', 'parent_category', 'index')
list_display_links = ('id', 'name')
list_filter = ('parent_category',)
search_fields = ('name',)
exclude = ('slug', 'last_mod_time', 'creation_time')
# ------------------------------------------------------------------------------
# Tag Admin
# ------------------------------------------------------------------------------
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ('id', 'name')
list_display_links = ('id', 'name')
search_fields = ('name',)
exclude = ('slug', 'last_mod_time', 'creation_time')
# ------------------------------------------------------------------------------
# Links Admin
# ------------------------------------------------------------------------------
@admin.register(Links)
class LinksAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'link', 'sequence', 'is_enable', 'show_type')
list_display_links = ('id', 'name')
list_filter = ('is_enable', 'show_type')
search_fields = ('name', 'link')
exclude = ('last_mod_time', 'creation_time')
# ------------------------------------------------------------------------------
# SideBar Admin
# ------------------------------------------------------------------------------
@admin.register(SideBar)
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
list_display = ('id', 'name', 'is_enable', 'sequence')
list_display_links = ('id', 'name')
list_filter = ('is_enable',)
search_fields = ('name', 'content')
exclude = ('last_mod_time', 'creation_time')
# ------------------------------------------------------------------------------
# BlogSettings Admin (Singleton Pattern)
# ------------------------------------------------------------------------------
@admin.register(BlogSettings)
class BlogSettingsAdmin(admin.ModelAdmin):
pass
list_display = ('id', 'site_name')
exclude = ('last_mod_time', 'creation_time')
def has_add_permission(self, request):
"""
限制只能有一个配置实例
如果已经存在一条记录则禁用添加按钮
"""
if BlogSettings.objects.exists():
return False
return True
def save_model(self, request, obj, form, change):
"""
确保始终只有一个配置实例
"""
if not change and BlogSettings.objects.exists():
raise ValidationError(_('There can be only one Blog Settings instance.'))
super().save_model(request, obj, form, change)
# ------------------------------------------------------------------------------
# UserProfile Admin
# ------------------------------------------------------------------------------
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'created_at')
list_display_links = ('id', 'user')
list_filter = ('created_at',)
search_fields = ('user__username', 'user__email', 'bio')
fieldsets = (
(_('User'), {
'fields': ('user',)
}),
(_('Profile Information'), {
'fields': ('bio', 'avatar')
}),
(_('Social Links'), {
'fields': ('website', 'github', 'twitter', 'weibo'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at') # 时间戳设为只读
# ------------------------------------------------------------------------------
# Favorite Admin (Optional)
# ------------------------------------------------------------------------------
@admin.register(Favorite)
class FavoriteAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'article', 'created_at')
list_display_links = ('id',)
list_filter = ('created_at',)
search_fields = ('user__username', 'article__title')
readonly_fields = ('created_at',)
# 通常不希望管理员手动创建或修改收藏,所以可以禁用相关权限
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

@ -17,3 +17,22 @@ class BlogSearchForm(SearchForm):
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas
# blog/forms.py
from django import forms
from .models import UserProfile
class UserProfileUpdateForm(forms.ModelForm):
"""
用户资料更新表单
"""
class Meta:
model = UserProfile
fields = ['avatar', 'bio', 'website', 'github', 'twitter', 'weibo']
widgets = {
'bio': forms.Textarea(attrs={'rows': 5, 'placeholder': 'Tell us about yourself...'}),
'website': forms.URLInput(attrs={'placeholder': 'https://'}),
'github': forms.URLInput(attrs={'placeholder': 'https://github.com/'}),
'twitter': forms.URLInput(attrs={'placeholder': 'https://twitter.com/'}),
'weibo': forms.URLInput(attrs={'placeholder': 'https://weibo.com/'}),
}

@ -0,0 +1,30 @@
# Generated by Django 5.2.6 on 2025-11-22 23:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Favorite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='blog.article')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_articles', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '收藏',
'verbose_name_plural': '收藏',
'unique_together': {('article', 'user')},
},
),
]

@ -0,0 +1,37 @@
# Generated by Django 5.2.6 on 2025-11-23 00:03
import blog.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0007_favorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('avatar', models.ImageField(blank=True, help_text='Upload your avatar image (recommended size: 100x100px)', null=True, upload_to=blog.models.user_avatar_path, verbose_name='Avatar')),
('bio', models.TextField(blank=True, help_text='Tell us a little about yourself', null=True, verbose_name='Biography')),
('website', models.URLField(blank=True, null=True, verbose_name='Website')),
('github', models.URLField(blank=True, null=True, verbose_name='GitHub')),
('twitter', models.URLField(blank=True, null=True, verbose_name='Twitter')),
('weibo', models.URLField(blank=True, null=True, verbose_name='Weibo')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'User Profile',
'verbose_name_plural': 'User Profiles',
'ordering': ['-created_at'],
},
),
]

@ -1,5 +1,6 @@
import logging
import re
import os
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
@ -13,8 +14,18 @@ from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
# 确保导入了 post_save 信号和 receiver 装饰器
from django.db.models.signals import post_save
from django.dispatch import receiver
logger = logging.getLogger(__name__)
def user_avatar_path(instance, filename):
"""
定义用户头像的上传路径
"""
# 文件将被上传到 MEDIA_ROOT/user_<id>/avatar/<filename>
return os.path.join(f'user_{instance.user.id}', 'avatar', filename)
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
@ -23,7 +34,6 @@ class LinkShowType(models.TextChoices):
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
@ -56,7 +66,6 @@ class BaseModel(models.Model):
def get_absolute_url(self):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
@ -130,9 +139,6 @@ class Article(BaseModel):
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
@ -210,6 +216,11 @@ class Article(BaseModel):
return related_articles
# 新增:获取文章的收藏数
def get_favorite_count(self):
"""获取文章的收藏数"""
return self.favorites.count()
class Category(BaseModel):
"""文章分类"""
@ -273,7 +284,6 @@ class Category(BaseModel):
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
@ -294,7 +304,6 @@ class Tag(BaseModel):
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
@ -319,7 +328,6 @@ class Links(models.Model):
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
@ -337,7 +345,6 @@ class SideBar(models.Model):
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
@ -407,4 +414,79 @@ class BlogSettings(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()
cache.clear()
class Favorite(models.Model):
"""
文章收藏模型
"""
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='favorites')
# 使用 settings.AUTH_USER_MODEL
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='favorite_articles')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('article', 'user') # 一个用户只能收藏同一篇文章一次
verbose_name = "收藏"
verbose_name_plural = "收藏"
def __str__(self):
return f'{self.user.username} favorites {self.article.title}'
class UserProfile(models.Model):
"""
用户资料扩展模型
用于存储用户头像个人简介等额外信息
"""
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile', verbose_name=_('User'))
# 基本信息
avatar = models.ImageField(
upload_to=user_avatar_path,
blank=True,
null=True,
verbose_name=_('Avatar'),
help_text=_('Upload your avatar image (recommended size: 100x100px)')
)
bio = models.TextField(
blank=True,
null=True,
verbose_name=_('Biography'),
help_text=_('Tell us a little about yourself')
)
# 可选的社交链接
website = models.URLField(blank=True, null=True, verbose_name=_('Website'))
github = models.URLField(blank=True, null=True, verbose_name=_('GitHub'))
twitter = models.URLField(blank=True, null=True, verbose_name=_('Twitter'))
weibo = models.URLField(blank=True, null=True, verbose_name=_('Weibo'))
# 时间戳
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At'))
class Meta:
verbose_name = _('User Profile')
verbose_name_plural = _('User Profiles')
ordering = ['-created_at']
def __str__(self):
return f"{self.user.username}'s Profile"
def get_absolute_url(self):
"""
定义用户资料页的绝对URL
"""
return reverse('blog:user_profile', kwargs={'username': self.user.username})
# 信号当一个新的自定义用户settings.AUTH_USER_MODEL被创建时自动创建一个关联的UserProfile
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def save_user_profile(sender, instance, **kwargs):
# 使用 get_or_create 防止在某些情况下如手动创建用户时profile不存在
UserProfile.objects.get_or_create(user=instance)
instance.profile.save()

@ -341,4 +341,4 @@ def query(qs, **kwargs):
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)
return str(arg1) + str(arg2)

@ -1,108 +1,130 @@
# blog/urls.py
# 导入 Django 内置的路径配置工具和缓存装饰器
from django.urls import path
from django.views.decorators.cache import cache_page
# 导入当前应用blog的视图模块,用于关联路由与视图逻辑
# 导入当前应用blog的视图模块
from . import views
# 定义应用命名空间namespace用于在模板或反向解析时区分不同应用的路由
# 例如:在模板中使用 {% url 'blog:index' %} 生成首页链接
# 定义应用命名空间
app_name = "blog"
# 路由配置列表,每个 path 对应一个 URL 规则与视图的映射
# 路由配置列表
urlpatterns = [
# 首页路由:匹配根路径(网站域名/
# 首页路由
path(
r'', # URL 路径表达式,空字符串表示根路径
views.IndexView.as_view(), # 关联的视图类IndexView通过 as_view() 转换为可调用视图
name='index' # 路由名称,用于反向解析(如 reverse('blog:index')
'',
views.IndexView.as_view(),
name='index'
),
# 分页首页路由:匹配带页码的首页(如 /page/2/
path(
r'page/<int:page>/', # <int:page> 是路径参数int 表示接收整数类型page 是参数名
views.IndexView.as_view(), # 复用首页视图类,视图中会通过 page 参数处理分页
'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'
),
# 文章详情页路由按日期和文章ID匹配如 /article/2023/10/20/100.html
# 文章详情页路由
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
# 路径参数year、month、day、article_id文章ID均为整数
views.ArticleDetailView.as_view(), # 文章详情视图类,处理文章展示逻辑
'article/<int:article_id>/',
views.ArticleDetailView.as_view(),
name='article_detail'
),
# 原始的带日期的URL保持不变
path(
'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'
),
# 分类详情页路由:按分类名匹配(如 /category/tech.html
# 文章收藏功能路由
path(
r'category/<slug:category_name>.html',
# <slug:category_name>slug 类型表示接收字母、数字、下划线和连字符组成的字符串适合URL友好的名称
views.CategoryDetailView.as_view(), # 分类详情视图类,展示该分类下的文章
name='category_detail'
'article/<int:article_id>/favorite/',
views.ArticleFavoriteView.as_view(),
name='article_favorite'
),
# 分类详情页路由:带页码的分类页(如 /category/tech/2.html
# 分类详情页路由
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(), # 复用分类视图类,通过 page 参数分页
'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'
),
path(
'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'
),
# 作者详情页路由:按作者名匹配(如 /author/alice.html
# 作者详情页路由
path(
r'author/<author_name>.html',
# <author_name>:未指定类型,默认接收字符串(除特殊字符外)
views.AuthorDetailView.as_view(), # 作者详情视图类,展示该作者的文章
'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'
),
# 作者详情分页路由:带页码的作者页(如 /author/alice/2.html
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(), # 复用作者视图类,通过 page 参数分页
'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'
),
# 标签详情页路由:按标签名匹配(如 /tag/python.html
# 标签详情页路由
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(), # 标签详情视图类,展示该标签下的文章
'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'
),
# 标签详情分页路由:带页码的标签页(如 /tag/python/2.html
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(), # 复用标签视图类,通过 page 参数分页
'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'
),
# 归档页路由:匹配 /archives.html
# 归档页路由
path(
'archives.html',
# 缓存装饰器cache_page(60*60) 表示缓存该页面1小时60秒*60减轻服务器压力
cache_page(60 * 60)(views.ArchivesView.as_view()),
name='archives' # 归档视图,通常展示按日期分组的文章列表
name='archives'
),
# 友情链接页路由:匹配 /links.html
# 友情链接页路由
path(
'links.html',
views.LinkListView.as_view(), # 友情链接视图类,展示网站链接列表
views.LinkListView.as_view(),
name='links'
),
# 文件上传路由:匹配 /upload
# ==================== 关键修正:调整 URL 顺序 ====================
# 用户资料相关路由
# 将具体的路径放在通用路径之前
path(
r'upload',
views.fileupload, # 关联函数视图(非类视图),处理文件上传逻辑
name='upload'
'profile/edit/',
views.UserProfileUpdateView.as_view(),
name='user_profile_update'
),
# 我的收藏页面路由
path(
'profile/favorites/',
views.UserFavoritesView.as_view(),
name='user_favorites'
),
# 通用的用户资料路径放在最后
path(
'profile/<str:username>/',
views.UserProfileDetailView.as_view(),
name='user_profile'
),
# =================================================================
# 缓存清理路由:匹配 /clean
# 其他功能路由
path(
'upload',
views.fileupload,
name='upload'
),
path(
r'clean',
views.clean_cache_view, # 关联缓存清理视图,用于手动触发缓存清理
'clean',
views.clean_cache_view,
name='clean'
),
]

@ -4,18 +4,26 @@ import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.shortcuts import render, redirect
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.views.generic import View, TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import HttpResponseRedirect
# 导入自定义用户模型
from accounts.models import BlogUser
from blog.models import Article, Category, LinkShowType, Links, Tag, Favorite, UserProfile
from comments.forms import CommentForm
from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
@ -23,7 +31,6 @@ from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
class ArticleListView(ListView):
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
@ -96,7 +103,6 @@ class ArticleListView(ListView):
kwargs['hot_articles'] = hot_articles
return super(ArticleListView, self).get_context_data(** kwargs)
class IndexView(ArticleListView):
'''
首页
@ -112,7 +118,6 @@ class IndexView(ArticleListView):
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
@ -171,6 +176,10 @@ class ArticleDetailView(DetailView):
logger.info('set hot articles cache')
kwargs['hot_articles'] = hot_articles
# 新增:判断当前用户是否收藏了该文章
if self.request.user.is_authenticated:
kwargs['is_favorited'] = Favorite.objects.filter(article=self.object, user=self.request.user).exists()
context = super(ArticleDetailView, self).get_context_data(** kwargs)
article = self.object
# Action Hook, 通知插件"文章详情已获取"
@ -181,7 +190,6 @@ class ArticleDetailView(DetailView):
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
@ -219,7 +227,6 @@ class CategoryDetailView(ArticleListView):
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(** kwargs)
class AuthorDetailView(ArticleListView):
'''
作者详情页
@ -235,6 +242,7 @@ class AuthorDetailView(ArticleListView):
def get_queryset_data(self):
author_name = self.kwargs['author_name']
# 这里使用 BlogUser 或 settings.AUTH_USER_MODEL 都是安全的
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
@ -245,7 +253,6 @@ class AuthorDetailView(ArticleListView):
kwargs['tag_name'] = author_name
return super(AuthorDetailView, self).get_context_data(** kwargs)
class TagDetailView(ArticleListView):
'''
标签列表页面
@ -276,7 +283,6 @@ class TagDetailView(ArticleListView):
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(** kwargs)
class ArchivesView(ArticleListView):
'''
文章归档页面
@ -293,7 +299,7 @@ class ArchivesView(ArticleListView):
cache_key = 'archives'
return cache_key
def get_context_data(self,** kwargs):
def get_context_data(self, **kwargs):
# 归档页单独添加热门文章因继承自ArticleListView但需确保显示
hot_articles_cache_key = 'hot_articles'
hot_articles = cache.get(hot_articles_cache_key)
@ -302,8 +308,7 @@ class ArchivesView(ArticleListView):
cache.set(hot_articles_cache_key, hot_articles, 60 * 60)
logger.info('set hot articles cache')
kwargs['hot_articles'] = hot_articles
return super(ArchivesView, self).get_context_data(**kwargs)
return super(ArchivesView, self).get_context_data(** kwargs)
class LinkListView(ListView):
model = Links
@ -312,7 +317,7 @@ class LinkListView(ListView):
def get_queryset(self):
return Links.objects.filter(is_enable=True)
def get_context_data(self,** kwargs):
def get_context_data(self, **kwargs):
# 链接页添加热门文章
hot_articles_cache_key = 'hot_articles'
hot_articles = cache.get(hot_articles_cache_key)
@ -321,8 +326,7 @@ class LinkListView(ListView):
cache.set(hot_articles_cache_key, hot_articles, 60 * 60)
logger.info('set hot articles cache')
kwargs['hot_articles'] = hot_articles
return super(LinkListView, self).get_context_data(**kwargs)
return super(LinkListView, self).get_context_data(** kwargs)
class EsSearchView(SearchView):
def get_context(self):
@ -337,7 +341,6 @@ class EsSearchView(SearchView):
context['hot_articles'] = hot_articles
return context
@csrf_exempt
def fileupload(request):
"""
@ -379,7 +382,6 @@ def fileupload(request):
else:
return HttpResponse("only for post")
def page_not_found_view(
request,
exception,
@ -393,7 +395,6 @@ def page_not_found_view(
'statuscode': '404'},
status=404)
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
@ -401,7 +402,6 @@ def server_error_view(request, template_name='blog/error_page.html'):
'statuscode': '500'},
status=500)
def permission_denied_view(
request,
exception,
@ -413,7 +413,6 @@ def permission_denied_view(
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'}, status=403)
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
@ -477,4 +476,118 @@ class DjangoBlogFeed(Feed):
返回单个项目文章的发布日期
这是可选的但推荐添加以符合 RSS 规范
"""
return item.creation_time
return item.creation_time
# ======================================================================
# 收藏功能视图 (修正和统一)
# ======================================================================
class ArticleFavoriteView(LoginRequiredMixin, View):
"""
处理文章收藏/取消收藏的视图 (统一处理)
"""
http_method_names = ['post'] # 只允许 POST 请求
def post(self, request, *args, **kwargs):
article_id = kwargs.get('article_id')
article = get_object_or_404(Article, pk=article_id)
# get_or_create 是一个原子操作,能有效防止并发问题
favorite, created = Favorite.objects.get_or_create(article=article, user=request.user)
if not created:
# 如果记录已存在,则删除(取消收藏)
favorite.delete()
is_favorite = False
else:
# 如果是新创建,则表示收藏成功
is_favorite = True
# 返回更新后的收藏数和状态
favorite_count = article.get_favorite_count()
return JsonResponse({'is_favorite': is_favorite, 'favorite_count': favorite_count})
class UserFavoritesView(LoginRequiredMixin, ListView):
"""
展示用户收藏的所有文章 (基于类的视图)
"""
model = Article
template_name = 'blog/user_favorites.html'
context_object_name = 'favorite_articles'
paginate_by = 10 # 每页显示10篇
def get_queryset(self):
# 获取当前用户的所有收藏,并按收藏时间倒序排列
# 使用 select_related 和 prefetch_related 优化数据库查询
return Article.objects.filter(
favorites__user=self.request.user
).select_related('author', 'category').prefetch_related('tags').order_by('-favorites__created_at')
def get_context_data(self,** kwargs):
context = super().get_context_data(**kwargs)
# 添加 profile_user 用于复用个人资料页面的侧边栏
context['profile_user'] = self.request.user
return context
# ======================================================================
# 用户资料视图
# ======================================================================
from django.views.generic import UpdateView
from django.urls import reverse_lazy
from .forms import UserProfileUpdateForm # 确保你有这个表单文件
class UserProfileDetailView(DetailView):
"""
显示用户公开资料的视图
"""
model = UserProfile
template_name = 'blog/user_profile_detail.html'
context_object_name = 'profile'
def get_object(self, queryset=None):
"""通过用户名查找用户,然后返回其 profile"""
username = self.kwargs.get('username')
# 修正:使用自定义的 BlogUser 模型进行查询
user = get_object_or_404(BlogUser, username=username)
return get_object_or_404(UserProfile, user=user)
def get_context_data(self, **kwargs):
"""添加额外的上下文数据,例如用户发布的文章"""
context = super().get_context_data(** kwargs)
user = self.object.user # 从 profile 对象获取 user
# 获取该用户发布的所有公开文章,并按发布时间排序
context['user_articles'] = Article.objects.filter(author=user, status='p').order_by('-pub_time')[:10]
return context
class UserProfileUpdateView(LoginRequiredMixin, UpdateView):
"""
用户编辑自己资料的视图
"""
model = UserProfile
form_class = UserProfileUpdateForm
template_name = 'blog/user_profile_update.html'
def get_queryset(self):
"""
重写此方法以确保用户只能编辑自己的资料
这是对象级权限控制的最佳实践
"""
# 只返回与当前登录用户关联的 UserProfile 对象
return UserProfile.objects.filter(user=self.request.user)
def get_object(self, queryset=None):
"""
用户只能编辑自己的 profile
此方法与 get_queryset 结合提供了双重保障
"""
return self.request.user.profile
def get_success_url(self):
"""编辑成功后重定向到自己的资料页"""
return reverse_lazy('blog:user_profile', kwargs={'username': self.request.user.username})
def form_valid(self, form):
"""表单验证成功后,显示成功消息"""
messages.success(self.request, 'Your profile has been updated successfully!')
return super().form_valid(form)

@ -40,7 +40,7 @@ class DjangoBlogAdminSite(AdminSite):
admin_site = DjangoBlogAdminSite(name='admin')
admin_site.register(Article, ArticlelAdmin)
admin_site.register(Article, ArticleAdmin)
admin_site.register(Category, CategoryAdmin)
admin_site.register(Tag, TagAdmin)
admin_site.register(Links, LinksAdmin)

@ -12,15 +12,8 @@ https://docs.djangoproject.com/en/1.10/ref/settings/
import os
import sys
from pathlib import Path
from django.utils.translation import gettext_lazy as _
def env_to_bool(env, default):
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@ -30,18 +23,17 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
DEBUG = os.environ.get('DJANGO_DEBUG', 'True') == 'True'
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
CSRF_TRUSTED_ORIGINS = os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhost,http://127.0.0.1').split(',')
# Application definition
INSTALLED_APPS = [
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
@ -65,7 +57,6 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
@ -104,8 +95,6 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
@ -113,15 +102,16 @@ DATABASES = {
'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root',
'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or '123456',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'PORT': int(os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'OPTIONS': {
'charset': 'utf8mb4'},
}}
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -147,18 +137,31 @@ LOCALE_PATHS = (
)
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
# --- MODIFICATION: 自动创建静态文件目录 ---
STATIC_DIR = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = [STATIC_DIR]
# 如果 static 目录不存在,则自动创建
if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR)
# --- MODIFICATION END ---
# 媒体文件配置 (用户上传的文件,如头像)
# 确保此配置已正确设置
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# 同样,自动创建媒体文件目录
if not os.path.exists(MEDIA_ROOT):
os.makedirs(MEDIA_ROOT)
HAYSTACK_CONNECTIONS = {
'default': {
@ -168,15 +171,11 @@ HAYSTACK_CONNECTIONS = {
}
# Automatically update searching index
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Allow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
AUTH_USER_MODEL = 'accounts.BlogUser'
LOGIN_URL = '/login/'
@ -192,6 +191,7 @@ BOOTSTRAP_COLOR_TYPES = [
PAGINATE_BY = 10
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
CACHES = {
'default': {
@ -215,16 +215,18 @@ BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
# Email:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
EMAIL_USE_TLS = os.environ.get('DJANGO_EMAIL_TLS', 'False') == 'True'
EMAIL_USE_SSL = os.environ.get('DJANGO_EMAIL_SSL', 'True') == 'True'
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
# Setting debug=false did NOT handle except email notifications
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# WX ADMIN password(Two times md5)
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
@ -300,9 +302,13 @@ STATICFILES_FINDERS = (
# other
'compressor.finders.CompressorFinder',
)
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
# --- MODIFICATION: 动态设置压缩开关 ---
# 在开发环境(DEBUG=True)关闭压缩,在生产环境(DEBUG=False)开启压缩
COMPRESS_ENABLED = not DEBUG
# --- MODIFICATION END ---
# COMPRESS_OFFLINE = True
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
@ -314,8 +320,6 @@ COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
X_FRAME_OPTIONS = 'SAMEORIGIN'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@ -340,4 +344,17 @@ ACTIVE_PLUGINS = [
'external_links',
'view_count',
'seo_optimizer'
]
]
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# ... 其他配置 ...
# 媒体文件(用户上传的文件)的存储根目录
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# 媒体文件的 URL 前缀。浏览器通过这个 URL 来访问媒体文件。
MEDIA_URL = '/media/'

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

@ -1,5 +1,6 @@
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load static %}
{% block header %}
{% endblock %}
@ -7,7 +8,10 @@
{% block content %}
<div id="primary" class="site-content">
<div id="content" role="main">
{% load_article_detail article False user %}
<!-- 修复:确保标签参数完整 -->
<div id="article-content">
{% load_article_detail article False user %}
</div>
{% if article.type == 'a' %}
<nav class="nav-single">
@ -24,7 +28,36 @@
</nav><!-- .nav-single -->
{% endif %}
<!-- ===================== 新增:相关推荐文章区域 ===================== -->
<!-- 文章收藏功能 -->
<div style="margin: 20px 0; padding: 10px; border-top: 1px solid #eee; border-bottom: 1px solid #eee;">
{% if user.is_authenticated %}
{# 为按钮添加一个 data-action 属性用于JS判断当前操作 #}
<button id="favorite-btn"
data-article-id="{{ article.id }}"
data-action="{% if article in user.favorite_articles.all %}remove{% else %}add{% endif %}"
style="background: none; border: none; cursor: pointer; color: #333; font-size: 1em;">
{% if article in user.favorite_articles.all %}
<i class="fas fa-bookmark" style="color: #f0ad4e;"></i>
<span>已收藏</span>
{% else %}
<i class="far fa-bookmark"></i>
<span>收藏</span>
{% endif %}
<span id="favorite-count" style="margin-left: 5px;">({{ article.get_favorite_count }})</span>
</button>
{% else %}
<span style="color: #999;">
<i class="far fa-bookmark"></i>
<span>收藏</span>
<span style="margin-left: 5px;">({{ article.get_favorite_count }})</span>
</span>
<p style="margin-top: 5px; font-size: 0.9em; color: #999;">
<a href="{% url "account:login" %}?next={{ request.get_full_path }}">登录</a> 后收藏文章
</p>
{% endif %}
</div>
<!-- 相关推荐文章区域 -->
{% if related_articles %}
<section class="related-posts">
<h3 class="related-title">相关推荐</h3>
@ -42,7 +75,6 @@
<time datetime="{{ rel_article.pub_time|date:"c" }}">
{{ rel_article.pub_time|date:"Y年m月d日" }}
</time>
<!-- 新增:在相关推荐中显示阅读量 -->
<span class="views"> | 阅读量:{{ rel_article.views }}</span>
</div>
</header>
@ -52,7 +84,6 @@
</ul>
</section>
{% endif %}
<!-- ===================== 相关推荐区域结束 ===================== -->
</div><!-- #content -->
@ -75,4 +106,61 @@
{% block sidebar %}
{% load_sidebar user "p" %}
{% endblock %}
{% block footer %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
<script>
$(document).ready(function() {
// 收藏功能 JS
$('#favorite-btn').on('click', function() {
var $btn = $(this);
var articleId = $btn.data('article-id');
var action = $btn.data('action'); // 获取当前操作add 或 remove
// 动态生成URL
// 修改后的代码
// 无论 add 还是 remove都使用同一个 URL 名称 article_favorite
var url = "{% url 'blog:article_favorite' article_id=999999999 %}".replace('999999999', articleId);
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
beforeSend: function(xhr) {
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
},
success: function(data) {
var $icon = $btn.find('i');
var $textSpan = $btn.find('span:eq(0)'); // 找到第一个span'收藏'/'已收藏'
var $countSpan = $('#favorite-count');
// 更新收藏数
$countSpan.text('(' + data.favorite_count + ')');
// 更新按钮状态和文本
if (action === 'add') {
$icon.removeClass('far').addClass('fas').css('color', '#f0ad4e');
$textSpan.text('已收藏');
$btn.data('action', 'remove'); // 切换action状态
} else {
$icon.removeClass('fas').addClass('far').css('color', '#333');
$textSpan.text('收藏');
$btn.data('action', 'add'); // 切换action状态
}
},
error: function(xhr, status, error) {
console.error("收藏操作失败:", error);
// 尝试解析错误信息
var errorMsg = "操作失败,请稍后重试。";
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMsg = xhr.responseJSON.message;
}
alert(errorMsg);
}
});
});
});
</script>
{% endblock %}

@ -0,0 +1,97 @@
<!-- blog/templates/blog/user_favorites.html -->
{% extends 'share_layout/base.html' %}
{% load static %}
{% load blog_tags %}
{% block header %}
<title>我的收藏 | {{ SITE_NAME }}</title>
{% endblock %}
{% block content %}
<div id="primary" class="site-content">
<div id="content" role="main">
<article class="hentry">
<header class="entry-header">
<h1 class="entry-title">我的收藏</h1>
<p>这里是你收藏的所有文章。</p>
</header>
<div class="entry-content">
{% if favorite_articles %}
<ul class="article-list">
{% for article in favorite_articles %}
<li>
<h2><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></h2>
<div class="meta">
<time datetime="{{ article.pub_time|date:"c" }}">
{{ article.pub_time|date:"F j, Y" }}
</time>
<span>作者: <a href="{% url 'blog:author_detail' article.author.username %}">{{ article.author.username }}</a></span>
<!-- 修正第 31 行的 URL 名称 -->
<span>分类:<a href="{% url 'blog:category_detail' article.category.slug %}">{{ article.category.name }}</a></span>
</div>
<div class="summary">
{{ article.body|striptags|truncatechars:150 }}
</div>
<a href="{{ article.get_absolute_url }}" class="read-more">阅读全文</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="no-favorites">你还没有收藏任何文章。快去 <a href="{% url 'blog:index' %}">文章列表</a> 看看吧!</p>
{% endif %}
</div>
</article>
</div>
</div>
{% endblock %}
{% block sidebar %}
{% load_sidebar profile_user "p" %}
{% endblock %}
{% block extra_footer %}
<style>
.article-list {
list-style: none;
padding: 0;
}
.article-list li {
padding: 20px 0;
border-bottom: 1px solid #eee;
}
.article-list li:last-child {
border-bottom: none;
}
.article-list h2 {
margin-top: 0;
}
.meta {
color: #777;
font-size: 0.9em;
margin-bottom: 10px;
}
.summary {
margin-bottom: 15px;
}
.read-more {
display: inline-block;
padding: 5px 10px;
background-color: #007bff;
color: white;
border-radius: 3px;
text-decoration: none;
}
.read-more:hover {
background-color: #0056b3;
color: white;
}
.no-favorites {
text-align: center;
padding: 50px 0;
color: #555;
}
</style>
{% endblock %}

@ -0,0 +1,230 @@
<!-- blog/templates/blog/user_profile_detail.html -->
{% extends 'share_layout/base.html' %}
{% load static %}
{% load blog_tags %} <!-- 添加这一行,加载自定义标签库 -->
{% block header %}
<title>{{ profile.user.username }}'s Profile | {{ SITE_NAME }}</title>
{% endblock %}
{% block content %}
<div id="primary" class="site-content">
<div id="content" role="main">
<article class="hentry">
<header class="entry-header profile-header">
<div class="profile-avatar">
{% if profile.avatar %}
<img src="{{ profile.avatar.url }}" alt="{{ profile.user.username }}'s avatar">
{% else %}
<img src="{% static 'blog/images/default-avatar.png' %}" alt="Default Avatar">
{% endif %}
</div>
<div class="profile-info">
<h1 class="entry-title">{{ profile.user.username }}</h1>
<div class="profile-meta">
<span>Joined on {{ profile.created_at|date:"F j, Y" }}</span>
{% if user.is_authenticated and user == profile.user %}
<a href="{% url 'blog:user_profile_update' %}" class="edit-profile-btn">
Edit Profile
</a>
{% endif %}
</div>
</div>
</header>
<div class="entry-content profile-content">
{% if profile.bio %}
<section class="profile-bio">
<h3>About Me</h3>
<p>{{ profile.bio|linebreaks }}</p>
</section>
{% endif %}
{% if profile.website or profile.github or profile.twitter or profile.weibo %}
<section class="profile-links">
<h3>Connect with Me</h3>
<ul>
{% if profile.website %}
<li><a href="{{ profile.website }}" target="_blank" rel="noopener noreferrer"><i class="fas fa-globe"></i> {{ profile.website }}</a></li>
{% endif %}
{% if profile.github %}
<li><a href="{{ profile.github }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-github"></i> GitHub</a></li>
{% endif %}
{% if profile.twitter %}
<li><a href="{{ profile.twitter }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i> Twitter</a></li>
{% endif %}
{% if profile.weibo %}
<li><a href="{{ profile.weibo }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-weibo"></i> Weibo</a></li>
{% endif %}
</ul>
</section>
{% endif %}
<!-- ==================== 新增:我的收藏链接 ==================== -->
{% if user.is_authenticated and user == profile.user %}
<section class="profile-actions">
<a href="{% url 'blog:user_favorites' %}" class="btn-favorite">
<i class="fas fa-heart"></i> 我的收藏 ({{ user.favorite_articles.count }})
</a>
</section>
{% endif %}
<!-- ========================================================== -->
</div>
</article>
{% if user_articles %}
<section class="profile-articles">
<h2>Articles by {{ profile.user.username }} <span>({{ user_articles|length }})</span></h2>
<ul>
{% for article in user_articles %}
<li>
<a href="{{ article.get_absolute_url }}">{{ article.title }}</a>
<time datetime="{{ article.pub_time|date:"c" }}">{{ article.pub_time|date:"F j, Y" }}</time>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% block sidebar %}
{% load_sidebar user "p" %}
{% endblock %}
{% block extra_footer %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
<style>
/* 个人资料头部布局 */
.profile-header {
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px; /* 增加了下边距,与内容区隔开 */
}
/* 头像样式调整 */
.profile-avatar img {
width: 80px; /* 从 120px 调整为 80px */
height: 80px; /* 从 120px 调整为 80px */
border-radius: 50%;
object-fit: cover;
margin-right: 20px; /* 稍微减少了右边距 */
border: 3px solid #fff;
box-shadow: 0 2px 5px rgba(0,0,0,0.1); /* 增强了一点阴影 */
}
/* 个人信息区域 */
.profile-info h1 {
margin-bottom: 5px; /* 减少了标题下边距 */
}
.edit-profile-btn {
display: inline-block;
margin-top: 10px;
padding: 5px 15px;
background-color: #007bff;
color: white;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em; /* 字体稍小 */
}
.edit-profile-btn:hover {
background-color: #0056b3;
color: white;
}
/* 内容区块通用样式 */
.profile-bio, .profile-links, .profile-articles, .profile-actions { /* 新增 .profile-actions */
margin-bottom: 30px;
padding: 20px;
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #eee; /* 增加了一个边框 */
}
/* ==================== 新增:我的收藏链接样式 ==================== */
.btn-favorite {
display: inline-flex; /* 使用 flex 布局让图标和文字垂直居中 */
align-items: center;
padding: 10px 20px;
background-color: #f0ad4e; /* 使用一个醒目的颜色,如橙色 */
color: white;
border-radius: 4px;
text-decoration: none;
font-weight: bold;
transition: background-color 0.2s ease;
}
.btn-favorite i {
margin-right: 8px;
font-size: 1.1em;
}
.btn-favorite:hover {
background-color: #ec971f; /* hover 时颜色加深 */
color: white;
}
/* ================================================================ */
/* 链接列表 */
.profile-links ul {
list-style: none;
padding: 0;
}
.profile-links li {
margin-bottom: 10px;
}
.profile-links a {
display: flex;
align-items: center;
color: #007bff;
text-decoration: none;
transition: color 0.2s; /* 增加了过渡效果 */
}
.profile-links a:hover {
text-decoration: underline;
color: #0056b3; /* hover 时颜色加深 */
}
.profile-links i {
margin-right: 10px;
font-size: 1.2em;
width: 20px;
text-align: center;
color: #555; /* 图标颜色稍微暗一点 */
}
/* 文章列表 */
.profile-articles ul {
list-style: none;
padding: 0;
}
.profile-articles li {
padding: 10px 0;
border-bottom: 1px dotted #ddd;
display: flex;
justify-content: space-between;
align-items: center; /* 垂直居中对齐 */
}
.profile-articles li:last-child {
border-bottom: none; /* 去掉最后一个的边框 */
}
.profile-articles li time {
color: #777;
font-size: 0.9em;
}
</style>
{% endblock %}

@ -0,0 +1,104 @@
<!-- blog/templates/blog/user_profile_update.html -->
{% extends 'share_layout/base.html' %}
{% load static %}
{% load blog_tags %} <!-- 加载自定义标签库 -->
{% block header %}
<title>Edit Profile | {{ SITE_NAME }}</title>
{% endblock %}
{% block content %}
<div id="primary" class="site-content">
<div id="content" role="main">
<article class="hentry">
<header class="entry-header">
<h1 class="entry-title">Edit Your Profile</h1>
</header>
<div class="entry-content">
<form enctype="multipart/form-data" method="post" action="{% url 'blog:user_profile_update' %}">
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger">
<strong>Please correct the errors below.</strong>
</div>
{% endif %}
<div class="form-group">
<label for="{{ form.avatar.id_for_label }}">Current Avatar:</label><br>
{% if user.profile.avatar %}
<img src="{{ user.profile.avatar.url }}" alt="Current Avatar" style="width: 100px; border-radius: 50%; margin-bottom: 10px;">
{% else %}
<img src="{% static 'blog/images/default-avatar.png' %}" alt="Default Avatar" style="width: 100px; border-radius: 50%; margin-bottom: 10px;">
{% endif %}
<br>
{{ form.avatar.label_tag }} {{ form.avatar }}
<small class="form-text text-muted">{{ form.avatar.help_text }}</small>
{{ form.avatar.errors }}
</div>
<div class="form-group">
{{ form.bio.label_tag }}
{{ form.bio }}
{{ form.bio.errors }}
</div>
<div class="form-group">
{{ form.website.label_tag }}
{{ form.website }}
{{ form.website.errors }}
</div>
<div class="form-group">
{{ form.github.label_tag }}
{{ form.github }}
{{ form.github.errors }}
</div>
<div class="form-group">
{{ form.twitter.label_tag }}
{{ form.twitter }}
{{ form.twitter.errors }}
</div>
<div class="form-group">
{{ form.weibo.label_tag }}
{{ form.weibo }}
{{ form.weibo.errors }}
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{% url 'blog:user_profile' username=user.username %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</article>
</div>
</div>
{% endblock %}
{% block sidebar %}
{% load_sidebar user "p" %}
{% endblock %}
{% block extra_footer %}
<style>
.form-group { margin-bottom: 20px; }
label { font-weight: bold; display: block; margin-bottom: 5px; }
input[type="text"], input[type="url"], textarea {
width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;
}
textarea { resize: vertical; }
.btn {
display: inline-block; padding: 10px 16px; margin-bottom: 0; font-size: 14px; font-weight: 400; line-height: 1.42857143; text-align: center; white-space: nowrap; vertical-align: middle; cursor: pointer; background-image: none; border: 1px solid transparent; border-radius: 4px; text-decoration: none;
}
.btn-primary { color: #fff; background-color: #337ab7; border-color: #2e6da4; }
.btn-primary:hover { background-color: #286090; border-color: #204d74; color: #fff; }
.btn-secondary { color: #333; background-color: #fff; border-color: #ccc; }
.btn-secondary:hover { background-color: #e6e6e6; border-color: #adadad; color: #333; }
.alert { padding: 15px; margin-bottom: 20px; border: 1px solid transparent; border-radius: 4px; }
.alert-danger { color: #a94442; background-color: #f2dede; border-color: #ebccd1; }
</style>
{% endblock %}

@ -43,6 +43,21 @@
<!-- 本地字体加载 -->
<link rel="stylesheet" href="{% static 'blog/fonts/open-sans.css' %}">
<!-- 新增:阅读进度条样式 -->
<style>
#reading-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background-color: #007bff; /* 你可以更改进度条颜色 */
z-index: 9999; /* 确保在最上层 */
width: 0%;
transition: width 0.1s ease; /* 平滑过渡效果 */
}
</style>
{% compress css %}
<link rel='stylesheet' id='twentytwelve-style-css' href='{% static 'blog/css/style.css' %}' type='text/css'
@ -57,38 +72,37 @@
{% block compress_css %}
{% endblock %}
{% endcompress %}
{% if GLOBAL_HEADER %}
{{ GLOBAL_HEADER|safe }}
{% endif %}
</head>
<body class="home blog custom-font-enabled">
<div id="page" class="hfeed site">
<header id="masthead" class="site-header" role="banner">
<hgroup>
<h1 class="site-title"><a href="/" title="{{ SITE_NAME }}" rel="home">{{ SITE_NAME }}</a>
</h1>
<h2 class="site-description">{{ SITE_DESCRIPTION }}</h2>
</hgroup>
{% load i18n %}
{% include 'share_layout/nav.html' %}
<!-- 新增:阅读进度条 DOM 元素 -->
<div id="reading-progress-bar"></div>
</header><!-- #masthead -->
<div id="main" class="wrapper">
{% block content %}
{% endblock %}
<div id="page" class="hfeed site">
<header id="masthead" class="site-header" role="banner">
<hgroup>
<h1 class="site-title"><a href="/" title="{{ SITE_NAME }}" rel="home">{{ SITE_NAME }}</a>
</h1>
<h2 class="site-description">{{ SITE_DESCRIPTION }}</h2>
</hgroup>
{% load i18n %}
{% include 'share_layout/nav.html' %}
</header><!-- #masthead -->
<div id="main" class="wrapper">
{% block content %}
{% endblock %}
{% block sidebar %}
{% endblock %}
{% block sidebar %}
{% endblock %}
</div><!-- #main .wrapper -->
</div><!-- #main .wrapper -->
{% include 'share_layout/footer.html' %}
</div><!-- #page -->
{% include 'share_layout/footer.html' %}
</div><!-- #page -->
<!-- JavaScript资源 -->
{% compress js %}
@ -99,11 +113,45 @@
{% block compress_js %}
{% endblock %}
{% endcompress %}
<!-- MathJax智能加载器 -->
<script src="{% static 'blog/js/mathjax-loader.js' %}" async defer></script>
<!-- 新增:阅读进度条 JavaScript -->
<script>
(function($) {
$(document).ready(function() {
var $progressBar = $('#reading-progress-bar');
var $articleContent = $('#article-content'); // 假设文章内容容器的ID是 'article-content'
// 仅在文章详情页执行
if ($articleContent.length > 0) {
var articleTop = $articleContent.offset().top;
var articleHeight = $articleContent.outerHeight();
var windowHeight = $(window).height();
var scrollableDistance = articleHeight - windowHeight;
$(window).on('scroll', function() {
var scrollPosition = $(window).scrollTop();
// 计算阅读进度百分比
var scrollPercent = (scrollPosition - articleTop) / scrollableDistance;
// 确保百分比在 0 到 1 之间
scrollPercent = Math.max(0, Math.min(1, scrollPercent));
// 更新进度条宽度
$progressBar.css('width', (scrollPercent * 100) + '%');
});
// 初始化时触发一次滚动事件,以设置初始状态
$(window).trigger('scroll');
}
});
})(jQuery);
</script>
{% block footer %}
{% endblock %}
</body>
</html>
</html>

@ -1,4 +1,5 @@
{% load i18n %}
{% load blog_tags %} <!-- 确保 blog_tags 已加载 -->
<nav id="site-navigation" class="main-navigation" role="navigation">
<div class="menu-%e8%8f%9c%e5%8d%95-container">
@ -7,24 +8,124 @@
class="menu-item menu-item-type-custom menu-item-object-custom current-menu-item current_page_item menu-item-home menu-item-3498">
<a href="/">{% trans 'index' %}</a></li>
{% load blog_tags %}
{% query nav_category_list parent_category=None as root_categorys %}
{% for node in root_categorys %}
{% include 'share_layout/nav_node.html' %}
{% endfor %}
{% if nav_pages %}
{% for node in nav_pages %}
<li id="menu-item-{{ node.pk }}"
class="menu-item menu-item-type-taxonomy menu-item-object-category menu-item-has-children menu-item-{{ node.pk }}">
<a href="{{ node.get_absolute_url }}">{{ node.title }}</a>
</li>
{% endfor %}
{% endif %}
<li class="menu-item menu-item-type-taxonomy menu-item-object-category menu-item-has-children">
<li class="menu-item menu-item-type-taxonomy menu-item-object-category menu-item-has-children">
<a href="{% url "blog:archives" %}">{% trans 'Article archive' %}</a>
</li>
<!-- 新增:用户菜单 -->
<li class="menu-item menu-item-type-custom menu-item-object-custom menu-item-has-children user-menu">
{% if user.is_authenticated %}
<a href="javascript:void(0);">
{% if user.profile.avatar %}
<img src="{{ user.profile.avatar.url }}" alt="{{ user.username }}" class="avatar">
{% else %}
<i class="fas fa-user"></i>
{% endif %}
{{ user.username }}
</a>
<ul class="sub-menu">
<li class="menu-item">
<a href="{% url 'blog:user_profile' username=user.username %}">
<i class="fas fa-id-card"></i> {% trans 'My Profile' %}
</a>
</li>
<li class="menu-item">
<a href="{% url 'blog:user_profile_update' %}">
<i class="fas fa-edit"></i> {% trans 'Edit Profile' %}
</a>
</li>
<li class="menu-item">
<a href="{% url 'account:logout' %}"> <!-- 假设你的登出URL名称是 account:logout -->
<i class="fas fa-sign-out-alt"></i> {% trans 'Logout' %}
</a>
</li>
</ul>
{% else %}
<a href="{% url 'account:login' %}"> <!-- 假设你的登录URL名称是 account:login -->
<i class="fas fa-sign-in-alt"></i> {% trans 'Login / Register' %}
</a>
{% endif %}
</li>
<!-- 用户菜单结束 -->
</ul>
</div>
</nav><!-- #site-navigation -->
</nav><!-- #site-navigation -->
<!-- 新增:引入 Font Awesome 用于显示图标 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
<!-- 新增:一些简单的 CSS 来美化用户菜单 -->
<style>
/* 让用户菜单靠右浮动 */
.nav-menu .user-menu {
float: right;
position: relative;
}
/* 美化头像 */
.nav-menu .user-menu .avatar {
width: 24px;
height: 24px;
border-radius: 50%;
vertical-align: middle;
margin-right: 8px;
}
/* 子菜单样式 */
.nav-menu .user-menu .sub-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
background: #fff;
min-width: 180px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 10px 0;
z-index: 1000;
border-radius: 4px;
}
.nav-menu .user-menu:hover .sub-menu {
display: block;
}
.nav-menu .user-menu .sub-menu li {
padding: 0;
margin: 0;
list-style: none;
}
.nav-menu .user-menu .sub-menu a {
display: block;
padding: 10px 20px;
color: #333;
text-decoration: none;
white-space: nowrap;
}
.nav-menu .user-menu .sub-menu a:hover {
background-color: #f5f5f5;
color: #007bff;
}
.nav-menu .user-menu .sub-menu i {
width: 20px; /* 图标宽度固定,使文字对齐 */
text-align: center;
margin-right: 8px;
}
</style>
Loading…
Cancel
Save