master
zhangyu 3 months ago
parent a2784f1681
commit 60c7d20074

Binary file not shown.

@ -0,0 +1,73 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, get_object_or_404
from django.views.generic.edit import CreateView
from django.urls import reverse_lazy
from .models import Article, Category, Tag, ArticleImage
from .forms import ArticleForm
class ArticleCreateView(LoginRequiredMixin, CreateView):
"""创建文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_create.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def form_valid(self, form):
# 先不保存表单,先处理分类
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
# 直接设置分类对象,而不是名称
form.instance.category = category
# 先保存文章实例,以便可以添加多对多关系
# 但在保存前先从表单中移除category字段避免表单尝试保存它
category_temp = form.cleaned_data.pop('category')
tags_temp = form.cleaned_data.pop('tags')
response = super().form_valid(form)
# 处理标签
if tags_temp:
tag_names = [tag.strip() for tag in tags_temp.split(',') if tag.strip()]
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
# 关联临时图片到新创建的文章
# 获取会话中的临时图片ID列表
temp_image_ids = self.request.session.get('temp_image_ids', [])
# 查找这些临时图片
temp_images = ArticleImage.objects.filter(id__in=temp_image_ids)
# 将这些图片关联到新创建的文章
for image in temp_images:
image.article = form.instance
image.save()
# 清除会话中的临时图片ID列表
self.request.session['temp_image_ids'] = []
self.request.session.modified = True
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})

@ -0,0 +1,46 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, get_object_or_404
from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy
from .models import Article, Category, Tag, ArticleImage
class ArticleDeleteView(LoginRequiredMixin, DeleteView):
"""删除文章视图"""
model = Article
template_name = 'blog/article_delete_confirm.html'
pk_url_kwarg = 'article_id'
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
# 只有文章作者或管理员可以删除
if obj.author != request.user and not request.user.is_superuser:
return redirect('blog:detailbyid',
article_id=obj.id,
year=obj.creation_time.year,
month=obj.creation_time.month,
day=obj.creation_time.day)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def delete(self, request, *args, **kwargs):
# 删除文章前先删除所有相关图片
self.object = self.get_object()
images = ArticleImage.objects.filter(article=self.object)
for image in images:
# 删除图片文件
image.image.delete()
# 删除图片记录
image.delete()
# 调用父类的delete方法删除文章
return super().delete(request, *args, *kwargs)
def get_success_url(self):
return reverse_lazy('blog:index')

@ -0,0 +1,101 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, get_object_or_404
from django.views.generic.edit import UpdateView
from django.urls import reverse_lazy
from .models import Article, Category, Tag, ArticleImage
from .forms import ArticleForm
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
"""更新文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_edit.html'
pk_url_kwarg = 'article_id'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def get_initial(self):
initial = super().get_initial()
# 设置分类初始值
if self.object.category:
initial['category'] = self.object.category.name
# 设置标签初始值
if self.object.tags.exists():
tag_names = [tag.name for tag in self.object.tags.all()]
initial['tags'] = ', '.join(tag_names)
return initial
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
# 只有文章作者或管理员可以编辑
if obj.author != request.user and not request.user.is_superuser:
return redirect('blog:detailbyid',
article_id=obj.id,
year=obj.creation_time.year,
month=obj.creation_time.month,
day=obj.creation_time.day)
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
# 先不保存表单,先处理分类
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
# 直接设置分类对象,而不是名称
form.instance.category = category
# 先保存文章实例,以便可以添加多对多关系
# 但在保存前先从表单中移除category字段避免表单尝试保存它
category_temp = form.cleaned_data.pop('category')
tags_temp = form.cleaned_data.pop('tags')
response = super().form_valid(form)
# 处理标签
if tags_temp:
tag_names = [tag.strip() for tag in tags_temp.split(',') if tag.strip()]
form.instance.tags.clear() # 清除现有标签
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
else:
form.instance.tags.clear() # 如果没有标签,清除所有标签
# 关联临时图片到文章
# 获取会话中的临时图片ID列表
temp_image_ids = self.request.session.get('temp_image_ids', [])
# 查找这些临时图片
temp_images = ArticleImage.objects.filter(id__in=temp_image_ids)
# 将这些图片关联到文章
for image in temp_images:
image.article = form.instance
image.save()
# 清除会话中的临时图片ID列表
self.request.session['temp_image_ids'] = []
self.request.session.modified = True
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})

@ -0,0 +1,135 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, get_object_or_404
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from .models import Article, Category, Tag, ArticleImage
from .forms import ArticleForm
class ArticleCreateView(LoginRequiredMixin, CreateView):
"""创建文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_create.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def form_valid(self, form):
# 先不保存表单,先处理分类
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
# 直接设置分类对象,而不是名称
form.instance.category = category
# 先保存文章实例,以便可以添加多对多关系
# 但在保存前先从表单中移除category字段避免表单尝试保存它
category_temp = form.cleaned_data.pop('category')
tags_temp = form.cleaned_data.pop('tags')
response = super().form_valid(form)
# 处理标签
if tags_temp:
tag_names = [tag.strip() for tag in tags_temp.split(',') if tag.strip()]
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
"""更新文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_edit.html'
pk_url_kwarg = 'article_id'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def get_initial(self):
initial = super().get_initial()
# 设置分类初始值
if self.object.category:
initial['category'] = self.object.category.name
# 设置标签初始值
if self.object.tags.exists():
tag_names = [tag.name for tag in self.object.tags.all()]
initial['tags'] = ', '.join(tag_names)
return initial
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
# 只有文章作者或管理员可以编辑
if obj.author != request.user and not request.user.is_superuser:
return redirect('blog:detailbyid',
article_id=obj.id,
year=obj.creation_time.year,
month=obj.creation_time.month,
day=obj.creation_time.day)
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
# 先不保存表单,先处理分类
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
# 直接设置分类对象,而不是名称
form.instance.category = category
# 先保存文章实例,以便可以添加多对多关系
# 但在保存前先从表单中移除category字段避免表单尝试保存它
category_temp = form.cleaned_data.pop('category')
tags_temp = form.cleaned_data.pop('tags')
response = super().form_valid(form)
# 处理标签
if tags_temp:
tag_names = [tag.strip() for tag in tags_temp.split(',') if tag.strip()]
form.instance.tags.clear() # 清除现有标签
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
else:
form.instance.tags.clear() # 如果没有标签,清除所有标签
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})

@ -0,0 +1,153 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, get_object_or_404
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from .models import Article, Category, Tag, ArticleImage
from .forms import ArticleForm
class ArticleCreateView(LoginRequiredMixin, CreateView):
"""创建文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_create.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def form_valid(self, form):
# 先不保存表单,先处理分类
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
# 直接设置分类对象,而不是名称
form.instance.category = category
# 先保存文章实例,以便可以添加多对多关系
# 但在保存前先从表单中移除category字段避免表单尝试保存它
category_temp = form.cleaned_data.pop('category')
tags_temp = form.cleaned_data.pop('tags')
response = super().form_valid(form)
# 处理标签
if tags_temp:
tag_names = [tag.strip() for tag in tags_temp.split(',') if tag.strip()]
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
# 处理临时上传的图片
temp_image_ids = self.request.session.get('temp_image_ids', [])
if temp_image_ids:
# 关联这些图片到当前文章
ArticleImage.objects.filter(id__in=temp_image_ids).update(article=self.object)
# 清除会话中的临时图片ID列表
self.request.session['temp_image_ids'] = []
self.request.session.modified = True
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
"""更新文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_edit.html'
pk_url_kwarg = 'article_id'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_categories'] = Category.objects.all()
context['all_tags'] = Tag.objects.all()
return context
def get_initial(self):
initial = super().get_initial()
# 设置分类初始值
if self.object.category:
initial['category'] = self.object.category.name
# 设置标签初始值
if self.object.tags.exists():
tag_names = [tag.name for tag in self.object.tags.all()]
initial['tags'] = ', '.join(tag_names)
return initial
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
# 只有文章作者或管理员可以编辑
if obj.author != request.user and not request.user.is_superuser:
return redirect('blog:detailbyid',
article_id=obj.id,
year=obj.creation_time.year,
month=obj.creation_time.month,
day=obj.creation_time.day)
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
# 先不保存表单,先处理分类
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
# 直接设置分类对象,而不是名称
form.instance.category = category
# 先保存文章实例,以便可以添加多对多关系
# 但在保存前先从表单中移除category字段避免表单尝试保存它
category_temp = form.cleaned_data.pop('category')
tags_temp = form.cleaned_data.pop('tags')
response = super().form_valid(form)
# 处理标签
if tags_temp:
tag_names = [tag.strip() for tag in tags_temp.split(',') if tag.strip()]
form.instance.tags.clear() # 清除现有标签
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
else:
form.instance.tags.clear() # 如果没有标签,清除所有标签
# 处理临时上传的图片
temp_image_ids = self.request.session.get('temp_image_ids', [])
if temp_image_ids:
# 关联这些图片到当前文章
ArticleImage.objects.filter(id__in=temp_image_ids).update(article=self.object)
# 清除会话中的临时图片ID列表
self.request.session['temp_image_ids'] = []
self.request.session.modified = True
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})

@ -1,7 +1,9 @@
import logging
from django import forms
from django.utils.translation import gettext_lazy as _
from haystack.forms import SearchForm
from .models import Article, Category, Tag
logger = logging.getLogger(__name__)
@ -17,3 +19,62 @@ class BlogSearchForm(SearchForm):
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas
class ArticleForm(forms.ModelForm):
"""文章表单"""
title = forms.CharField(label='标题', max_length=200, required=True)
body = forms.CharField(label='内容', widget=forms.Textarea(attrs={'id': 'id_body_md'}), required=True)
status = forms.ChoiceField(
label='状态',
choices=[('p', '发布'), ('d', '草稿')],
initial='p',
widget=forms.Select,
required=True
)
comment_status = forms.ChoiceField(
label='评论状态',
choices=[('o', '开启'), ('c', '关闭')],
initial='o',
widget=forms.Select,
required=True
)
type = forms.ChoiceField(
label='类型',
choices=[('a', '文章'), ('p', '页面')],
initial='a',
widget=forms.Select,
required=True
)
show_toc = forms.BooleanField(label='显示目录', required=False)
category = forms.CharField(
label='分类',
required=True,
widget=forms.TextInput(attrs={
'class': 'form-control',
'list': 'category-list',
'placeholder': '选择或输入分类名称'
})
)
tags = forms.CharField(
label='标签',
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'list': 'tag-list',
'placeholder': '选择或输入标签,多个标签用逗号分隔'
})
)
class Meta:
model = Article
fields = ['title', 'body', 'status', 'comment_status', 'type', 'show_toc']
def __init__(self, *args, **kwargs):
super(ArticleForm, self).__init__(*args, **kwargs)
self.fields['title'].widget.attrs.update({'class': 'form-control'})
self.fields['body'].widget.attrs.update({'class': 'form-control mdeditor', 'rows': 20})
self.fields['status'].widget.attrs.update({'class': 'form-control'})
self.fields['comment_status'].widget.attrs.update({'class': 'form-control'})
self.fields['type'].widget.attrs.update({'class': 'form-control'})
self.fields['show_toc'].widget.attrs.update({'class': 'form-check-input'})

@ -0,0 +1,15 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import ArticleImage
class ImageUploadForm(forms.ModelForm):
"""图片上传表单"""
class Meta:
model = ArticleImage
fields = ['image', 'description']
widgets = {
'image': forms.FileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
'description': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('可选,添加图片描述')})
}

@ -0,0 +1,127 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.generic.edit import CreateView, DeleteView
from django.urls import reverse_lazy
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
import json
from .models import Article, ArticleImage
from .forms_image import ImageUploadForm
class ImageUploadView(LoginRequiredMixin, CreateView):
"""上传图片视图"""
model = ArticleImage
form_class = ImageUploadForm
template_name = 'blog/image_upload.html'
def form_valid(self, form):
article_id = self.kwargs.get('article_id')
article = get_object_or_404(Article, id=article_id)
# 检查用户是否有权限上传图片
if article.author != self.request.user and not self.request.user.is_superuser:
return JsonResponse({'status': 'error', 'message': '没有权限上传图片'}, status=403)
form.instance.article = article
self.object = form.save()
# 返回JSON响应包含图片URL和ID
return JsonResponse({
'status': 'success',
'image_id': self.object.id,
'image_url': self.object.image.url,
'description': self.object.description
})
def form_invalid(self, form):
return JsonResponse({'status': 'error', 'errors': form.errors}, status=400)
@login_required
@require_POST
@csrf_exempt
def markdown_image_upload(request):
"""为Markdown编辑器提供的图片上传接口"""
if request.method == 'POST' and request.FILES.get('image'):
image_file = request.FILES['image']
description = request.POST.get('description', '')
article_id = request.POST.get('article_id')
try:
# 获取会话中的临时图片ID列表
temp_image_ids = request.session.get('temp_image_ids', [])
# 创建图片对象,但不立即关联到文章
temp_image = ArticleImage(
image=image_file,
description=description or "Markdown编辑器上传"
)
# 如果有文章ID尝试关联到文章
if article_id:
try:
from blog.models import Article
article = Article.objects.get(id=article_id, author=request.user)
temp_image.article = article
except Article.DoesNotExist:
# 文章不存在,不关联
temp_image.article_id = None
else:
# 没有文章ID保存为临时图片
temp_image.article_id = None
# 保存图片
temp_image.save()
# 如果是临时图片将图片ID添加到会话中的临时图片列表
if not temp_image.article:
temp_image_ids.append(temp_image.id)
request.session['temp_image_ids'] = temp_image_ids
request.session.modified = True
# 返回Markdown编辑器需要的格式
return JsonResponse({
'success': 1,
'message': '上传成功',
'url': temp_image.image.url,
'image_id': temp_image.id # 添加图片ID以便后续可以删除
})
except Exception as e:
# 记录错误并返回失败信息
import logging
logger = logging.getLogger(__name__)
logger.error(f"图片上传失败: {str(e)}")
return JsonResponse({
'success': 0,
'message': f'上传失败: {str(e)}'
})
return JsonResponse({
'success': 0,
'message': '上传失败:未提供图片文件'
})
class ImageDeleteView(LoginRequiredMixin, DeleteView):
"""删除图片视图"""
model = ArticleImage
pk_url_kwarg = 'image_id'
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
# 检查用户是否有权限删除图片
if self.object.article.author != request.user and not request.user.is_superuser:
return JsonResponse({'status': 'error', 'message': '没有权限删除图片'}, status=403)
# 删除图片文件
self.object.image.delete()
self.object.delete()
return JsonResponse({'status': 'success', 'message': '图片已删除'})

@ -0,0 +1,41 @@
from django.core.management.base import BaseCommand
from blog.models import Category
class Command(BaseCommand):
help = '创建文章分类'
def handle(self, *args, **options):
categories = [
'技术',
'生活',
'旅行',
'美食',
'摄影',
'读书',
'电影',
'音乐',
'编程',
'设计',
'健康',
'教育',
'职场',
'财经',
'历史',
'文化',
'体育',
'游戏',
'科技'
]
for name in categories:
category, created = Category.objects.get_or_create(
name=name,
defaults={'parent_category': None}
)
if created:
self.stdout.write(self.style.SUCCESS(f'创建分类: {name}'))
else:
self.stdout.write(self.style.WARNING(f'分类已存在: {name}'))
self.stdout.write(self.style.SUCCESS('分类创建完成'))

@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand
from blog.models import Category
class Command(BaseCommand):
help = '列出所有文章分类'
def handle(self, *args, **options):
categories = Category.objects.all()
self.stdout.write('当前数据库中的分类:')
for category in categories:
self.stdout.write(f'- {category.name} (ID: {category.id})')
self.stdout.write(f'总计: {categories.count()} 个分类')

@ -0,0 +1,24 @@
# Generated by Django
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='articleimage',
name='article',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.CASCADE,
related_name='images',
to='blog.article'
),
),
]

@ -0,0 +1,29 @@
# Generated by Django 4.2.14 on 2025-11-20 20:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('blog', '0006_alter_blogsettings_options'),
]
operations = [
migrations.CreateModel(
name='ArticleImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='article_images/%Y/%m/%d/', verbose_name='图片')),
('description', models.CharField(blank=True, max_length=255, verbose_name='图片描述')),
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='blog.article')),
],
options={
'verbose_name': '文章图片',
'verbose_name_plural': '文章图片',
'ordering': ['-created_time'],
},
),
]

@ -0,0 +1,14 @@
# Generated by Django 4.2.14 on 2025-11-20 22:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_auto_20231201_1200'),
('blog', '0007_articleimage'),
]
operations = [
]

@ -1,228 +1,123 @@
"""
博客应用数据模型模块
该模块定义了DjangoBlog博客系统的核心数据模型包括
- 文章(Article)
- 分类(Category)
- 标签(Tag)
- 友情链接(Links)
- 侧边栏(SideBar)
- 博客设置(BlogSettings)
以及相关的基础模型和辅助类
"""
# ZY: 导入系统标准库
import logging
import re
from abc import abstractmethod
# WMW: 导入Django核心模块
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
# XJH: 导入第三方库
from mdeditor.fields import MDTextField # Markdown编辑器字段
from uuslug import slugify # URL友好的slug生成器
from mdeditor.fields import MDTextField
from uuslug import slugify
# ZYG: 导入项目工具函数
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
# CHY: 获取当前模块的日志记录器
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
"""
链接显示类型枚举类
定义友情链接在不同页面的显示方式
"""
# ZY: 首页显示
I = ('i', _('index'))
# WMW: 列表页显示
L = ('l', _('list'))
# XJH: 文章页显示
P = ('p', _('post'))
# ZYG: 所有页面显示
A = ('a', _('all'))
# CHY: 幻灯片显示
S = ('s', _('slide'))
class BaseModel(models.Model):
"""
基础模型类
为所有模型提供公共字段和方法包括主键创建时间和修改时间
"""
# ZY: 自增主键
id = models.AutoField(primary_key=True)
# WMW: 创建时间,默认为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# XJH: 最后修改时间,默认为当前时间
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
"""
重写保存方法处理特殊逻辑
如果只是更新浏览量则直接更新而不触发其他逻辑
否则自动生成slug并调用父类保存方法
"""
# ZYG: 检查是否只是更新浏览量
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# CHY: 直接更新浏览量,避免触发其他逻辑
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# ZY: 如果有slug字段则自动生成slug
if 'slug' in self.__dict__:
# WMW: 获取标题或名称用于生成slug
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
# XJH: 使用uuslug生成URL友好的slug
setattr(self, 'slug', slugify(slug))
# ZYG: 调用父类保存方法
super().save(*args, **kwargs)
def get_full_url(self):
"""
获取对象的完整URL
返回:
str: 包含域名的完整URL
"""
# CHY: 获取当前站点域名
site = get_current_site().domain
# ZY: 构建完整URL
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
# WMW: 设置为抽象模型,不创建数据库表
abstract = True
# XJH: 声明抽象方法,子类必须实现
@abstractmethod
def get_absolute_url(self):
"""
获取对象的绝对URL路径
返回:
str: URL路径
"""
pass
class Article(BaseModel):
"""
文章模型
存储博客文章或页面内容支持Markdown格式
"""
# ZY: 文章状态选择
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')), # 草稿
('p', _('Published')), # 已发布
('d', _('Draft')),
('p', _('Published')),
)
# WMW: 评论状态选择
COMMENT_STATUS = (
('o', _('Open')), # 开放评论
('c', _('Close')), # 关闭评论
('o', _('Open')),
('c', _('Close')),
)
# XJH: 文章类型选择
TYPE = (
('a', _('Article')), # 文章
('p', _('Page')), # 页面
('a', _('Article')),
('p', _('Page')),
)
# ZYG: 文章标题,唯一
title = models.CharField(_('title'), max_length=200, unique=True)
# CHY: 文章内容使用Markdown编辑器字段
body = MDTextField(_('body'))
# ZY: 发布时间
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
# WMW: 文章状态,默认为已发布
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
# XJH: 评论状态,默认为开放
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
# ZYG: 文章类型,默认为文章
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
# CHY: 浏览量默认为0
views = models.PositiveIntegerField(_('views'), default=0)
# ZY: 作者,外键关联用户模型
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
# WMW: 文章排序,数字越大越靠前
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
# XJH: 是否显示目录
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
# ZYG: 分类,外键关联分类模型
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
# CHY: 标签,多对多关系
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
"""
获取文章内容字符串
返回:
str: 文章内容
"""
return self.body
def __str__(self):
"""
文章的字符串表示
返回:
str: 文章标题
"""
return self.title
class Meta:
# ZY: 按文章排序和发布时间降序排列
ordering = ['-article_order', '-pub_time']
# WMW: 模型的可读名称
verbose_name = _('article')
# XJH: 复数形式使用相同名称
verbose_name_plural = verbose_name
# ZYG: 指定latest()方法使用的字段
get_latest_by = 'id'
def get_absolute_url(self):
"""
获取文章的绝对URL
返回:
str: 文章详情URL
"""
# CHY: 使用年月日和ID构建URL
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
@ -232,90 +127,43 @@ class Article(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
获取文章分类树带缓存
返回:
list: 分类名称和URL的元组列表
"""
# ZY: 获取分类树
tree = self.category.get_category_tree()
# WMW: 将分类转换为名称和URL的元组列表
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
"""
重写保存方法调用父类方法
"""
# XJH: 调用父类保存方法
super().save(*args, **kwargs)
def viewed(self):
"""
增加文章浏览量
"""
# ZYG: 增加浏览量
self.views += 1
# CHY: 只更新浏览量字段
self.save(update_fields=['views'])
def comment_list(self):
"""
获取文章评论列表带缓存
返回:
QuerySet: 已启用的评论列表
"""
# ZY: 生成缓存键
cache_key = 'article_comments_{id}'.format(id=self.id)
# WMW: 尝试从缓存获取评论
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
# XJH: 从数据库获取已启用的评论
comments = self.comment_set.filter(is_enable=True).order_by('-id')
# ZYG: 设置缓存
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
"""
获取文章管理页面URL
返回:
str: 文章编辑页面URL
"""
# CHY: 获取应用标签和模型名
info = (self._meta.app_label, self._meta.model_name)
# ZY: 构建管理页面URL
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
"""
获取下一篇文章带缓存
返回:
Article|None: 下一篇文章对象或None
"""
# WMW: 获取ID大于当前文章的已发布文章
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
"""
获取上一篇文章带缓存
返回:
Article|None: 上一篇文章对象或None
"""
# XJH: 获取ID小于当前文章的已发布文章
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
@ -456,6 +304,22 @@ class SideBar(models.Model):
return self.name
class ArticleImage(models.Model):
"""文章图片模型"""
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='images', null=True, blank=True)
image = models.ImageField(_('图片'), upload_to='images/')
description = models.CharField(_('图片描述'), max_length=255, blank=True)
created_time = models.DateTimeField(_('创建时间'), auto_now_add=True)
class Meta:
verbose_name = _('文章图片')
verbose_name_plural = verbose_name
ordering = ['-created_time']
def __str__(self):
return f"{self.article.title} - {self.image.name}"
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(

@ -0,0 +1,91 @@
/* 自定义样式 - 修改导航栏背景色和按钮样式 */
/* 导航栏背景色改为天蓝色 */
.main-navigation {
background: linear-gradient(to right, #2e9ac5, #66b8db) !important;
}
/* 文章标题链接样式 - 去掉蓝色按钮效果 */
.entry-title a {
color: #2c3e50 !important;
text-decoration: none !important;
background: none !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.entry-title a:hover {
color: #3498db !important;
text-decoration: underline !important;
background: none !important;
}
/* 侧边栏链接样式 - 去掉蓝色按钮效果 */
.widget a {
color: #34495e !important;
text-decoration: none !important;
background: none !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.widget a:hover {
color: #3498db !important;
text-decoration: underline !important;
background: none !important;
}
/* 文章阅读更多链接 - 去掉蓝色按钮效果 */
.read-more {
background: none !important;
color: #3498db !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
text-decoration: underline !important;
}
.read-more:hover {
background: none !important;
color: #2980b9 !important;
transform: none !important;
box-shadow: none !important;
}
/* 文章链接 - 去掉蓝色按钮效果 */
a[href*="/p/"],
a[href*="/article/"] {
background: none !important;
color: #3498db !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
text-decoration: underline !important;
margin: 0 !important;
}
a[href*="/p/"]:hover,
a[href*="/article/"]:hover {
background: none !important;
color: #2980b9 !important;
transform: none !important;
box-shadow: none !important;
}
/* 背景图片样式 */
body {
background-color: #f8f9fa;
background-image: url('/static/blog/img/default_bg.jpg');
background-size: cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
}
/* 网站容器样式,确保背景图片可见 */
.site {
background-color: rgba(255, 255, 255, 0.9);
}

@ -0,0 +1,90 @@
/* 简单的图标样式 */
/* 图标基础样式 */
[class^="icon-"], [class*=" icon-"] {
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.2em;
line-height: 1;
font-style: normal;
font-weight: normal;
text-align: center;
text-decoration: inherit;
text-transform: none;
speak: none;
}
/* 文件夹图标 */
.icon-folder:before {
content: "📁";
}
/* 标签图标 */
.icon-tag:before {
content: "🏷️";
}
/* 评论图标 */
.icon-comment:before {
content: "💬";
}
/* 搜索图标 */
#searchform input[type="submit"]:before {
content: "🔍";
margin-right: 0.25em;
}
/* 首页图标 */
#menu-item-3498 a:before {
content: "🏠";
margin-right: 0.25em;
}
/* 归档图标 */
.menu-item a[href*="archives"]:before {
content: "📚";
margin-right: 0.25em;
}
/* 管理站点图标 */
.menu-item a[href*="admin"]:before {
content: "⚙️";
margin-right: 0.25em;
}
/* 登录/登出图标 */
.menu-item a[href*="login"]:before {
content: "🔑";
margin-right: 0.25em;
}
.menu-item a[href*="logout"]:before {
content: "🚪";
margin-right: 0.25em;
}
/* 发布文章图标 */
.menu-item a[href*="create"]:before {
content: "✍️";
margin-right: 0.25em;
}
/* 我的文章图标 */
.menu-item a[href*="my_articles"]:before {
content: "📝";
margin-right: 0.25em;
}
/* 返回顶部按钮 */
#rocket:before {
content: "⬆️";
font-size: 1.5rem;
}
/* 背景切换按钮 */
.bg-toggle-btn i {
font-size: 1.5rem;
}

@ -0,0 +1,968 @@
/* 现代化博客样式 - 保持原有布局但提升视觉效果 */
/* 全局样式优化 */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #333;
line-height: 1.6;
background-color: #f8f9fa;
}
/* 网站容器优化 */
.site {
max-width: 1200px;
margin: 2rem auto;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
/* 网站头部优化 */
.site-header {
background: linear-gradient(to bottom, #fff, #f8f9fa);
padding: 1.5rem 0;
border-bottom: 1px solid #eee;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.site-title a {
font-size: 2.2rem;
font-weight: 700;
color: #2c3e50;
text-decoration: none;
transition: color 0.3s ease;
display: inline-block;
position: relative;
}
.site-title a::after {
content: '';
position: absolute;
bottom: -5px;
left: 0;
width: 0;
height: 3px;
background: linear-gradient(to right, #3498db, #2980b9);
transition: width 0.3s ease;
}
.site-title a:hover {
color: #3498db;
}
.site-title a:hover::after {
width: 100%;
}
.site-description {
color: #7f8c8d;
font-size: 1.1rem;
margin-top: 0.5rem;
font-style: italic;
}
/* 导航栏优化 */
.main-navigation {
margin-top: 1rem;
background: linear-gradient(to right, #34495e, #2c3e50);
padding: 0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
position: relative;
overflow: hidden;
}
.main-navigation::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to right, #3498db, #2980b9);
box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3);
}
.main-navigation::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
}
.main-navigation ul {
display: flex;
justify-content: center;
list-style: none;
margin: 0;
padding: 0;
}
.main-navigation li {
position: relative;
margin: 0;
}
.main-navigation a {
color: #ecf0f1;
text-decoration: none;
font-weight: 500;
padding: 0.9rem 1.2rem;
display: block;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.main-navigation a::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 3px;
background: linear-gradient(to right, #3498db, #2980b9);
transform: translateX(-50%);
transition: width 0.3s ease;
}
.main-navigation a:hover::before {
width: 80%;
}
.main-navigation a::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.6s ease, height 0.6s ease;
}
.main-navigation a:hover::after {
width: 100px;
height: 100px;
}
.main-navigation a:hover {
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* 当前活动菜单项 */
.main-navigation .current-menu-item > a,
.main-navigation .current_page_item > a {
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.main-navigation .current-menu-item > a::before,
.main-navigation .current_page_item > a::before {
width: 80%;
}
/* 下拉菜单样式 */
.main-navigation .sub-menu {
position: absolute;
top: 100%;
left: 0;
background: linear-gradient(to bottom, #34495e, #2c3e50);
min-width: 220px;
opacity: 0;
visibility: hidden;
transform: translateY(15px);
transition: all 0.3s ease;
z-index: 1000;
border-radius: 0 0 6px 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.main-navigation li:hover > .sub-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.main-navigation .sub-menu li {
width: 100%;
position: relative;
}
.main-navigation .sub-menu a {
padding: 0.8rem 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.main-navigation .sub-menu li:last-child a {
border-bottom: none;
}
.main-navigation .sub-menu a::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(to bottom, #3498db, #2980b9);
transform: scaleY(0);
transition: transform 0.3s ease;
}
.main-navigation .sub-menu a:hover::before {
transform: scaleY(1);
}
/* 移动端菜单按钮 */
.menu-toggle {
display: none;
background-color: #34495e;
color: #ecf0f1;
padding: 0.75rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
margin: 1rem auto;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.menu-toggle::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.1), transparent);
transition: left 0.5s ease;
}
.menu-toggle:hover::before {
left: 100%;
}
.menu-toggle:hover {
background-color: #3498db;
}
/* 主内容区域优化 */
#main {
display: flex;
flex-wrap: wrap;
padding: 2rem;
}
#primary {
flex: 2;
padding-right: 2rem;
}
#secondary {
flex: 1;
}
/* 文章卡片样式 */
article.post {
margin-bottom: 2rem;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
article.post:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}
.entry-header {
padding: 1.5rem 1.5rem 0;
}
/* 文章顶部元信息 */
.article-meta-top {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
}
.author-avatar {
margin-right: 1rem;
}
.author-avatar img {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
}
.author-info {
flex: 1;
}
.author-name {
font-weight: 600;
color: #2c3e50;
display: block;
}
.post-date {
font-size: 0.9rem;
color: #7f8c8d;
}
.post-views {
font-size: 0.9rem;
color: #7f8c8d;
}
/* 文章标题 */
.entry-title {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
margin-bottom: 1rem;
}
.pin-indicator {
color: #e74c3c;
font-weight: 600;
}
.entry-title a {
color: #2c3e50;
text-decoration: none;
transition: color 0.3s ease;
}
.entry-title a:hover {
color: #3498db;
}
/* 面包屑导航 */
.breadcrumb {
margin-bottom: 1rem;
font-size: 0.9rem;
color: #7f8c8d;
}
.breadcrumb a {
color: #3498db;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
/* 文章元信息 */
.article-meta {
display: flex;
flex-wrap: wrap;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #7f8c8d;
}
.article-meta span {
margin-right: 1rem;
margin-bottom: 0.5rem;
}
.article-meta a {
color: #3498db;
text-decoration: none;
}
.article-meta a:hover {
text-decoration: underline;
}
/* 文章内容 */
.entry-content {
padding: 0 1.5rem 1.5rem;
font-size: 1rem;
line-height: 1.7;
}
.article-summary {
margin-bottom: 1rem;
}
.read-more-container {
text-align: right;
margin-top: 1.5rem;
}
.read-more {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(to right, #3498db, #2980b9);
color: #fff;
padding: 0.6rem 1.2rem;
border-radius: 30px;
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(52, 152, 219, 0.3);
position: relative;
overflow: hidden;
}
.read-more::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.6s ease;
}
.read-more:hover::before {
left: 100%;
}
.read-more:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.4);
}
.read-more .arrow {
margin-left: 0.5rem;
font-weight: bold;
transition: transform 0.3s ease;
}
.read-more:hover .arrow {
transform: translateX(3px);
}
/* 统一所有阅读更多链接的样式 */
a[href*="/p/"],
a[href*="/article/"] {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(to right, #3498db, #2980b9);
color: #fff;
padding: 0.6rem 1.2rem;
border-radius: 30px;
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(52, 152, 219, 0.3);
position: relative;
overflow: hidden;
margin: 0.5rem 0;
}
a[href*="/p/"]::before,
a[href*="/article/"]::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.6s ease;
}
a[href*="/p/"]:hover::before,
a[href*="/article/"]:hover::before {
left: 100%;
}
a[href*="/p/"]:hover,
a[href*="/article/"]:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.4);
color: #fff;
}
/* 目录样式 */
.toc-container {
background-color: #f8f9fa;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.toc-title {
font-size: 1.1rem;
font-weight: 600;
margin-top: 0;
margin-bottom: 0.75rem;
color: #2c3e50;
}
.toc-content ul {
padding-left: 1.5rem;
margin: 0;
}
.toc-content li {
margin-bottom: 0.25rem;
}
.toc-content a {
color: #34495e;
text-decoration: none;
}
.toc-content a:hover {
color: #3498db;
}
/* 文章内容区域 */
.article-content {
margin-top: 1.5rem;
}
/* 文章页脚 */
.entry-footer {
padding: 0 1.5rem 1.5rem;
border-top: 1px solid #eee;
margin-top: 1.5rem;
}
/* 侧边栏样式 */
.widget-area {
padding-left: 1rem;
}
.widget {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.widget-title {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #3498db;
}
.widget ul {
list-style: none;
padding: 0;
margin: 0;
}
.widget li {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.widget li:last-child {
border-bottom: none;
}
.widget a {
color: #34495e;
text-decoration: none;
transition: color 0.3s ease;
}
.widget a:hover {
color: #3498db;
}
/* 搜索框样式 */
#searchform {
display: flex;
}
#searchform input[type="text"] {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 0.9rem;
}
#searchform input[type="submit"] {
background-color: #3498db;
color: #fff;
border: none;
padding: 0 1rem;
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: background-color 0.3s ease;
}
#searchform input[type="submit"]:hover {
background-color: #2980b9;
}
/* 标签云样式 */
.tagcloud a {
display: inline-block;
margin: 0.25rem;
padding: 0.25rem 0.5rem;
background-color: #f1f2f6;
color: #34495e;
border-radius: 4px;
text-decoration: none;
transition: all 0.3s ease;
}
.tagcloud a:hover {
background-color: #3498db;
color: #fff;
}
/* 评论区域样式 */
.comments-area {
margin-top: 2rem;
padding: 1.5rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.comment-list {
padding: 0;
list-style: none;
}
.comment {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.comment:last-child {
border-bottom: none;
}
.comment-author {
font-weight: 600;
color: #2c3e50;
}
.comment-meta {
font-size: 0.8rem;
color: #7f8c8d;
margin-bottom: 0.5rem;
}
/* 页脚样式 */
footer[role="contentinfo"] {
background-color: #34495e;
color: #ecf0f1;
padding: 2rem;
text-align: center;
margin-top: 2rem;
}
footer[role="contentinfo"] a {
color: #3498db;
text-decoration: none;
transition: color 0.3s ease;
}
footer[role="contentinfo"] a:hover {
color: #5dade2;
}
/* 归档页面样式 */
.archive-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.archive-title {
font-size: 2rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.archive-title .icon-folder {
margin-right: 0.5rem;
}
.archive-description {
font-size: 1rem;
color: #7f8c8d;
}
.archive-content {
padding: 1rem 0;
}
/* 时间轴样式 */
.timeline-container {
position: relative;
padding: 1rem 0;
}
.timeline-container::before {
content: '';
position: absolute;
left: 30px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(to bottom, #3498db, #2980b9);
border-radius: 3px;
}
.timeline-year {
margin-bottom: 2rem;
}
.year-title {
position: relative;
display: flex;
align-items: center;
margin-bottom: 1.5rem;
padding-left: 60px;
}
.year-badge {
position: absolute;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
background-color: #3498db;
color: #fff;
font-size: 1.2rem;
font-weight: 600;
border-radius: 50%;
box-shadow: 0 3px 10px rgba(52, 152, 219, 0.3);
z-index: 1;
}
.year-text {
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
margin-right: 0.5rem;
}
.article-count {
font-size: 0.9rem;
color: #7f8c8d;
background-color: #f8f9fa;
padding: 0.2rem 0.5rem;
border-radius: 12px;
}
.timeline-months {
padding-left: 60px;
}
.timeline-month {
margin-bottom: 1.5rem;
}
.month-title {
position: relative;
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.month-badge {
position: absolute;
left: -45px;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background-color: #2ecc71;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(46, 204, 113, 0.3);
z-index: 1;
}
.month-text {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin-right: 0.5rem;
}
.timeline-articles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.archive-article {
display: flex;
background: #fff;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.archive-article:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
}
.article-date {
margin-right: 1rem;
}
.day {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background-color: #f1f2f6;
color: #2c3e50;
font-size: 1.1rem;
font-weight: 600;
border-radius: 50%;
}
.article-content {
flex: 1;
}
.article-title {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.article-title a {
color: #2c3e50;
text-decoration: none;
transition: color 0.3s ease;
}
.article-title a:hover {
color: #3498db;
}
.article-meta {
display: flex;
font-size: 0.8rem;
color: #7f8c8d;
}
.article-meta .category {
margin-right: 1rem;
}
.article-meta a {
color: #3498db;
text-decoration: none;
}
.article-meta a:hover {
text-decoration: underline;
}
/* 返回顶部按钮 */
#rocket {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 3rem;
height: 3rem;
background-color: #3498db;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
opacity: 0;
visibility: hidden;
}
#rocket.show {
opacity: 1;
visibility: visible;
}
#rocket:hover {
background-color: #2980b9;
transform: translateY(-5px);
}
#rocket::before {
content: "↑";
font-size: 1.5rem;
}
/* 响应式设计 */
@media screen and (max-width: 992px) {
#main {
flex-direction: column;
}
#primary {
padding-right: 0;
margin-bottom: 2rem;
}
.widget-area {
padding-left: 0;
}
}
@media screen and (max-width: 768px) {
.site {
margin: 0;
border-radius: 0;
box-shadow: none;
}
.main-navigation ul {
flex-wrap: wrap;
}
.main-navigation li {
margin: 0.25rem;
}
#primary, #secondary {
padding: 0 1rem;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

@ -2,6 +2,10 @@ from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
from . import article_views
from .article_views_new import ArticleCreateView, ArticleUpdateView
from .article_delete_view import ArticleDeleteView
from . import image_views
app_name = "blog"
urlpatterns = [
@ -17,6 +21,22 @@ urlpatterns = [
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
path(
r'article/create/',
ArticleCreateView.as_view(),
name='article_create'),
path(
r'article/edit/<int:article_id>/',
ArticleUpdateView.as_view(),
name='article_edit'),
path(
r'article/delete/<int:article_id>/',
ArticleDeleteView.as_view(),
name='article_delete'),
path(
r'my-articles/',
views.my_articles,
name='my_articles'),
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
@ -59,4 +79,17 @@ urlpatterns = [
r'clean',
views.clean_cache_view,
name='clean'),
# 图片相关路由
path(
'article/<int:article_id>/upload-image/',
image_views.ImageUploadView.as_view(),
name='upload_image'),
path(
'markdown-image-upload/',
image_views.markdown_image_upload,
name='markdown_image_upload'),
path(
'image/<int:image_id>/delete/',
image_views.ImageDeleteView.as_view(),
name='delete_image'),
]

@ -150,6 +150,11 @@ class ArticleDetailView(DetailView):
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
# 获取文章关联的图片
from .models import ArticleImage
article_images = ArticleImage.objects.filter(article=self.object)
kwargs['article_images'] = article_images
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
@ -378,3 +383,102 @@ def permission_denied_view(
def clean_cache_view(request):
cache.clear()
return HttpResponse('ok')
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from .forms import ArticleForm
class ArticleCreateView(LoginRequiredMixin, CreateView):
"""创建文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_create.html'
def form_valid(self, form):
form.instance.author = self.request.user
# 处理分类
category_name = form.cleaned_data['category']
category, created = Category.objects.get_or_create(
name=category_name,
defaults={'parent_category': None}
)
form.instance.category = category
# 处理标签
tags_str = form.cleaned_data['tags']
if tags_str:
tag_names = [tag.strip() for tag in tags_str.split(',') if tag.strip()]
form.instance.save() # 保存文章实例,以便可以添加多对多关系
for tag_name in tag_names:
tag, created = Tag.objects.get_or_create(name=tag_name)
form.instance.tags.add(tag)
# 先保存文章,以便可以关联图片
response = super().form_valid(form)
# 将临时图片关联到新创建的文章
from .models import ArticleImage
# 获取会话中的临时图片ID列表
temp_image_ids = self.request.session.get('temp_image_ids', [])
# 查找这些临时图片
temp_images = ArticleImage.objects.filter(id__in=temp_image_ids)
# 将这些图片关联到新创建的文章
for image in temp_images:
image.article = form.instance
image.save()
# 清除会话中的临时图片ID列表
self.request.session['temp_image_ids'] = []
self.request.session.modified = True
return response
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
"""更新文章视图"""
model = Article
form_class = ArticleForm
template_name = 'blog/article_edit.html'
pk_url_kwarg = 'article_id'
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
# 只有文章作者或管理员可以编辑
if obj.author != request.user and not request.user.is_superuser:
return redirect('blog:detailbyid',
article_id=obj.id,
year=obj.creation_time.year,
month=obj.creation_time.month,
day=obj.creation_time.day)
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse_lazy('blog:detailbyid', kwargs={
'article_id': self.object.id,
'year': self.object.creation_time.year,
'month': self.object.creation_time.month,
'day': self.object.creation_time.day
})
@login_required
def my_articles(request):
"""显示当前用户的文章列表"""
articles = Article.objects.filter(author=request.user)
return render(request, 'blog/my_articles.html', {'articles': articles})

@ -18,10 +18,6 @@ from servermanager.models import *
class DjangoBlogAdminSite(AdminSite):
"""
DjangoBlogAdminSite类继承自AdminSite用于自定义Django管理站点的行为和外观
"""
site_header = 'djangoblog administration'
site_title = 'djangoblog site admin'
@ -29,7 +25,7 @@ class DjangoBlogAdminSite(AdminSite):
super().__init__(name)
def has_permission(self, request):
return request.user.is_superuser
return request.user.is_superuser
# def get_urls(self):
# urls = super().get_urls()

@ -1,554 +1,447 @@
"""
DjangoBlog项目设置配置模块
Django settings for djangoblog project.
该模块定义了DjangoBlog项目的所有配置参数包括数据库设置
中间件配置静态文件处理缓存设置邮件配置日志配置等
Generated by 'django-admin startproject' using Django 1.10.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""
# ZY: 导入必要的系统模块和Django模块
import os
import sys
from pathlib import Path
# ZY: 导入Django国际化支持模块
from django.utils.translation import gettext_lazy as _
# ZY: 环境变量转布尔值的辅助函数
def env_to_bool(env, default):
# ZY: 获取环境变量值
str_val = os.environ.get(env)
# ZY: 如果环境变量不存在则返回默认值,否则根据字符串值判断布尔值
return default if str_val is None else str_val == 'True'
# WMW: 项目根目录路径配置
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# WMW: 快速开发设置 - 不适合生产环境
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# XJH: 安全警告:生产环境中必须保持密钥安全!
# 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'
# XJH: 安全警告:生产环境中不要开启调试模式!
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_to_bool('DJANGO_DEBUG', True)
# DEBUG = False
# ZYG: 判断是否在测试环境
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# ZY: 允许的主机列表
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# CHY: Django 4.0新增配置用于CSRF信任源
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# WMW: 应用程序定义
# Application definition
INSTALLED_APPS = [
# ZY: 使用简化版管理配置,避免与自定义管理站点冲突
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
# ZY: Django核心应用
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# XJH: 站点地图和站点管理应用
'django.contrib.sites',
'django.contrib.sitemaps',
# ZYG: 第三方应用
'mdeditor', # Markdown编辑器
'haystack', # 全文搜索框架
# CHY: 项目自定义应用
'blog', # 博客核心应用
'accounts', # 用户账户管理
'comments', # 评论系统
'oauth', # OAuth认证
'servermanager', # 服务器管理
'owntracks', # 位置追踪
# ZY: 静态文件压缩
'mdeditor',
'haystack',
'blog',
'accounts',
'comments',
'oauth',
'servermanager',
'owntracks',
'compressor',
# WMW: 项目主应用
'djangoblog'
]
# XJH: 中间件配置,按执行顺序排列
MIDDLEWARE = [
# ZY: 安全中间件,提供各种安全保护
'django.middleware.security.SecurityMiddleware',
# WMW: 会话中间件,处理用户会话
'django.contrib.sessions.middleware.SessionMiddleware',
# XJH: 国际化中间件,处理多语言
'django.middleware.locale.LocaleMiddleware',
# ZYG: GZip压缩中间件压缩响应内容
'django.middleware.gzip.GZipMiddleware',
# CHY: 缓存中间件(已注释)
# 'django.middleware.cache.UpdateCacheMiddleware',
# ZY: 通用中间件处理URL路由等
'django.middleware.common.CommonMiddleware',
# CHY: 缓存中间件(已注释)
# 'django.middleware.cache.FetchFromCacheMiddleware',
# WMW: CSRF保护中间件
'django.middleware.csrf.CsrfViewMiddleware',
# XJH: 认证中间件,处理用户认证
'django.contrib.auth.middleware.AuthenticationMiddleware',
# ZYG: 消息中间件,处理临时消息
'django.contrib.messages.middleware.MessageMiddleware',
# CHY: 点击劫持保护中间件
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# ZY: HTTP条件请求中间件支持304响应
'django.middleware.http.ConditionalGetMiddleware',
# WMW: 自定义中间件,用于在线用户统计
'blog.middleware.OnlineMiddleware'
]
# ZY: 根URL配置文件
ROOT_URLCONF = 'djangoblog.urls'
# XJH: 模板配置
TEMPLATES = [
{
# ZY: 使用Django模板引擎
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# WMW: 模板目录,除了应用内模板外,还查找此目录
'DIRS': [os.path.join(BASE_DIR, 'templates')],
# CHY: 是否在应用内查找模板
'APP_DIRS': True,
# ZYG: 模板选项配置
'OPTIONS': {
# XJH: 上下文处理器列表
'context_processors': [
# ZY: 调试上下文处理器
'django.template.context_processors.debug',
# WMW: 请求上下文处理器
'django.template.context_processors.request',
# XJH: 认证上下文处理器
'django.contrib.auth.context_processors.auth',
# ZYG: 消息上下文处理器
'django.contrib.messages.context_processors.messages',
# CHY: 自定义SEO处理器
'blog.context_processors.seo_processor'
],
},
},
]
# ZY: WSGI应用配置
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# WMW: 数据库配置
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
# XJH: 默认数据库配置使用MySQL
'default': {
# ZY: 数据库引擎使用MySQL
'ENGINE': 'django.db.backends.mysql',
# CHY: 数据库名称
'NAME': 'djangoblogwhocare15',
# ZYG: 数据库用户名
'USER': 'whocare15',
# WMW: 数据库密码
'PASSWORD': 'IL2sXejLMkiEt8aU',
# XJH: 数据库主机地址
'HOST': 'mysql5.sqlpub.com',
# ZY: 数据库端口
'PORT': 3310,
}
}
# XJH: 密码验证配置
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
# ZY: 用户属性相似性验证器,防止密码与用户信息过于相似
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
# WMW: 最小长度验证器
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
# XJH: 常见密码验证器,防止使用常见密码
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
# ZYG: 数字密码验证器,防止纯数字密码
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# ZY: 国际化配置 - 支持的语言
LANGUAGES = (
# WMW: 英语
('en', _('English')),
# XJH: 简体中文
('zh-hans', _('Simplified Chinese')),
# ZYG: 繁体中文
('zh-hant', _('Traditional Chinese')),
)
# CHY: 本地化文件路径
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)
# WMW: 默认语言代码
LANGUAGE_CODE = 'zh-hans'
# XJH: 时区设置
TIME_ZONE = 'Asia/Shanghai'
# ZY: 启用国际化翻译
USE_I18N = True
# ZYG: 启用本地化数据格式
USE_L10N = True
# CHY: 不使用时区支持,使用本地时间
USE_TZ = False
# ZY: 静态文件配置 (CSS, JavaScript, Images)
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# WMW: 全文搜索配置 - 使用Whoosh搜索引擎
HAYSTACK_CONNECTIONS = {
'default': {
# XJH: 使用自定义中文Whoosh引擎
'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
# ZYG: 索引文件存储路径
'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
},
}
# CHY: 自动更新搜索索引
# Automatically update searching index
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# ZY: 允许用户使用用户名或邮箱登录
# Allow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
# XJH: 静态文件收集目录
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
# WMW: 静态文件URL前缀
STATIC_URL = '/static/'
# ZYG: 静态文件源目录
STATICFILES = os.path.join(BASE_DIR, 'static')
# CHY: 添加插件静态文件目录
# 添加插件静态文件目录
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'plugins'), # 让Django能找到插件的静态文件
]
# ZY: 自定义用户模型
AUTH_USER_MODEL = 'accounts.BlogUser'
# WMW: 登录URL
LOGIN_URL = '/login/'
# XJH: 时间格式化设置
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
# ZYG: Bootstrap颜色样式
# bootstrap color styles
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
# CHY: 分页设置
# paginate
PAGINATE_BY = 10
# WMW: HTTP缓存超时时间30天
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# XJH: 缓存配置
# cache setting
CACHES = {
'default': {
# ZY: 默认使用本地内存缓存
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
# WMW: 缓存超时时间3小时
'TIMEOUT': 10800,
# XJH: 缓存位置标识符
'LOCATION': 'unique-snowflake',
}
}
# ZYG: 根据环境变量配置使用Redis作为缓存
# 使用redis作为缓存
if os.environ.get("DJANGO_REDIS_URL"):
CACHES = {
'default': {
# CHY: 使用Redis缓存后端
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
# ZY: Redis服务器地址
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
# WMW: 站点ID
SITE_ID = 1
# XJH: 百度推送URL用于SEO优化
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
# ZYG: 邮件配置
# Email:
# WMW: 邮件后端使用SMTP
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# XJH: 是否使用TLS加密
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
# ZY: 是否使用SSL加密
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
# CHY: SMTP服务器地址
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
# WMW: SMTP服务器端口
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
# XJH: SMTP用户名
EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
# ZYG: SMTP密码
EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
# CHY: 默认发件人
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
# ZY: 服务器通知邮件发件人
SERVER_EMAIL = EMAIL_HOST_USER
# WMW: 管理员邮箱列表
# Setting debug=false did NOT handle except email notifications
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# XJH: 微信管理后台密码双重MD5加密
# WX ADMIN password(Two times md5)
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
# ZYG: 日志文件路径
LOG_PATH = os.path.join(BASE_DIR, 'logs')
# CHY: 如果日志目录不存在则创建
if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH, exist_ok=True)
# WMW: 日志配置
LOGGING = {
# ZY: 日志配置版本
'version': 1,
# XJH: 不禁用已存在的日志记录器
'disable_existing_loggers': False,
# ZYG: 根日志记录器配置
'root': {
# CHY: 日志级别
'level': 'INFO',
# WMW: 处理器列表
'handlers': ['console', 'log_file'],
},
# XJH: 日志格式化器
'formatters': {
# ZY: 详细格式
'verbose': {
'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
}
},
# WMW: 日志过滤器
'filters': {
# XJH: 调试模式关闭时启用
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
# ZYG: 调试模式开启时启用
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
# CHY: 日志处理器
'handlers': {
# ZY: 文件日志处理器,按天轮转
'log_file': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
# WMW: 按天轮转
'when': 'D',
# XJH: 使用详细格式
'formatter': 'verbose',
# ZYG: 轮转间隔为1天
'interval': 1,
# CHY: 延迟创建文件
'delay': True,
# WMW: 保留5个备份文件
'backupCount': 5,
# XJH: 文件编码
'encoding': 'utf-8'
},
# ZY: 控制台日志处理器,仅在调试模式下启用
'console': {
'level': 'DEBUG',
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
# WMW: 空处理器,用于禁用日志
'null': {
'class': 'logging.NullHandler',
},
# XJH: 邮件通知处理器,仅在非调试模式下发送错误邮件
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
}
},
# ZYG: 日志记录器配置
'loggers': {
# CHY: DjangoBlog应用日志记录器
'djangoblog': {
'handlers': ['log_file', 'console'],
'level': 'INFO',
# WMW: 允许日志传播到根记录器
'propagate': True,
},
# XJH: Django请求错误日志记录器
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
# ZY: 不传播到根记录器
'propagate': False,
}
}
}
# WMW: 静态文件查找器配置
STATICFILES_FINDERS = (
# ZY: 文件系统查找器在STATICFILES_DIRS中查找
'django.contrib.staticfiles.finders.FileSystemFinder',
# XJH: 应用目录查找器在应用的static目录中查找
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# ZYG: 压缩器查找器
# other
'compressor.finders.CompressorFinder',
)
# CHY: 启用静态文件压缩
COMPRESS_ENABLED = True
# WMW: 根据环境变量决定是否启用离线压缩
# 根据环境变量决定是否启用离线压缩
COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true'
# XJH: 压缩输出目录
# 压缩输出目录
COMPRESS_OUTPUT_DIR = 'compressed'
# ZYG: 压缩文件名模板 - 包含哈希值用于缓存破坏
# 压缩文件名模板 - 包含哈希值用于缓存破坏
COMPRESS_CSS_HASHING_METHOD = 'mtime'
COMPRESS_JS_HASHING_METHOD = 'mtime'
# WMW: 高级CSS压缩过滤器
# 高级CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
# ZY: 创建绝对URL
# 创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
# XJH: CSS压缩器 - 高压缩等级
# CSS压缩器 - 高压缩等级
'compressor.filters.cssmin.CSSCompressorFilter',
]
# ZYG: 高级JS压缩过滤器
# 高级JS压缩过滤器
COMPRESS_JS_FILTERS = [
# CHY: JS压缩器 - 高压缩等级
# JS压缩器 - 高压缩等级
'compressor.filters.jsmin.SlimItFilter',
]
# WMW: 压缩缓存配置
# 压缩缓存配置
COMPRESS_CACHE_BACKEND = 'default'
COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey'
# XJH: 预压缩配置
# 预压缩配置
COMPRESS_PRECOMPILERS = (
# ZY: 支持SCSS/SASS
# 支持SCSS/SASS
('text/x-scss', 'django_libsass.SassCompiler'),
('text/x-sass', 'django_libsass.SassCompiler'),
)
# ZYG: 压缩性能优化
COMPRESS_MINT_DELAY = 30 # WMW: 压缩延迟(秒)
COMPRESS_MTIME_DELAY = 10 # XJH: 修改时间检查延迟
COMPRESS_REBUILD_TIMEOUT = 2592000 # ZY: 重建超时30天
# 压缩性能优化
COMPRESS_MINT_DELAY = 30 # 压缩延迟(秒)
COMPRESS_MTIME_DELAY = 10 # 修改时间检查延迟
COMPRESS_REBUILD_TIMEOUT = 2592000 # 重建超时30天
# CHY: 压缩等级配置
# 压缩等级配置
COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor'
COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor'
# WMW: 静态文件缓存配置
# 静态文件缓存配置
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# XJH: 浏览器缓存配置(通过中间件或服务器配置)
# 浏览器缓存配置(通过中间件或服务器配置)
COMPRESS_URL = STATIC_URL
COMPRESS_ROOT = STATIC_ROOT
# ZYG: 用户上传媒体文件配置
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
# WMW: 防止点击劫持,只允许同源嵌入
MEDIA_ROOT = os.path.join(BASE_DIR, 'images')
MEDIA_URL = '/images/'
# MDEDITOR配置
MDEDITOR_CONFIGS = {
'default': {
'width': '100%', # 编辑器宽度
'height': 500, # 编辑器高度
'toolbar': ["undo", "redo", "|",
"bold", "del", "italic", "quote", "ucwords", "uppercase", "lowercase", "|",
"h1", "h2", "h3", "h5", "h6", "|",
"list-ul", "list-ol", "hr", "|",
"link", "reference-link", "image", "code", "preformatted-text", "code-block", "table", "datetime", "emoji", "html-entities", "pagebreak", "|",
"goto-line", "watch", "preview", "fullscreen", "clear", "search", "|",
"help", "info"
],
'upload_image_formats': ["jpg", "jpeg", "gif", "png", "bmp", "webp"],
'image_folder': 'images',
'theme': 'default', # dark / default
'preview_theme': 'default', # dark / default
'editor_theme': 'default', # pastel-on-dark / dark
'toolbar_autofixed': True,
'search_place': 'top', # top / bottom
'language': 'zh-cn', # 语言
'line_numbers': True, # 显示行号
'tab_size': 4, # Tab缩进
'tex': True, # 科学公式TeX支持
'flow_chart': True, # 流程图支持
'sequence': True, # 时序图支持
'mind_map': True, # 脑图支持
'inline_break': True, # 换行符
'line_wrapping': True, # 自动换行
'auto_save': 1000, # 自动保存时间间隔(ms)
'save_html_to textarea': True, # 保存HTML到文本框
'html_decode': True, # HTML解码
'emoji': True, # 表情
'task_list': True, # 任务列表
'katex': True, # 科学公式KaTeX支持
'toc': True, # 目录
'at_link': True, # @link
'email_link': True, # email链接
'image_upload': True, # 图片上传
'image_upload_url': '/mdeditor/upload/', # 图片上传URL
'image_path': 'images/' # 图片上传路径
}
}
X_FRAME_OPTIONS = 'SAMEORIGIN'
# XJH: 安全头部配置 - 防XSS和其他攻击
# 安全头部配置 - 防XSS和其他攻击
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# ZY: 内容安全策略 (CSP) - 防XSS攻击
CSP_DEFAULT_SRC = ["'self'"] # WMW: 默认源只允许同源
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] # XJH: 脚本源配置
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] # ZYG: 样式源配置
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] # CHY: 图片源配置
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] # WMW: 字体源配置
CSP_CONNECT_SRC = ["'self'"] # XJH: 连接源配置
CSP_FRAME_SRC = ["'none'"] # ZY: 禁止嵌入框架
CSP_OBJECT_SRC = ["'none'"] # CHY: 禁止嵌入对象
# ZYG: 默认自增字段类型
# 内容安全策略 (CSP) - 防XSS攻击
CSP_DEFAULT_SRC = ["'self'"]
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
CSP_CONNECT_SRC = ["'self'"]
CSP_FRAME_SRC = ["'none'"]
CSP_OBJECT_SRC = ["'none'"]
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# WMW: 根据环境变量配置Elasticsearch搜索引擎
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
# XJH: Elasticsearch DSL配置
ELASTICSEARCH_DSL = {
'default': {
# ZY: Elasticsearch主机地址
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
# CHY: 使用Elasticsearch作为搜索后端
HAYSTACK_CONNECTIONS = {
'default': {
# WMW: 使用自定义Elasticsearch引擎
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# ZY: 插件系统配置
# Plugin System
# XJH: 插件目录路径
PLUGINS_DIR = BASE_DIR / 'plugins'
# WMW: 激活的插件列表
ACTIVE_PLUGINS = [
# ZYG: 文章版权插件
'article_copyright',
# CHY: 阅读时间插件
'reading_time',
# WMW: 外部链接处理插件
'external_links',
# XJH: 浏览量统计插件
'view_count',
# ZY: SEO优化插件
'seo_optimizer',
# WMW: 图片懒加载插件
'image_lazy_loading',
# XJH: 文章推荐插件
'article_recommendation',
]

@ -1,189 +1,59 @@
"""
网站地图模块
该模块定义了DjangoBlog博客系统的网站地图(Sitemap)用于生成符合搜索引擎标准的XML网站地图
包括静态视图文章分类标签和作者的网站地图
"""
# ZY: 导入Django网站地图基类
from django.contrib.sitemaps import Sitemap
# WMW: 导入URL反向解析函数
from django.urls import reverse
# XJH: 导入博客模型
from blog.models import Article, Category, Tag
class StaticViewSitemap(Sitemap):
"""
静态视图网站地图
为博客首页等静态视图提供网站地图信息
"""
# ZYG: 优先级,中等
priority = 0.5
# CHY: 更新频率,每天
changefreq = 'daily'
def items(self):
"""
获取静态视图列表
返回:
list: 静态视图名称列表
"""
# ZY: 返回博客首页视图
return ['blog:index', ]
def location(self, item):
"""
获取视图的URL位置
参数:
item: 视图名称
返回:
str: 视图URL
"""
# WMW: 使用反向解析获取URL
return reverse(item)
class ArticleSiteMap(Sitemap):
"""
文章网站地图
为所有已发布的文章提供网站地图信息
"""
# XJH: 更新频率,每月
changefreq = "monthly"
# ZYG: 优先级,较高
priority = "0.6"
def items(self):
"""
获取文章列表
返回:
QuerySet: 已发布的文章列表
"""
# CHY: 只返回已发布的文章
return Article.objects.filter(status='p')
def lastmod(self, obj):
"""
获取文章最后修改时间
参数:
obj: 文章对象
返回:
datetime: 最后修改时间
"""
# ZY: 返回文章的最后修改时间
return obj.last_modify_time
class CategorySiteMap(Sitemap):
"""
分类网站地图
为所有分类提供网站地图信息
"""
# WMW: 更新频率,每周
changefreq = "Weekly"
# XJH: 优先级,较高
priority = "0.6"
def items(self):
"""
获取分类列表
返回:
QuerySet: 所有分类列表
"""
# ZYG: 返回所有分类
return Category.objects.all()
def lastmod(self, obj):
"""
获取分类最后修改时间
参数:
obj: 分类对象
返回:
datetime: 最后修改时间
"""
# CHY: 返回分类的最后修改时间
return obj.last_modify_time
class TagSiteMap(Sitemap):
"""
标签网站地图
为所有标签提供网站地图信息
"""
# ZY: 更新频率,每周
changefreq = "Weekly"
# WMW: 优先级,较低
priority = "0.3"
def items(self):
"""
获取标签列表
返回:
QuerySet: 所有标签列表
"""
# XJH: 返回所有标签
return Tag.objects.all()
def lastmod(self, obj):
"""
获取标签最后修改时间
参数:
obj: 标签对象
返回:
datetime: 最后修改时间
"""
# ZYG: 返回标签的最后修改时间
return obj.last_modify_time
class UserSiteMap(Sitemap):
"""
作者网站地图
为所有文章作者提供网站地图信息
"""
# CHY: 更新频率,每周
changefreq = "Weekly"
# ZY: 优先级,较低
priority = "0.3"
def items(self):
"""
获取作者列表
返回:
list: 去重后的作者列表
"""
# WMW: 获取所有文章的作者并去重
return list(set(map(lambda x: x.author, Article.objects.all())))
def lastmod(self, obj):
"""
获取用户注册时间
参数:
obj: 用户对象
返回:
datetime: 用户注册时间
"""
# XJH: 返回用户的注册时间
return obj.date_joined

@ -73,6 +73,8 @@ urlpatterns += i18n_patterns(
re_path(r'', include('servermanager.urls', namespace='servermanager')),
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)

@ -1,4 +1,4 @@
S#!/usr/bin/env python
#!/usr/bin/env python
# encoding: utf-8
@ -243,33 +243,25 @@ def class_filter(tag, name, value):
return ' '.join(allowed_classes) if allowed_classes else False
return value
# WMW: 安全的属性白名单 - 防止XSS攻击
# 安全的属性白名单
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
# XJH: 这些标签的class属性使用自定义过滤器
'span': class_filter,
'div': class_filter,
'pre': class_filter,
'code': class_filter
}
# ZYG: 安全的协议白名单 - 防止javascript:等危险协议
# 安全的协议白名单 - 防止javascript:等危险协议
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
def sanitize_html(html):
"""
安全的HTML清理函数
使用bleach库进行白名单过滤防止XSS攻击
参数:
html: 需要清理的HTML字符串
返回:
str: 清理后的安全HTML字符串
"""
# CHY: 使用bleach库清理HTML
return bleach.clean(
html,
tags=ALLOWED_TAGS,

@ -1,20 +1,7 @@
# encoding: utf-8
"""
中文Whoosh搜索引擎后端模块
该模块为Django-Haystack框架提供了支持中文分词的Whoosh搜索引擎后端实现
主要功能包括
- 中文分词支持使用jieba库
- 搜索索引的创建更新和删除
- 搜索查询处理
- 搜索结果高亮显示
"""
# ZY: 导入Python未来兼容性支持
from __future__ import absolute_import, division, print_function, unicode_literals
# WMW: 导入系统标准库
import json
import os
import re
@ -22,13 +9,11 @@ import shutil
import threading
import warnings
# XJH: 导入第三方库
import six
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from datetime import datetime
from django.utils.encoding import force_str
# ZYG: 导入Haystack框架核心模块
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
from haystack.constants import DJANGO_CT, DJANGO_ID, ID
from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
@ -37,9 +22,7 @@ from haystack.models import SearchResult
from haystack.utils import get_identifier, get_model_ct
from haystack.utils import log as logging
from haystack.utils.app_loading import haystack_get_model
# CHY: 导入中文分词库
from jieba.analyse import ChineseAnalyzer
# ZY: 导入Whoosh搜索引擎核心模块
from whoosh import index
from whoosh.analysis import StemmingAnalyzer
from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
@ -51,47 +34,36 @@ from whoosh.qparser import QueryParser
from whoosh.searching import ResultsPage
from whoosh.writing import AsyncWriter
# WMW: 检查Whoosh库是否安装
try:
import whoosh
except ImportError:
raise MissingDependency(
"The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
# XJH: 检查Whoosh版本是否满足最低要求
# Handle minimum requirement.
if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
raise MissingDependency(
"The 'whoosh' backend requires version 2.5.0 or greater.")
# ZYG: 冒泡正确错误
# Bubble up the correct error.
# CHY: 日期时间正则表达式用于解析ISO格式的日期时间
DATETIME_REGEX = re.compile(
'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})T(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})(\.\d{3,6}Z?)?$')
# ZY: 线程本地存储用于存储RAM存储实例
LOCALS = threading.local()
LOCALS.RAM_STORE = None
class WhooshHtmlFormatter(HtmlFormatter):
"""
Whoosh HTML格式化器
这是一个比whoosh.HtmlFormatter更简单的HTML格式化器
我们使用它来在不同后端之间获得一致的结果
特别是SolrXapian和Elasticsearch都使用这种格式
This is a HtmlFormatter simpler than the whoosh.HtmlFormatter.
We use it to have consistent results across backends. Specifically,
Solr, Xapian and Elasticsearch are using this formatting.
"""
# WMW: HTML模板用于高亮显示搜索结果
template = '<%(tag)s>%(t)s</%(tag)s>'
class WhooshSearchBackend(BaseSearchBackend):
"""
Whoosh搜索引擎后端
实现了基于Whoosh的搜索引擎后端支持中文分词和搜索
"""
# XJH: Whoosh保留的关键字用于特殊用途
# Word reserved by Whoosh for special use.
RESERVED_WORDS = (
'AND',
'NOT',

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@ -665,3 +665,95 @@ msgstr "快捷登录"
#: .\templates\share_layout\nav.html:26
msgid "Article archive"
msgstr "文章归档"
#: .\templates\share_layout\nav.html:29
msgid "Create Article"
msgstr "创建文章"
#: .\templates\share_layout\nav.html:33
msgid "My Articles"
msgstr "我的文章"
#: .\templates\blog\article_create.html:5
msgid "Create Article"
msgstr "创建文章"
#: .\templates\blog\article_edit.html:5
msgid "Edit Article"
msgstr "编辑文章"
#: .\templates\blog\my_articles.html:5
msgid "My Articles"
msgstr "我的文章"
#: .\templates\share_layout\nav.html:29
msgid "发布文章"
msgstr "发布文章"
#: .\templates\share_layout\nav.html:33
msgid "我的文章"
msgstr "我的文章"
#: .\templates\blog\article_create.html:97
msgid "发布"
msgstr "发布"
#: .\templates\blog\article_create.html:98
msgid "取消"
msgstr "取消"
#: .\templates\blog\article_edit.html:97
msgid "更新"
msgstr "更新"
#: .\templates\blog\article_edit.html:98
msgid "取消"
msgstr "取消"
#: .\templates\blog\my_articles.html:13
msgid "发布新文章"
msgstr "发布新文章"
#: .\templates\blog\my_articles.html:22
msgid "标题"
msgstr "标题"
#: .\templates\blog\my_articles.html:23
msgid "状态"
msgstr "状态"
#: .\templates\blog\my_articles.html:24
msgid "分类"
msgstr "分类"
#: .\templates\blog\my_articles.html:25
msgid "创建时间"
msgstr "创建时间"
#: .\templates\blog\my_articles.html:26
msgid "浏览量"
msgstr "浏览量"
#: .\templates\blog\my_articles.html:27
msgid "操作"
msgstr "操作"
#: .\templates\blog\my_articles.html:38
msgid "已发布"
msgstr "已发布"
#: .\templates\blog\my_articles.html:40
msgid "草稿"
msgstr "草稿"
#: .\templates\blog\my_articles.html:48
msgid "编辑"
msgstr "编辑"
#: .\templates\blog\my_articles.html:58
msgid "您还没有创建任何文章。"
msgstr "您还没有创建任何文章。"
#: .\templates\blog\my_articles.html:59
msgid "创建您的第一篇文章"
msgstr "创建您的第一篇文章"

File diff suppressed because it is too large Load Diff

@ -0,0 +1,2 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jU77yQAAAABJRU5ErkJggg==

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save