From 777ca50328e2b620deab5dea267a4605066c7249 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Wed, 1 Apr 2026 14:17:03 +0800 Subject: [PATCH] =?UTF-8?q?update.=20=E5=AE=8C=E6=88=90=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B1=95=E7=A4=BA=E3=80=81=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=92=8C=E8=AF=A6=E6=83=85=E9=A1=B5=20update.=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E8=9E=8D=E5=90=88=E4=B8=BB=E8=8F=9C=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E6=9D=BF=E5=9D=97=20update.=20=E5=88=86=E7=A6=BB=E6=96=B0?= =?UTF-8?q?=E9=97=BB=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/logs/django_server.log => .env | 0 .gitignore | 2 - article/admin.py | 120 ++++--- article/migrations/0001_initial.py | 45 +-- article/migrations/0002_initial.py | 18 +- ...ons_remove_section_description_and_more.py | 47 --- article/models.py | 106 +++--- article/seeder.py | 84 +++++ article/views.py | 2 +- base/admin.py | 65 +++- base/migrations/0001_initial.py | 72 +++- ...ons_alter_linkcategory_options_and_more.py | 30 ++ base/models.py | 145 +++++++- base/seeder.py | 107 ++++++ base/templatetags/__init__.py | 0 base/templatetags/footer_tags.py | 36 ++ base/views.py | 42 ++- common/__init__.py | 0 common/base.py | 17 + {utils => common}/common_response.py | 0 db.sqlite3 | Bin 360448 -> 0 bytes front/urls.py | 5 +- front/views.py | 331 ++++++++++-------- news/__init__.py | 0 news/admin.py | 52 +++ news/apps.py | 6 + news/migrations/0001_initial.py | 48 +++ news/migrations/0002_initial.py | 28 ++ news/migrations/__init__.py | 0 news/models.py | 37 ++ news/seeder.py | 67 ++++ news/tests.py | 3 + news/views.py | 3 + poetry.lock | 216 +++++++++++- pyproject.toml | 6 + server/settings.py | 105 +++++- server/urls.py | 9 +- templates/base.html | 177 +++++----- templates/components/_news_card.html | 9 +- templates/components/footer.html | 95 +++++ templates/components/main_menu.html | 9 + templates/footer.html | 45 --- templates/front/details/article.html | 80 +++++ .../list.html => front/details/news.html} | 2 +- templates/front/error/404.html | 50 +++ templates/front/error/500.html | 60 ++++ templates/front/h.html | 21 -- templates/front/index.html | 5 +- templates/front/info.html | 12 +- templates/front/list.html | 172 +++++++++ templates/front/news.html | 4 +- users/admin.py | 19 +- users/migrations/0001_initial.py | 2 +- users/seeder.py | 46 +++ utils/upload_to.py | 95 +++++ 55 files changed, 2190 insertions(+), 567 deletions(-) rename db/logs/django_server.log => .env (100%) delete mode 100644 article/migrations/0003_alter_section_options_remove_section_description_and_more.py create mode 100644 article/seeder.py create mode 100644 base/migrations/0002_alter_friendlink_options_alter_linkcategory_options_and_more.py create mode 100644 base/seeder.py create mode 100644 base/templatetags/__init__.py create mode 100644 base/templatetags/footer_tags.py create mode 100644 common/__init__.py create mode 100644 common/base.py rename {utils => common}/common_response.py (100%) delete mode 100644 db.sqlite3 create mode 100644 news/__init__.py create mode 100644 news/admin.py create mode 100644 news/apps.py create mode 100644 news/migrations/0001_initial.py create mode 100644 news/migrations/0002_initial.py create mode 100644 news/migrations/__init__.py create mode 100644 news/models.py create mode 100644 news/seeder.py create mode 100644 news/tests.py create mode 100644 news/views.py create mode 100644 templates/components/footer.html create mode 100644 templates/components/main_menu.html delete mode 100644 templates/footer.html create mode 100644 templates/front/details/article.html rename templates/{articles/list.html => front/details/news.html} (93%) create mode 100644 templates/front/error/404.html create mode 100644 templates/front/error/500.html delete mode 100644 templates/front/h.html create mode 100644 templates/front/list.html create mode 100644 users/seeder.py create mode 100644 utils/upload_to.py diff --git a/db/logs/django_server.log b/.env similarity index 100% rename from db/logs/django_server.log rename to .env diff --git a/.gitignore b/.gitignore index 4e2a696..c60a6b3 100755 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ config/supervisord.sock .idea/ /.idea/ /.idea/* -/templates/ -/templates/* **/__pycache__ **/.DS_Store /venv/ diff --git a/article/admin.py b/article/admin.py index e6a58c2..fd36a38 100644 --- a/article/admin.py +++ b/article/admin.py @@ -1,51 +1,56 @@ +from django import forms from django.contrib import admin from django.utils.html import format_html +from import_export.admin import ImportExportModelAdmin -from article.models import NoticeReceipt, Notice, Article, Carousel, Section +from article.models import NoticeReceipt, Notice, Article, Carousel, ArticleAttachment +from base.models import MainMenu from users.models import User # —————— 板块 —————— -@admin.register(Section) -class SectionAdmin(admin.ModelAdmin): - list_display = ('display_name', 'code', 'parent', 'is_enabled', 'order') - list_filter = ('parent', 'enabled') - search_fields = ('name', 'code') - ordering = ('order', 'name') - list_editable = ('order',) - fieldsets = ( - ('基本信息', { - 'fields': ('name', 'code', 'parent', 'enabled', 'order') - }), - ) - - def display_name(self, obj): - """在列表中显示层级缩进""" - if obj.is_sub: - return f"  └─ {obj.name}" # 使用全角空格+符号模拟缩进 - return obj.name - display_name.short_description = '板块名称' - display_name.admin_order_field = 'name' - - def is_enabled(self, obj): - return obj.enabled - is_enabled.boolean = True - is_enabled.short_description = '启用' - - def formfield_for_foreignkey(self, db_field, request, **kwargs): - """限制 parent 只能选择主板块(无父级的板块)""" - if db_field.name == "parent": - kwargs["queryset"] = Section.objects.filter(parent__isnull=True) - return super().formfield_for_foreignkey(db_field, request, **kwargs) - - def get_queryset(self, request): - """优化查询,避免 N+1""" - return super().get_queryset(request).select_related('parent') - +# @admin.register(ArticleSection) +# class SectionAdmin(admin.ModelAdmin): +# list_display = ('display_name', 'code', 'parent', 'is_enabled', 'order') +# list_filter = ('parent', 'enabled') +# search_fields = ('name', 'code') +# ordering = ('order', 'name') +# list_editable = ('order',) +# fieldsets = ( +# ('基本信息', { +# 'fields': ('name', 'code', 'parent', 'enabled', 'order') +# }), +# ) +# +# def display_name(self, obj): +# """在列表中显示层级缩进""" +# if obj.is_sub: +# return f"  └─ {obj.name}" # 使用全角空格+符号模拟缩进 +# return obj.name +# +# display_name.short_description = '板块名称' +# display_name.admin_order_field = 'name' +# +# def is_enabled(self, obj): +# return obj.enabled +# +# is_enabled.boolean = True +# is_enabled.short_description = '启用' +# +# def formfield_for_foreignkey(self, db_field, request, **kwargs): +# """限制 parent 只能选择主板块(无父级的板块)""" +# if db_field.name == "parent": +# kwargs["queryset"] = ArticleSection.objects.filter(parent__isnull=True) +# return super().formfield_for_foreignkey(db_field, request, **kwargs) +# +# def get_queryset(self, request): +# """优化查询,避免 N+1""" +# return super().get_queryset(request).select_related('parent') +# # —————— 轮播图 —————— @admin.register(Carousel) -class CarouselAdmin(admin.ModelAdmin): +class CarouselAdmin(ImportExportModelAdmin): list_display = ('title', 'image_thumb', 'link_url', 'order', 'active') list_editable = ('order', 'active') ordering = ('order',) @@ -54,18 +59,49 @@ class CarouselAdmin(admin.ModelAdmin): if obj.image: return format_html('', obj.image.url) return "无图" + image_thumb.short_description = "预览" +# --- 附件内联配置 --- +class ArticleAttachmentInline(admin.TabularInline): + model = ArticleAttachment + extra = 1 # 默认显示 1 个空白的附件上传框 + verbose_name = "附件" + verbose_name_plural = "附件列表" + # 如果附件较多,可以限制每页显示数量 + # max_num = 5 + + +# 1. 定义自定义表单 +class ArticleForm(forms.ModelForm): + class Meta: + model = Article + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # 核心代码:过滤 section 字段,只保留有父级的分类(即二级分类) + # 这里的 queryset 会直接作用于 Admin 的下拉选择框 + self.fields['section'].queryset = MainMenu.objects.filter(parent__isnull=False, enabled=True) + + # 可选:优化显示格式,让用户知道是哪个主板块下的子板块 + # 例如显示:"[新闻中心] 国内新闻" + self.fields['section'].label_from_instance = lambda obj: f"[{obj.parent.name}] {obj.name}" + + # —————— 文章 —————— @admin.register(Article) -class ArticleAdmin(admin.ModelAdmin): +class ArticleAdmin(ImportExportModelAdmin): + form = ArticleForm # <--- 关联自定义表单 list_display = ('title', 'section', 'author', 'is_published', 'created_at') list_filter = ('section', 'is_published', 'created_at') search_fields = ('title', 'content') date_hierarchy = 'created_at' autocomplete_fields = ('author',) # 需要 UserAdmin 支持搜索 + # 核心配置:将附件内联注册到文章管理中 + inlines = [ArticleAttachmentInline] fieldsets = ( ('基本信息', {'fields': ('title', 'section', 'cover')}), ('内容', {'fields': ('content',)}), @@ -75,7 +111,7 @@ class ArticleAdmin(admin.ModelAdmin): # —————— 通知(带签收) —————— @admin.register(Notice) -class NoticeAdmin(admin.ModelAdmin): +class NoticeAdmin(ImportExportModelAdmin): list_display = ( 'title', 'publisher', @@ -112,6 +148,7 @@ class NoticeAdmin(admin.ModelAdmin): def signed_count(self, obj): return obj.receipts.count() + signed_count.short_description = "已签收人数" def total_target_users(self, obj): @@ -123,12 +160,13 @@ class NoticeAdmin(admin.ModelAdmin): else: count = User.objects.filter(is_active=True).count() return count + total_target_users.short_description = "应签收人数" # —————— 签收记录 —————— @admin.register(NoticeReceipt) -class NoticeReceiptAdmin(admin.ModelAdmin): +class NoticeReceiptAdmin(ImportExportModelAdmin): list_display = ('notice', 'user', 'signed_at') list_filter = ('signed_at', 'notice__title') search_fields = ('user__cname', 'user__username', 'notice__title') diff --git a/article/migrations/0001_initial.py b/article/migrations/0001_initial.py index 450c847..5c35eba 100644 --- a/article/migrations/0001_initial.py +++ b/article/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.3 on 2026-03-24 10:31 +# Generated by Django 6.0.3 on 2026-04-01 00:55 import django_ckeditor_5.fields from django.db import migrations, models @@ -16,24 +16,40 @@ class Migration(migrations.Migration): name='Article', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200, verbose_name='标题')), - ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='正文')), - ('cover', models.ImageField(blank=True, upload_to='articles/', verbose_name='封面图')), - ('is_published', models.BooleanField(default=True, verbose_name='已发布')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('title', models.CharField(max_length=200, verbose_name='标题')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='正文')), + ('cover', models.ImageField(blank=True, upload_to='articles/%Y/%m/%d/', verbose_name='封面图')), + ('is_contribution', models.BooleanField(default=False, verbose_name='是否投稿')), + ('is_published', models.BooleanField(default=True, verbose_name='已发布')), ], options={ 'verbose_name': '文章', 'verbose_name_plural': '文章管理', }, ), + migrations.CreateModel( + name='ArticleAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('file', models.FileField(upload_to='articles/attachments/%Y/%m/%d/', verbose_name='附件文件')), + ], + options={ + 'verbose_name': '文章附件', + 'verbose_name_plural': '文章附件管理', + }, + ), migrations.CreateModel( name='Carousel', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), ('title', models.CharField(max_length=100, verbose_name='轮播标题')), - ('image', models.ImageField(upload_to='carousel/', verbose_name='图片')), + ('image', models.ImageField(upload_to='carousel/%Y/%m/%d/', verbose_name='图片')), ('link_url', models.URLField(blank=True, verbose_name='跳转链接')), ('order', models.IntegerField(default=0, verbose_name='排序')), ('active', models.BooleanField(default=True, verbose_name='启用')), @@ -63,6 +79,8 @@ class Migration(migrations.Migration): name='NoticeReceipt', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), ('signed_at', models.DateTimeField(auto_now_add=True, verbose_name='签收时间')), ], options={ @@ -70,19 +88,4 @@ class Migration(migrations.Migration): 'verbose_name_plural': '签收管理', }, ), - migrations.CreateModel( - name='Section', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, unique=True, verbose_name='板块名称')), - ('code', models.SlugField(help_text='用于模板识别,如 news, notice', unique=True, verbose_name='板块代码')), - ('description', models.TextField(blank=True, verbose_name='描述')), - ('order', models.IntegerField(default=0, verbose_name='排序')), - ('enabled', models.BooleanField(default=True, verbose_name='启用')), - ], - options={ - 'verbose_name': '板块', - 'verbose_name_plural': '板块管理', - }, - ), ] diff --git a/article/migrations/0002_initial.py b/article/migrations/0002_initial.py index d285ca1..b3e9b4d 100644 --- a/article/migrations/0002_initial.py +++ b/article/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.3 on 2026-03-24 10:31 +# Generated by Django 6.0.3 on 2026-04-01 00:55 import django.db.models.deletion from django.conf import settings @@ -11,6 +11,7 @@ class Migration(migrations.Migration): dependencies = [ ('article', '0001_initial'), + ('base', '0001_initial'), ('users', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -21,6 +22,16 @@ class Migration(migrations.Migration): name='author', field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='作者'), ), + migrations.AddField( + model_name='article', + name='section', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='base.mainmenu', verbose_name='所属板块'), + ), + migrations.AddField( + model_name='articleattachment', + name='article', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='article.article', verbose_name='所属文章'), + ), migrations.AddField( model_name='notice', name='publisher', @@ -41,11 +52,6 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='签收人'), ), - migrations.AddField( - model_name='article', - name='section', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='article.section', verbose_name='所属板块'), - ), migrations.AlterUniqueTogether( name='noticereceipt', unique_together={('notice', 'user')}, diff --git a/article/migrations/0003_alter_section_options_remove_section_description_and_more.py b/article/migrations/0003_alter_section_options_remove_section_description_and_more.py deleted file mode 100644 index 0eea2c2..0000000 --- a/article/migrations/0003_alter_section_options_remove_section_description_and_more.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-24 13:24 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('article', '0002_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='section', - options={'ordering': ('order', 'name'), 'verbose_name': '板块', 'verbose_name_plural': '板块管理'}, - ), - migrations.RemoveField( - model_name='section', - name='description', - ), - migrations.AddField( - model_name='section', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subsections', to='article.section', verbose_name='所属主板块'), - ), - migrations.AlterField( - model_name='section', - name='code', - field=models.SlugField(help_text='用于模板或URL识别,建议英文', unique=True, verbose_name='唯一标识'), - ), - migrations.AlterField( - model_name='section', - name='enabled', - field=models.BooleanField(default=True, verbose_name='启用状态'), - ), - migrations.AlterField( - model_name='section', - name='name', - field=models.CharField(max_length=100, unique=True, verbose_name='板块名称'), - ), - migrations.AlterField( - model_name='section', - name='order', - field=models.PositiveSmallIntegerField(default=0, verbose_name='排序权重'), - ), - ] diff --git a/article/models.py b/article/models.py index 23aa1fc..75d675b 100644 --- a/article/models.py +++ b/article/models.py @@ -1,79 +1,22 @@ from django.db import models from django_ckeditor_5.fields import CKEditor5Field +from base.models import MainMenu +from common.base import BaseEntity from users.models import User, Department # Create your models here. -from django.db import models -from django.core.exceptions import ValidationError - -class Section(models.Model): - name = models.CharField('板块名称', max_length=100, unique=True) - code = models.SlugField('唯一标识', max_length=50, unique=True, help_text='用于模板或URL识别,建议英文') - parent = models.ForeignKey( - 'self', - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='subsections', - verbose_name='所属主板块' - ) - order = models.PositiveSmallIntegerField('排序权重', default=0) - enabled = models.BooleanField('启用状态', default=True) - - class Meta: - verbose_name = '板块' - verbose_name_plural = '板块管理' - ordering = ('order', 'name') - - def __str__(self): - if self.parent: - return f"{self.parent.name} → {self.name}" - return self.name - - def clean(self): - super().clean() - # 禁止子板块再设父级(即最多两级) - if self.parent and self.parent.parent: - raise ValidationError('子板块不能再设置子级,最多只允许一级嵌套。') - # 防止自引用 - if self.pk and self.parent and self.parent.pk == self.pk: - raise ValidationError('板块不能以自身为父级。') - - @property - def is_sub(self): - """是否为子板块""" - return self.parent is not None - - @property - def is_main(self): - """是否为主板块(无父级)""" - return self.parent is None - - -class Carousel(models.Model): - title = models.CharField("轮播标题", max_length=100) - image = models.ImageField("图片", upload_to='carousel/') - link_url = models.URLField("跳转链接", blank=True) - order = models.IntegerField("排序", default=0) - active = models.BooleanField("启用", default=True) - - class Meta: - verbose_name = "轮播图" - verbose_name_plural = "轮播管理" - - -class Article(models.Model): +class Article(BaseEntity): title = models.CharField("标题", max_length=200) - section = models.ForeignKey(Section, on_delete=models.CASCADE, verbose_name="所属板块") + section = models.ForeignKey(MainMenu, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属板块") author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="作者") content = CKEditor5Field("正文") # 需安装 django-ckeditor - cover = models.ImageField("封面图", upload_to='articles/', blank=True) + cover = models.ImageField("封面图", upload_to='articles/%Y/%m/%d/', blank=True) + # 标记是否为投稿(决定是否需要审核) + is_contribution = models.BooleanField("是否投稿", default=False) is_published = models.BooleanField("已发布", default=True) - created_at = models.DateTimeField("创建时间", auto_now_add=True) - updated_at = models.DateTimeField("更新时间", auto_now=True) class Meta: verbose_name = "文章" @@ -83,7 +26,38 @@ class Article(models.Model): return self.title -class Notice(models.Model): +# --- 新增:附件模型 --- +class ArticleAttachment(BaseEntity): + # 关联文章:related_name='attachments' 允许我们通过 article.attachments.all() 访问 + article = models.ForeignKey( + Article, + on_delete=models.CASCADE, + related_name='attachments', + verbose_name="所属文章" + ) + file = models.FileField("附件文件", upload_to='articles/attachments/%Y/%m/%d/') + + class Meta: + verbose_name = "文章附件" + verbose_name_plural = "文章附件管理" + + def __str__(self): + return f"{self.article.title} - {self.file.name}" + + +class Carousel(BaseEntity): + title = models.CharField("轮播标题", max_length=100) + image = models.ImageField("图片", upload_to='carousel/%Y/%m/%d/') + link_url = models.URLField("跳转链接", blank=True) + order = models.IntegerField("排序", default=0) + active = models.BooleanField("启用", default=True) + + class Meta: + verbose_name = "轮播图" + verbose_name_plural = "轮播管理" + + +class Notice(BaseEntity): title = models.CharField("通知标题", max_length=200) content = CKEditor5Field("通知内容") publisher = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="发布人") @@ -101,7 +75,7 @@ class Notice(models.Model): return self.title -class NoticeReceipt(models.Model): +class NoticeReceipt(BaseEntity): """签收记录""" notice = models.ForeignKey(Notice, on_delete=models.CASCADE, related_name='receipts') user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="签收人") diff --git a/article/seeder.py b/article/seeder.py new file mode 100644 index 0000000..11e2e91 --- /dev/null +++ b/article/seeder.py @@ -0,0 +1,84 @@ +import os +import random +import sys +import tempfile + +import django + +# 配置 Django 环境 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") # 修改为你的 settings 路径 +django.setup() + +# 2. 导入 Faker 和 模型 + +from faker import Faker +from article.models import Article, ArticleAttachment +from base.models import MainMenu +from users.models import User +from django.core.files import File # 引入 Django 的文件处理类 + +fake = Faker('zh_CN') + + +def seed_articles(count=20): + print(f"🚀 开始生成 {count} 篇文章...") + + subsections = list(MainMenu.objects.filter(parent__isnull=False, visible=True)) + users = list(User.objects.all()) + + if not subsections: + print("❌ 错误:数据库中没有找到任何子板块!") + return + + if not users: + print("⚠️ 警告:数据库中没有用户,作者将设为空。") + + created_count = 0 + for i in range(count): + section = random.choice(subsections) + author = random.choice(users) if users else None + + paragraphs = fake.paragraphs(nb=random.randint(3, 8)) + content_html = "\n".join([f"

{p}

" for p in paragraphs]) + + article = Article.objects.create( + title=fake.sentence(nb_words=random.randint(5, 10))[:-1], + section=section, + author=author, + content=content_html, + is_published=True, + ) + + # --- 生成真实附件 --- + if random.random() < 0.3: + fake_filename = f"附件资料_{fake.word()}.pdf" + + # 创建临时文件 + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + tmp_file.write(b'%PDF-1.4 Fake PDF Content for Testing') + tmp_file_path = tmp_file.name + + try: + # 打开并保存 + with open(tmp_file_path, 'rb') as f: + # 关键点:使用 file.save 方法,这会触发文件上传机制 + attachment = ArticleAttachment(article=article) + attachment.file.save(fake_filename, File(f), save=True) + + print(f" - [{section}] {article.title} (含真实附件)") + + finally: + # 删除临时文件 + if os.path.exists(tmp_file_path): + os.remove(tmp_file_path) + else: + print(f" - [{section}] {article.title}") + + created_count += 1 + + print(f"✅ 成功生成 {created_count} 篇文章!") + + +if __name__ == "__main__": + seed_articles(30) diff --git a/article/views.py b/article/views.py index 7a65ecc..9eb6c51 100644 --- a/article/views.py +++ b/article/views.py @@ -13,7 +13,7 @@ router = Router(tags=['content']) @router.get('/article', description="获取文章列表") -def article_list(request): +def article_details(request): if not user_has_permission(request.user, 'article', 'view'): return HttpResponseForbidden("无权访问") articles = Article.objects.all() diff --git a/base/admin.py b/base/admin.py index 62105c2..2241a97 100644 --- a/base/admin.py +++ b/base/admin.py @@ -1,24 +1,67 @@ from django.contrib import admin -from django.utils.html import format_html +from import_export.admin import ImportExportModelAdmin -from article.models import Section, Carousel, Article, Notice, NoticeReceipt -from base.models import MainMenu, FooterLink -from users.models import User +from base.models import MainMenu, FooterLink, LinkCategory, FriendLink # —————— 主菜单 —————— @admin.register(MainMenu) -class MainMenuAdmin(admin.ModelAdmin): - list_display = ('title', 'url', 'parent', 'order', 'visible') +class MainMenuAdmin(ImportExportModelAdmin): + list_display = ('display_name', 'code', 'url', 'parent', 'order', 'visible') list_editable = ('order', 'visible') list_filter = ('parent', 'visible') search_fields = ('title', 'url') - ordering = ('order',) + ordering = ('id',) + + def display_name(self, obj): + """在列表中显示层级缩进""" + if obj.is_sub: + return f"  └─ {obj.title}" # 使用全角空格+符号模拟缩进 + return obj.title + + display_name.short_description = '板块名称' + display_name.admin_order_field = 'name' + + def is_enabled(self, obj): + return obj.enabled + + is_enabled.boolean = True + is_enabled.short_description = '启用' + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """限制 parent 只能选择主板块(无父级的板块)""" + if db_field.name == "parent": + kwargs["queryset"] = MainMenu.objects.filter(parent__isnull=True, is_external=False) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def get_queryset(self, request): + """优化查询,避免 N+1""" + return super().get_queryset(request).select_related('parent') + + +# 1. 注册分类管理 +@admin.register(LinkCategory) +class LinkCategoryAdmin(ImportExportModelAdmin): + list_display = ('name', 'code', 'order', 'is_active') + list_editable = ('order', 'is_active') + + +# 2. 注册链接管理 +@admin.register(FriendLink) +class FriendLinkAdmin(ImportExportModelAdmin): + list_display = ('name', 'category', 'url', 'order', 'is_active') + list_filter = ('category', 'is_active') # 左侧过滤器,方便筛选 + search_fields = ('name', 'url') + + # 优化:在添加链接时,只显示启用的分类 + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "category": + kwargs["queryset"] = LinkCategory.objects.filter(is_active=True) + return super().formfield_for_foreignkey(db_field, request, **kwargs) # —————— 页脚链接 —————— @admin.register(FooterLink) -class FooterLinkAdmin(admin.ModelAdmin): - list_display = ('title', 'url', 'order') - list_editable = ('order',) - ordering = ('order',) +class FooterLinkAdmin(ImportExportModelAdmin): + list_display = ('title', 'address', 'unit', 'postal', 'telephone', 'fax', 'filing') + list_editable = ('postal', 'telephone', 'fax', 'unit', 'filing',) diff --git a/base/migrations/0001_initial.py b/base/migrations/0001_initial.py index 303ea49..51e657f 100644 --- a/base/migrations/0001_initial.py +++ b/base/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.3 on 2026-03-24 10:31 +# Generated by Django 6.0.3 on 2026-04-01 00:55 import django.db.models.deletion from django.db import migrations, models @@ -16,29 +16,77 @@ class Migration(migrations.Migration): name='FooterLink', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=50, verbose_name='链接文字')), - ('url', models.URLField(verbose_name='链接地址')), - ('order', models.IntegerField(default=0, verbose_name='排序')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('title', models.CharField(max_length=50, verbose_name='页脚信息')), + ('address', models.CharField(max_length=200, verbose_name='单位地址')), + ('postal', models.CharField(default='', verbose_name='邮编')), + ('telephone', models.CharField(default='', verbose_name='服务热线')), + ('fax', models.CharField(default='', verbose_name='传真')), + ('unit', models.CharField(default='', verbose_name='版权单位')), + ('filing', models.CharField(default='', verbose_name='备案号')), + ('official_account', models.ImageField(default='', upload_to='carousel/', verbose_name='公众号二维码')), + ('weibo', models.ImageField(default='', upload_to='carousel/', verbose_name='微博二维码')), + ('is_active', models.BooleanField(default=False, verbose_name='生效')), ], options={ - 'verbose_name': '页脚链接', + 'verbose_name': '页脚信息', 'verbose_name_plural': '页脚管理', }, ), + migrations.CreateModel( + name='LinkCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='分类名称')), + ('code', models.SlugField(help_text="用于前端模板调用的标识,如 'gov', 'fire'", unique=True, verbose_name='分类代码')), + ('order', models.IntegerField(default=0, verbose_name='显示排序')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ], + options={ + 'verbose_name': '友链分类', + 'verbose_name_plural': '友链分类管理', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='FriendLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('name', models.CharField(max_length=100, verbose_name='网站名称')), + ('url', models.URLField(verbose_name='链接地址')), + ('order', models.IntegerField(default=0, verbose_name='排序')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='base.linkcategory', verbose_name='所属分类')), + ], + options={ + 'verbose_name': '友情链接', + 'verbose_name_plural': '友情链接管理', + 'ordering': ['order', '-created_at'], + }, + ), migrations.CreateModel( name='MainMenu', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=50, verbose_name='菜单标题')), - ('url', models.CharField(help_text='如 /news/ 或 外链 https://xxx', max_length=200, verbose_name='链接地址')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('title', models.CharField(max_length=100, verbose_name='标题')), + ('code', models.SlugField(help_text='用于URL或模板识别,建议英文', unique=True, verbose_name='唯一标识')), + ('url', models.CharField(blank=True, help_text='如 /menu/news.html 或 外链', max_length=200, verbose_name='链接地址')), ('is_external', models.BooleanField(default=False, verbose_name='是否外链')), - ('order', models.IntegerField(default=0, verbose_name='排序')), - ('visible', models.BooleanField(default=True, verbose_name='是否显示')), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='base.mainmenu', verbose_name='父菜单')), + ('order', models.PositiveSmallIntegerField(default=0, verbose_name='排序权重')), + ('visible', models.BooleanField(default=True, verbose_name='显示状态')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='base.mainmenu', verbose_name='父级节点')), ], options={ - 'verbose_name': '主菜单', - 'verbose_name_plural': '主菜单管理', + 'verbose_name': '站点导航/板块', + 'verbose_name_plural': '站点结构管理', + 'ordering': ('order', 'title'), }, ), ] diff --git a/base/migrations/0002_alter_friendlink_options_alter_linkcategory_options_and_more.py b/base/migrations/0002_alter_friendlink_options_alter_linkcategory_options_and_more.py new file mode 100644 index 0000000..bdc0f4e --- /dev/null +++ b/base/migrations/0002_alter_friendlink_options_alter_linkcategory_options_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.3 on 2026-04-01 01:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='friendlink', + options={'ordering': ['order', '-created_at'], 'verbose_name': '友情链接', 'verbose_name_plural': '友链管理'}, + ), + migrations.AlterModelOptions( + name='linkcategory', + options={'ordering': ['order'], 'verbose_name': '友链分类', 'verbose_name_plural': '友链分类'}, + ), + migrations.AlterModelOptions( + name='mainmenu', + options={'ordering': ('order', 'title'), 'verbose_name': '站点导航', 'verbose_name_plural': '站点导航'}, + ), + migrations.AddField( + model_name='mainmenu', + name='is_carousel', + field=models.BooleanField(default=False, verbose_name='轮播图'), + ), + ] diff --git a/base/models.py b/base/models.py index 8cbc48b..1ae3b32 100644 --- a/base/models.py +++ b/base/models.py @@ -1,27 +1,144 @@ +from django.core.exceptions import ValidationError from django.db import models +from common.base import BaseEntity + + # Create your models here. -class MainMenu(models.Model): - title = models.CharField("菜单标题", max_length=50) - url = models.CharField("链接地址", max_length=200, help_text="如 /news/ 或 外链 https://xxx") - is_external = models.BooleanField("是否外链", default=False) - parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, verbose_name="父菜单") - order = models.IntegerField("排序", default=0) - visible = models.BooleanField("是否显示", default=True) + + +# --- 新增:友情链接分类模型 --- +class LinkCategory(BaseEntity): + name = models.CharField("分类名称", max_length=50, unique=True) # 分类名,如"政府网站" + code = models.SlugField("分类代码", max_length=50, unique=True, help_text="用于前端模板调用的标识,如 'gov', 'fire'") + order = models.IntegerField("显示排序", default=0) # 分类本身的排序 + is_active = models.BooleanField("是否启用", default=True) class Meta: - verbose_name = "主菜单" - verbose_name_plural = "主菜单管理" + verbose_name = "友链分类" + verbose_name_plural = "友链分类" + ordering = ['order'] def __str__(self): - return self.title + return self.name -class FooterLink(models.Model): - title = models.CharField("链接文字", max_length=50) - url = models.URLField("链接地址") +# --- 修改:友情链接模型 --- +class FriendLink(BaseEntity): + # 1. 关联到新的分类模型(一对多关系:一个分类下有多个链接) + category = models.ForeignKey( + LinkCategory, + on_delete=models.CASCADE, # 如果分类被删,关联的链接也删除 + verbose_name="所属分类", + related_name='links' # 反向查询名称,方便后续调用 + ) + + name = models.CharField("网站名称", max_length=100) + url = models.URLField("链接地址", max_length=200) + + # 2. 排序和状态 order = models.IntegerField("排序", default=0) + is_active = models.BooleanField("是否启用", default=True) class Meta: - verbose_name = "页脚链接" + verbose_name = "友情链接" + verbose_name_plural = "友链管理" + ordering = ['order', '-created_at'] + + def __str__(self): + return f"[{self.category.name}] {self.name}" + + +class MainMenu(BaseEntity): + # --- 基础信息 --- + title = models.CharField("标题", max_length=100) + code = models.SlugField("唯一标识", max_length=50, unique=True, help_text="用于URL或模板识别,建议英文") + + # --- 树形结构 --- + parent = models.ForeignKey( + 'self', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='children', + verbose_name="父级节点" + ) + + # --- 菜单特有属性 --- + # 只有顶级节点(主菜单)才需要填写这个,子节点作为板块时通常不需要跳转链接 + url = models.CharField("链接地址", max_length=200, blank=True, help_text="如 /menu/news.html 或 外链") + is_external = models.BooleanField("是否外链", default=False) + is_carousel = models.BooleanField("轮播图", default=False) + + # --- 通用属性 --- + order = models.PositiveSmallIntegerField("排序权重", default=0) + visible = models.BooleanField("显示状态", default=True) + + class Meta: + verbose_name = "站点导航" + verbose_name_plural = "站点导航" + ordering = ('order', 'title') + + def __str__(self): + # 显示层级路径,例如:新闻中心 → 国内新闻 + if self.parent: + return f"{self.parent.title} → {self.title}" + return self.title + + def clean(self): + super().clean() + # 限制层级:最多 2 级 + if self.parent and self.parent.parent: + raise ValidationError('层级过深:子节点不能再设置子级,最多只允许两级结构。') + + # 防止自引用 + if self.pk and self.parent and self.parent.pk == self.pk: + raise ValidationError('节点不能以自身为父级。') + + # --- 核心逻辑属性 --- + @property + def is_sub(self): + """是否为子板块(即是否有父级)""" + return self.parent is not None + + @property + def is_main(self): + """是否为主菜单(即是否无父级)""" + return not self.parent + + @property + def active_children(self): + """ + 获取当前节点下可见的子节点(用于前端导航渲染) + """ + return self.children.filter(visible=True) + + +class FooterLink(BaseEntity): + title = models.CharField("页脚信息", max_length=50) + address = models.CharField("单位地址", max_length=200) + postal = models.CharField("邮编", default="") + telephone = models.CharField("服务热线", default="") + fax = models.CharField("传真", default="") + unit = models.CharField("版权单位", default="") + filing = models.CharField("备案号", default="") + official_account = models.ImageField("公众号二维码", default="", upload_to='carousel/') + weibo = models.ImageField("微博二维码", default="", upload_to='carousel/') + is_active = models.BooleanField("生效", default=False) + + class Meta: + verbose_name = "页脚信息" verbose_name_plural = "页脚管理" + + def __str__(self): + return f"{self.title} - 状态:{'使用中' if self.is_active else '已停用'}" + + def save(self, *args, **kwargs): + # 1. 如果当前这条数据被标记为“生效” (is_active=True) + if self.is_active: + # 2. 将数据库中【除了当前这条】以外的所有 FooterLink 都更新为“不生效” + # self.pk 是当前对象的唯一标识 + FooterLink.objects.exclude(pk=self.pk).update(is_active=False) + + # 3. 执行正常的保存操作 + super().save(*args, **kwargs) diff --git a/base/seeder.py b/base/seeder.py new file mode 100644 index 0000000..ce0595f --- /dev/null +++ b/base/seeder.py @@ -0,0 +1,107 @@ +import os +import sys + +import django +from loguru import logger +from pypinyin import lazy_pinyin, Style + +# 配置 Django 环境 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") # 修改为你的 settings 路径 +django.setup() + +from faker import Faker +from base.models import MainMenu + +# 初始化 Faker,设置中文(如果需要中文名) +# 初始化 Faker (zh_CN 生成中文数据) +fake = Faker('zh_CN') + +# --- 核心数据结构:定义你的菜单层级 --- +MENU_STRUCTURE = { + "信息公开": [ + "通知通告", "法律法规", "政策文件", "政策解读" + ], + "党建引领": [ + "党建工作", "党建轮播", "党风廉政", "先进典型" + ], + "互动交流": [ + "专家访谈", "调查征集", "在线咨询" + ], + "消防科普": [ + "数字消防", "火灾预防", "自救逃生" + ], + "办事服务": [ + "办事指南", "政务服务", "资料下载", "办事统计" + ] +} + + +def get_slug(text): + """ + 将中文转换为拼音 Slug + 例如: "信息公开" -> "xin-xi-gong-kai" + """ + # lazy_pinyin 返回拼音列表,join 将其用 '-' 连接 + return "-".join(lazy_pinyin(text, style=Style.NORMAL)) + + +def seed_main_menu(): + logger.info("🚀 开始生成站点导航数据...") + + # 1. 清空旧数据 (可选,如果需要重置数据请取消注释) + MainMenu.objects.all().delete() + logger.info("🧹 已清空旧数据") + + created_count = 0 + + # 2. 循环创建主菜单和子板块 + for main_title, sub_titles in MENU_STRUCTURE.items(): + # --- 创建主菜单 --- + # 生成唯一的 code,例如 "xin-xi-gong-kai" + main_code = get_slug(text=main_title) + logger.info(f"主菜单: [{main_title}] Code: [{main_code}]") + main_menu, created = MainMenu.objects.get_or_create( + title=main_title, + defaults={ + 'code': main_code, + 'url': main_code, # 模拟 URL + 'is_external': False, + 'order': created_count, + 'visible': True + } + ) + + if created: + logger.info(f"✅ 创建主菜单: [{main_title}]") + else: + logger.info(f"⏭️ 主菜单已存在: [{main_title}]") + + # --- 创建子板块 --- + for idx, sub_title in enumerate(sub_titles): + sub_code = get_slug(text=sub_title) + logger.info(f"子菜单: [{sub_title}] Code: [{sub_code}]") + sub_menu, sub_created = MainMenu.objects.get_or_create( + title=sub_title, + parent=main_menu, # 关键:关联父级 + defaults={ + 'code': sub_code, + 'url': sub_code, + 'is_external': False, + 'order': idx, # 子菜单排序 + 'visible': True + } + ) + + if sub_created: + logger.info(f" └─ ✅ 创建子板块: [{sub_title}]") + else: + logger.info(f" └─ ⏭️ 子板块已存在: [{sub_title}]") + + created_count += 1 + + logger.info(f"\n🎉 导航数据生成完毕!共处理 {len(MENU_STRUCTURE)} 个主菜单。") + + +if __name__ == "__main__": + seed_main_menu() diff --git a/base/templatetags/__init__.py b/base/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/base/templatetags/footer_tags.py b/base/templatetags/footer_tags.py new file mode 100644 index 0000000..35de31c --- /dev/null +++ b/base/templatetags/footer_tags.py @@ -0,0 +1,36 @@ +from django import template +from loguru import logger + +from ..models import FooterLink, LinkCategory, MainMenu # 假设你的模型在当前目录的上一级 models.py 中 + +register = template.Library() + + +# inclusion_tag 装饰器会自动帮你渲染指定的模板 +@register.inclusion_tag('components/footer.html') +def show_footer(): + # 1. 获取当前生效的那一条页脚数据 + # 根据你之前的模型,is_active=True 的只有一条 + footer = FooterLink.objects.filter(is_active=True).first() + link_categories = LinkCategory.objects.filter(is_active=True) + logger.info(footer) + logger.info(link_categories) + + # 3. 将数据传递给模板 + return { + 'footer': footer, + 'categories': link_categories + } + + +@register.inclusion_tag('components/main_menu.html') +def show_main_menu(): + # 1. 获取当前生效的那一条页脚数据 + # 根据你之前的模型,is_active=True 的只有一条 + menus = MainMenu.objects.filter(visible=True, parent__isnull=True).order_by('order') + logger.info(menus) + + # 3. 将数据传递给模板 + return { + 'menus': menus, + } diff --git a/base/views.py b/base/views.py index 286b1b6..aff25ac 100644 --- a/base/views.py +++ b/base/views.py @@ -1,5 +1,45 @@ from django.shortcuts import render from ninja import Router +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt +from django.conf import settings +import os +import uuid +from datetime import datetime # Create your views here. -router = Router(tags=['content']) \ No newline at end of file +router = Router(tags=['content']) + + +@csrf_exempt +@require_http_methods(["POST"]) +def custom_upload_file(request): + """ + 自定义 CKEditor 5 上传视图 + 支持:图片 + PDF / Word / Excel / ZIP 等所有文件 + """ + file = request.FILES.get('upload') + if not file: + return JsonResponse({"error": "请选择文件"}, status=400) + + # 安全处理文件名(避免中文/特殊字符报错) + ext = file.name.split('.')[-1].lower() + filename = f"{uuid.uuid4().hex}_{datetime.now().strftime('%Y%m%d%H%M%S')}.{ext}" + + # 保存路径 + upload_dir = os.path.join(settings.MEDIA_ROOT, 'uploads') + os.makedirs(upload_dir, exist_ok=True) + file_path = os.path.join(upload_dir, filename) + + # 保存文件 + with open(file_path, 'wb+') as f: + for chunk in file.chunks(): + f.write(chunk) + + # 返回文件 URL(CKEditor 规范格式) + file_url = f"{settings.MEDIA_URL}uploads/{filename}" + return JsonResponse({ + "url": file_url, + "default": file_url + }) diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/base.py b/common/base.py new file mode 100644 index 0000000..c9c4b48 --- /dev/null +++ b/common/base.py @@ -0,0 +1,17 @@ +# import django +from django.db import models + + +class BaseEntity(models.Model): + created_at = models.DateTimeField(verbose_name='创建时间', auto_now_add=True) + updated_at = models.DateTimeField(verbose_name='更新时间', auto_now=True) + + class Meta: + abstract = True + + +class DownloaderCategory(models.TextChoices): + # 下载器名称 + # Deluge = 'De', 'Deluge' + Transmission = 'Tr', 'Transmission' + qBittorrent = 'Qb', 'qBittorrent' diff --git a/utils/common_response.py b/common/common_response.py similarity index 100% rename from utils/common_response.py rename to common/common_response.py diff --git a/db.sqlite3 b/db.sqlite3 deleted file mode 100644 index 829d1a2994e0befbfd6a22a9ec33b71e8a984158..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 360448 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 hA`V0xh&T{&AmTv8frtYU2O.html', views.info, name='menu'), + path('/list.html', views.main_article_list, name='main_list'), + path('/list/.html', views.sub_article_list, name='sub_list'), path('about/', views.about, name='about'), path('news/', views.news, name='news'), path('info/', views.info, name='info'), @@ -14,4 +16,5 @@ urlpatterns = [ path('party/', views.party, name='party'), path('interaction/', views.interaction, name='interaction'), path('science/', views.science, name='science'), + path('article/.html/', views.article_detail, name='article_detail'), ] diff --git a/front/views.py b/front/views.py index fa33a4c..2039926 100644 --- a/front/views.py +++ b/front/views.py @@ -1,14 +1,36 @@ +import traceback +from typing import Dict, List from unicodedata import category -from django.shortcuts import render +from django.db.models import Prefetch +from django.http import Http404, HttpResponse +from django.shortcuts import render, get_object_or_404 +from loguru import logger +from article.models import Article +from base.models import MainMenu +from news.models import NewsSection from utils.format import get_now_strftime_format # Create your views here. -def h(request): - return render(request, 'front/h.html', ) +# 2. 新增:文章详情视图函数 +def article_detail(request, pk: int): + """ + 根据主键 (pk) 获取文章详情 + """ + try: + # 使用 get_object_or_404 简化代码:如果文章不存在,自动返回 404 页面 + article = get_object_or_404(Article, pk=pk) + # 如果是渲染 HTML 模板 + return render(request, 'front/details/article.html', {'article': article}) + except (Article.DoesNotExist, Http404): + return render(request, 'front/error/404.html') + except Exception as e: + logger.error(e) + logger.error(traceback.format_exc()) + return render(request, 'front/error/500.html') def index(request): @@ -37,64 +59,91 @@ def index(request): ] } } - section_data = [ - { - "key": "info", + # section_data = [ + # { + # "key": "info", + # "data": { + # '信息公开': [ + # {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, + # {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, + # {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, + # {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, + # {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, + # {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, + # {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, + # ], + # }, + # }, + # { + # "key": "party", + # "data": { + # '党建引领': [ + # {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, + # {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, + # {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, + # {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, + # {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, + # {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, + # {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, + # ], + # }, + # }, + # { + # "key": "interaction", + # "data": { + # '互动交流': [ + # {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, + # {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, + # {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, + # {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, + # {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, + # {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, + # {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, + # ], + # }, + # }, + # { + # "key": "science", + # "data": { + # '消防科普': [ + # {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, + # {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, + # {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, + # {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, + # {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, + # {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, + # {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, + # ], + # }, + # } + # ] + articles_qs = Article.objects.filter(is_published=True).order_by('-created_at') + main_sections = MainMenu.objects.filter(parent__isnull=True, visible=True).order_by('order').prefetch_related( + # 预取可见的子板块 + Prefetch('children', queryset=MainMenu.objects.filter(visible=True).order_by('order')), + # 预取子板块下的文章,并将结果存入 'latest_articles' 属性 + # 注意:这个属性是属于 'children' (即下面的 sub) 的 + Prefetch('children__article_set', queryset=articles_qs, to_attr='latest_articles') + ) + + # 3. 构建数据 (纯内存操作,速度极快) + section_data = [] + for main in main_sections: + # 收集该主板块下所有子板块的文章 + all_articles = [] + for sub in main.children.all(): + # 使用预取好的 children + # 使用预取好的 latest_articles,并取前7条 + all_articles.extend(sub.latest_articles[:7]) + all_articles.sort(key=lambda x: x.created_at, reverse=True) + # 截取总列表的前7条(如果需要限制总数) + section_data.append({ + "key": main.code, # 建议使用 code 作为 key,比对象本身更安全 "data": { - '信息公开': [ - {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, - {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, - {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, - {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, - {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, - {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, - {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, - ], - }, - }, - { - "key": "party", - "data": { - '党建引领': [ - {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, - {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, - {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, - {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, - {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, - {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, - {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, - ], - }, - }, - { - "key": "interaction", - "data": { - '互动交流': [ - {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, - {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, - {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, - {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, - {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, - {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, - {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, - ], - }, - }, - { - "key": "science", - "data": { - '消防科普': [ - {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, - {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, - {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, - {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, - {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, - {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, - {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, - ], - }, - } - ] + main.title: all_articles[:7] + } + }) + logger.info(section_data) carousel = { "key": "index", "data": [ @@ -135,112 +184,100 @@ def about(request): def news(request): - sections = [ + sections = NewsSection.objects.filter(enabled=True).all().order_by('order') + section_data = [ { - "key": "fire_news", + "key": section.code, "data": { - "消防要闻": [ - {'title': '习近平会见全国应急管理系统先进模范和消防忠诚卫士表彰...', 'date': '2021-11-06'}, - {'title': '北京市消防救援局挂牌', 'date': '2024-06-19'}, - {'title': '市消防救援局即日起开展专项行动,推出一揽子“便民利企”措施', 'date': '2025-07-01'}, - {'title': '北京开展石油化工生产装置灭火救援实战演练', 'date': '2025-09-30'}, - {'title': '北京市全面启动 2025 年度消防宣传月活动', 'date': '2025-11-11'}, - {'title': '逛庙会学消防!厂甸大观园庙会打造消防“打卡点”', 'date': '2026-02-25'}, - {'title': '“火焰蓝”守护年味!消防员坚守景区庙会护平安', 'date': '2026-02-21'}, - ] - } - }, - { - "key": "local_dynamic", - "data": { - "基层动态": [ - {'title': '某支队开展冬季练兵比武活动', 'date': '2023-12-01'}, - {'title': '社区微型消防站进行应急演练', 'date': '2023-12-05'}, - {'title': '朝阳区举办消防安全知识讲座', 'date': '2024-01-15'}, - ] - } - }, - { - "key": "state_council", - "data": { - "国务院新闻": [ - {'title': '国务院安委会部署全国安全生产大检查', 'date': '2023-11-20'}, - ] + section.name: section.news_set.filter(is_published=True)[:7], } } + for section in sections ] context = { - 'sections': sections, + 'sections': section_data, } + logger.info(context) return render(request, 'front/news.html', context) -def info(request): +def info(request, main): """信息公开""" - - sections = [ + logger.info(f"正在访问 {main}") + main_section = MainMenu.objects.filter(code=main).first() + if not main_section: + raise Http404("不存在的板块") + sections = MainMenu.objects.filter(parent__isnull=False, parent__code=main).order_by('order') + sections_data = [ { - "key": "notice", + "key": section.code, "data": { - "通知通告": [ - {'title': '关于第二轮中央生态环境保护督察第四十一项整改任务销号...', 'date': '2026-03-11'}, - {'title': '关于第二轮中央生态环境保护督察第三十八项整改任务销号...', 'date': '2026-03-11'}, - {'title': '公告送达通知', 'date': '2026-03-10'}, - {'title': '吉林省长白山保护开发区管理委员会规划和自然资源局行政...', 'date': '2026-03-06'}, - {'title': '吉林省长白山保护开发区管理委员会规划和自然资源局行政...', 'date': '2026-03-06'}, - {'title': '吉林省长白山保护开发区管理委员会规划和自然资源局行政...', 'date': '2026-03-06'}, - ] - } - }, - { - "key": "law", - "data": { - "法律法规": [ - # ⚠️ 请替换为真实的法律法规数据 - {'title': '关于第二轮中央生态环境保护督察第四十一项整改任务销号...', 'date': '2026-03-11'}, - {'title': '关于第二轮中央生态环境保护督察第三十八项整改任务销号...', 'date': '2026-03-11'}, - {'title': '公告送达通知', 'date': '2026-03-10'}, - {'title': '吉林省长白山保护开发区管理委员会规划和自然资源局行政...', 'date': '2026-03-06'}, - {'title': '吉林省长白山保护开发区管理委员会规划和自然资源局行政...', 'date': '2026-03-06'}, - {'title': '吉林省长白山保护开发区管理委员会规划和自然资源局行政...', 'date': '2026-03-06'}, - ] - } - }, - { - "key": "policy_doc", - "data": { - "政策文件": [ - {'title': '中共长白山保护开发区工委办公室关于印发杨文慧同志在 2026...', 'date': '2026-03-05'}, - {'title': '长白山管委会办公室关于印发《长白山保护开发区落实<吉林省...', 'date': '2026-02-28'}, - {'title': '长白山保护开发区管理委员会 2025 年度行政执法工作报告', 'date': '2026-01-30'}, - {'title': '长白山管委会办公室关于印发《美丽长白山建设行动方案(202...', 'date': '2025-12-31'}, - {'title': '长白山管委会办公室关于印发《长白山保护开发区碳达峰实施...', 'date': '2025-12-31'}, - {'title': '《长白山保护开发区防雷减灾管理办法》政策解读', 'date': '2025-12-12'}, - ] - } - }, - { - "key": "policy_interp", - "data": { - "政策解读": [ - {'title': '《长白山保护开发区防雷减灾管理办法》政策解读', 'date': '2025-12-12'}, - {'title': '《长白山保护开发区旅游漂流活动管理规范》政策解读', 'date': '2025-07-23'}, - {'title': '《长白山 40 米射电望远镜电磁波宁静区保护办法》政策解读', 'date': '2025-07-23'}, - {'title': '《长白山保护开发区古树名木管理办法(试行)》政策解读', 'date': '2025-06-18'}, - {'title': '《长白山保护开发区公共租赁住房管理办法》政策解读', 'date': '2025-04-08'}, - {'title': '《长白山保护开发区旅游民宿管理与服务规范(试行)》政策解读', 'date': '2024-12-31'}, - ] + section.title: section.article_set.filter(is_published=True).order_by('-created_at')[:7], } } - ] - + for section in sections] + logger.info(sections_data) context = { - 'sections': sections, + 'main_section': main_section, + 'sections': sections_data, } return render(request, 'front/info.html', context=context) +def main_article_list(request, main): + """信息公开""" + logger.info(f"正在访问 {main}") + main_section = MainMenu.objects.filter(code=main).first() + if not main_section: + raise Http404("不存在的板块") + articles_qs = Article.objects.filter(is_published=True).order_by('-created_at') + + sections = main_section.active_children.order_by('order').prefetch_related( + Prefetch('article_set', queryset=articles_qs, to_attr='latest_articles') + ) + section_data = { + "key": main_section, + "data": { + main_section.title: [ + article + for section in sections + for article in section.latest_articles[:7] + ], + } + } + + logger.info(section_data) + context = { + 'main_section': main_section, + 'sections': sections, + 'articles': [ + article + for section in sections + for article in section.latest_articles[:7] + ] + } + return render(request, 'front/list.html', context=context) + + +def sub_article_list(request, main: str, sub: str): + """信息公开""" + logger.info(f"正在访问 {main}") + sections = MainMenu.objects.all().order_by('order') + main_section = sections.filter(code=main).first() + sub_section = sections.filter(code=sub, parent__code=main).first() + logger.info(f"主板块 {main_section} 子板块 {sub_section}") + if not sub_section or not main_section: + raise Http404("不存在的板块") + context = { + 'main_section': main_section, + 'sub_section': sub_section, + 'sections': sections.filter(parent__code=main), + 'articles': sub_section.article_set.filter(is_published=True).order_by('-created_at')[:21] + } + return render(request, 'front/list.html', context=context) + + def service(request): """办事服务""" q = request.GET.get('q') or "办事指南" @@ -286,7 +323,7 @@ def service(request): def party(request): """党建引领""" - sections = [ + sections: List[Dict] = [ { "key": "party_work", "data": { @@ -329,7 +366,7 @@ def party(request): }, ] - carousel = { + carousel: Dict = { "key": "index", "data": [ { diff --git a/news/__init__.py b/news/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/news/admin.py b/news/admin.py new file mode 100644 index 0000000..c743c97 --- /dev/null +++ b/news/admin.py @@ -0,0 +1,52 @@ +from django.contrib import admin +from django.utils.html import format_html +from import_export.admin import ImportExportModelAdmin + +from .models import NewsSection, News + + +# Register your models here. + + +# --- 1. 板块管理配置 --- +@admin.register(NewsSection) +class NewsSectionAdmin(ImportExportModelAdmin): + list_display = ('name', 'code', 'order', 'enabled') # 列表显示的字段 + list_editable = ('order', 'enabled') # 允许直接在列表页修改排序和状态 + search_fields = ('name', 'code') # 搜索框 + prepopulated_fields = {'code': ('name',)} # 输入名称时自动生成 Slug + ordering = ('order', 'name') # 默认排序 + + +# --- 2. 文章管理配置 --- +@admin.register(News) +class NewsAdmin(ImportExportModelAdmin): + # 列表页显示 + list_display = ('title', 'section', 'author', 'is_published', 'created_at', 'updated_at') + list_filter = ('section', 'is_published', 'created_at', 'author') # 右侧过滤器 + search_fields = ('title', 'content') # 搜索框(支持搜标题和内容) + date_hierarchy = 'created_at' # 顶部日期层级导航 + ordering = ('-created_at',) # 按创建时间倒序 + + # 编辑页布局 + fieldsets = ( + ('基本信息', { + 'fields': ('title', 'section', 'author', 'is_published') + }), + ('内容', { + # CKEditor5Field 会自动渲染为富文本编辑器,无需额外配置 widget + 'fields': ('content', 'cover'), + 'classes': ('wide',) # wide 类可以让编辑器占据更多宽度 + }), + ('系统信息', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse', 'extrapretty'), # collapse 默认折叠,extrapretty 美化 + 'description': '只读信息,自动生成' + }), + ) + + # 只读字段(系统自动生成的字段) + readonly_fields = ('created_at', 'updated_at') + + # 自动填充 Slug(如果以后给 News 加 slug 字段可以用) + # prepopulated_fields = {'slug': ('title',)} diff --git a/news/apps.py b/news/apps.py new file mode 100644 index 0000000..e7c3dbb --- /dev/null +++ b/news/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NewsConfig(AppConfig): + name = 'news' + verbose_name = '新闻管理' diff --git a/news/migrations/0001_initial.py b/news/migrations/0001_initial.py new file mode 100644 index 0000000..6566dc7 --- /dev/null +++ b/news/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 6.0.3 on 2026-04-01 00:55 + +import django_ckeditor_5.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='News', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('title', models.CharField(max_length=200, verbose_name='标题')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='正文')), + ('cover', models.ImageField(blank=True, upload_to='articles/%Y/%m/%d/', verbose_name='封面图')), + ('is_published', models.BooleanField(default=True, verbose_name='已发布')), + ], + options={ + 'verbose_name': '新闻管理', + 'verbose_name_plural': '新闻管理', + }, + ), + migrations.CreateModel( + name='NewsSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='模块名称')), + ('code', models.SlugField(help_text='用于模板或URL识别,建议英文', unique=True, verbose_name='唯一标识')), + ('order', models.PositiveSmallIntegerField(default=0, verbose_name='排序权重')), + ('enabled', models.BooleanField(default=True, verbose_name='启用状态')), + ], + options={ + 'verbose_name': '新闻模块', + 'verbose_name_plural': '新闻模块', + 'ordering': ('order', 'name'), + }, + ), + ] diff --git a/news/migrations/0002_initial.py b/news/migrations/0002_initial.py new file mode 100644 index 0000000..eee58f3 --- /dev/null +++ b/news/migrations/0002_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-04-01 00:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('news', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='作者'), + ), + migrations.AddField( + model_name='news', + name='section', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='news.newssection', verbose_name='所属板块'), + ), + ] diff --git a/news/migrations/__init__.py b/news/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/news/models.py b/news/models.py new file mode 100644 index 0000000..ab23b83 --- /dev/null +++ b/news/models.py @@ -0,0 +1,37 @@ +from django.db import models +from django_ckeditor_5.fields import CKEditor5Field + +from common.base import BaseEntity +from users.models import User + + +# Create your models here. +class NewsSection(BaseEntity): + name = models.CharField('模块名称', max_length=100, unique=True) + code = models.SlugField('唯一标识', max_length=50, unique=True, help_text='用于模板或URL识别,建议英文') + order = models.PositiveSmallIntegerField('排序权重', default=0) + enabled = models.BooleanField('启用状态', default=True) + + class Meta: + verbose_name = '新闻模块' + verbose_name_plural = '新闻模块' + ordering = ('order', 'name') + + def __str__(self): + return self.name + + +class News(BaseEntity): + title = models.CharField("标题", max_length=200) + section = models.ForeignKey(NewsSection, on_delete=models.CASCADE, verbose_name="所属板块") + author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="作者") + content = CKEditor5Field("正文") # 需安装 django-ckeditor + cover = models.ImageField("封面图", upload_to='articles/%Y/%m/%d/', blank=True) + is_published = models.BooleanField("已发布", default=True) + + class Meta: + verbose_name = "新闻管理" + verbose_name_plural = "新闻管理" + + def __str__(self): + return f"{self.title} -> {self.section}" diff --git a/news/seeder.py b/news/seeder.py new file mode 100644 index 0000000..1441257 --- /dev/null +++ b/news/seeder.py @@ -0,0 +1,67 @@ +import os +import random +import sys + +import django + +# 配置 Django 环境 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") # 修改为你的 settings 路径 +django.setup() + +# 2. 导入 Faker 和 模型 +from faker import Faker +from news.models import News, NewsSection +from users.models import User + +# 初始化 Faker (zh_CN 表示生成中文数据) +fake = Faker('zh_CN') + + +def seed_news(count=20): + print(f"🚀 开始生成 {count} 条新闻数据...") + + # --- 1. 获取关联数据 --- + # 获取所有板块 + sections = list(NewsSection.objects.all()) + # 获取所有用户 + users = list(User.objects.all()) + + # 检查基础数据是否存在 + if not sections: + print("❌ 错误:数据库中没有找到任何板块 (NewsSection)!请先添加板块。") + return + if not users: + print("⚠️ 警告:数据库中没有用户 (User),作者字段将设为空。") + + # --- 2. 生成数据 --- + created_count = 0 + for i in range(count): + # 随机选择一个板块 + section = random.choice(sections) + # 随机选择一个作者 (如果有用户) + author = random.choice(users) if users else None + + # 生成富文本内容 (模拟 CKEditor 输出的 HTML) + # paragrahs 生成多段文本,join 拼接 + paragraphs = fake.paragraphs(nb=random.randint(3, 6)) + content_html = "\n".join([f"

{p}

" for p in paragraphs]) + + # 创建新闻对象 + news = News.objects.create( + title=fake.sentence(nb_words=random.randint(4, 8))[:-1], # 生成标题并去掉末尾句号 + section=section, + author=author, + content=content_html, + is_published=True, # 默认已发布 + # 封面图留空,因为自动生成图片文件比较复杂,建议手动上传测试图 + ) + + created_count += 1 + print(f" - [{section.name}] {news.title}") + + print(f"✅ 成功生成 {created_count} 条新闻数据!") + + +if __name__ == "__main__": + seed_news(30) diff --git a/news/tests.py b/news/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/news/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/news/views.py b/news/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/news/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/poetry.lock b/poetry.lock index 25009c6..f3f289c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -46,7 +46,7 @@ version = "3.11.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"}, {file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"}, @@ -98,13 +98,33 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "diff-match-patch" +version = "20241021" +description = "Repackaging of Google's Diff Match and Patch libraries." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "diff_match_patch-20241021-py3-none-any.whl", hash = "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782"}, + {file = "diff_match_patch-20241021.tar.gz", hash = "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073"}, +] + +[package.extras] +dev = ["attribution (==1.8.0)", "black (==24.8.0)", "build (>=1)", "flit (==3.9.0)", "mypy (==1.12.1)", "ufmt (==2.7.3)", "usort (==1.0.8.post1)"] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "django" version = "6.0.3" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.12" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3"}, {file = "django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1"}, @@ -201,6 +221,39 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "django-import-export" +version = "4.4.0" +description = "Django application and library for importing and exporting data with included admin integration." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "django_import_export-4.4.0-py3-none-any.whl", hash = "sha256:2d9b234c0f024d3377167f4d9c5a506e095c5bad98e06d30700e1d0752829e3d"}, + {file = "django_import_export-4.4.0.tar.gz", hash = "sha256:9900e99c89027594941074fb4cd63a5f2964975e239021765c0f066003fcd412"}, +] + +[package.dependencies] +diff-match-patch = "20241021" +Django = ">=4.2" +tablib = ">=3.7.0" + +[package.extras] +all = ["tablib[all]"] +cli = ["tablib[cli]"] +docs = ["openpyxl (==3.1.5)", "psycopg[binary] (>=3.2.9)", "sphinx (==8.1.3)", "sphinx-rtd-theme (==3.0.1)"] +ods = ["tablib[ods]"] +pandas = ["tablib[pandas]"] +tests = ["chardet (==5.2.0)", "coverage (==7.6.4)", "django-extensions (==3.2.3)", "memory-profiler (==0.61.0)", "mysqlclient (==2.2.5)", "psycopg[binary] (>=3.2.9)", "pytz (==2024.2)", "setuptools-scm (==8.1.0)", "tablib[all] (>=3.7.0)"] +xls = ["tablib[xls]"] +xlsx = ["tablib[xlsx]"] +yaml = ["tablib[yaml]"] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "django-ninja" version = "1.6.2" @@ -251,6 +304,27 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "django-seed" +version = "0.3.1" +description = "Seed your Django project with fake data" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "django-seed-0.3.1.tar.gz", hash = "sha256:93e65d2c10449c464b83e9031be02763ec10e786aa064d649c4dd5ad06fa0eea"}, +] + +[package.dependencies] +django = ">=1.11" +Faker = ">=0.7.7" +toposort = ">=1.5" + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "django-simpleui" version = "2026.1.13" @@ -270,6 +344,29 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "faker" +version = "40.11.1" +description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "faker-40.11.1-py3-none-any.whl", hash = "sha256:3af3a213ba8fb33ce6ba2af7aef2ac91363dae35d0cec0b2b0337d189e5bee2a"}, + {file = "faker-40.11.1.tar.gz", hash = "sha256:61965046e79e8cfde4337d243eac04c0d31481a7c010033141103b43f603100c"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +tzdata = ["tzdata"] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "greenlet" version = "3.3.2" @@ -438,6 +535,30 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5,<4.0" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "markdown" version = "3.10.2" @@ -844,6 +965,23 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "pypinyin" +version = "0.55.0" +description = "汉字拼音转换模块/工具." +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" +groups = ["dev"] +files = [ + {file = "pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f"}, + {file = "pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b"}, +] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1144,7 +1282,7 @@ version = "0.5.5" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"}, {file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"}, @@ -1159,6 +1297,32 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "tablib" +version = "3.9.0" +description = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tablib-3.9.0-py3-none-any.whl", hash = "sha256:eda17cd0d4dda614efc0e710227654c60ddbeb1ca92cdcfc5c3bd1fc5f5a6e4a"}, + {file = "tablib-3.9.0.tar.gz", hash = "sha256:1b6abd8edb0f35601e04c6161d79660fdcde4abb4a54f66cc9f9054bd55d5fe2"}, +] + +[package.extras] +all = ["odfpy", "openpyxl (>=2.6.0)", "pandas", "pyyaml", "tabulate", "xlrd", "xlwt"] +cli = ["tabulate"] +ods = ["odfpy"] +pandas = ["pandas"] +xls = ["xlrd", "xlwt"] +xlsx = ["openpyxl (>=2.6.0)"] +yaml = ["pyyaml"] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "toml" version = "0.10.2" @@ -1176,6 +1340,23 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "toposort" +version = "1.10" +description = "Implements a topological sort algorithm." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87"}, + {file = "toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd"}, +] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1219,12 +1400,12 @@ version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main"] -markers = "sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, ] +markers = {main = "sys_platform == \"win32\"", dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} [package.source] type = "legacy" @@ -1535,7 +1716,28 @@ type = "legacy" url = "http://192.168.123.5:3141/root/pypi/+simple" reference = "mirrors" +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[package.source] +type = "legacy" +url = "http://192.168.123.5:3141/root/pypi/+simple" +reference = "mirrors" + [metadata] lock-version = "2.1" python-versions = ">=3.13,<3.14" -content-hash = "486f6320097292ce4db7f06f502b35949aaca16bc30356379e2be8e5c17bf774" +content-hash = "3e611ebdd242413bf20b6f4f63f2db3238f46614acb7d4e54d315075c7ba0d7a" diff --git a/pyproject.toml b/pyproject.toml index a5452e4..6c4d96c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,15 @@ django-environ = "^0.13.0" django-db-connection-pool = "^1.2.6" pillow = "^12.1.1" django-ckeditor-5 = "^0.2.20" +loguru = "^0.7.3" +django-import-export = "^4.4.0" +[tool.poetry.group.dev.dependencies] +pypinyin = "^0.55.0" +django-seed = "^0.3.1" + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/server/settings.py b/server/settings.py index 8cb421b..42ab2cf 100644 --- a/server/settings.py +++ b/server/settings.py @@ -11,10 +11,34 @@ https://docs.djangoproject.com/en/6.0/ref/settings/ """ import os from pathlib import Path +from loguru import logger +import environ + +env = environ.Env() # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# 获取 env文件路径 +env_file = BASE_DIR / "db/.env" + +environ.Env.read_env(env_file) +logger.info(".ENV变量输出", env.ENVIRON) +# 日志文件存储配置 +LOGURU_FILE = { + "is_show": "off", + "level": "ERROR", + 'path': os.path.join(Path(__file__).parent, 'db/logs', 'test{time:YYYY-MM-DD}.logu'), + "rotation": "10MB", + 'retention': "7days", +} + +# 日志控制台配置 +LOGURU_CONSOLE = { + "is_show": "on", # 配置是否开启控制台打印日志 + "level": "INFO", # 最低打印级别是info级别 +} + # 日志文件夹不存在时创建 logs_path = os.path.join(BASE_DIR, 'db/logs') if not os.path.exists(logs_path): @@ -29,7 +53,16 @@ SECRET_KEY = 'django-insecure-gr&k$v)_t*ax)yr1v9iz3x%$-j812ajpxs+fe2dn*dgy=a!730 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ["*"] +ALLOWED_HOSTS = ['*', "127.0.0.1", "localhost"] + +# 2. 【必须添加】这个配置专门用于解决 Origin checking failed 报错 +# 注意:必须包含协议头 (http://) 和端口号 +CSRF_TRUSTED_ORIGINS = [ + 'http://127.0.0.1:11111', # 对应你报错的端口 + 'http://127.0.0.1', + 'http://localhost:11111', + 'http://localhost', +] AUTH_USER_MODEL = 'users.User' @@ -43,18 +76,21 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'import_export', 'django_ckeditor_5', 'base.apps.BaseConfig', 'article.apps.ArticleConfig', 'users.apps.UsersConfig', - 'front.apps.ContentConfig' + 'front.apps.ContentConfig', + 'news.apps.NewsConfig', + 'django_seed', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', @@ -84,12 +120,40 @@ ASGI_APPLICATION = 'server.asgi.application' # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', +if env('DB_REMOTE', default=False): + # mysql_connect = dj_database_url.parse(os.getenv('MYSQL_CONNECTION'), conn_max_age=0) + # print("数据库连接信息", mysql_connect) + DATABASES = { + 'default': { + 'ENGINE': env('DB_ENGINE', default='dj_db_conn_pool.backends.postgresql'), + 'NAME': env('DB_NAME', default='harvest'), + 'USER': env('DB_USER', default='harvest'), + 'PASSWORD': env('DB_PASSWORD', default='harvest'), + 'HOST': env('DB_HOST', default='harvest_postgres'), + 'PORT': env('DB_PORT', default=5432), + 'CONN_MAX_AGE': env('DB_CONN_MAX_AGE', default=0), # 必须设为 0,让池管理连接生命周期 + 'OPTIONS': { + # 'MAX_CONNS': env('DB_MAX_CONNS', default=30), # 池中最大物理连接数 (根据 PG 总限制调整) + # 'REUSE_CONNS': env('DB_REUSE_CONNS', default=10), # 最小保留连接数 + }, + 'POOL_OPTIONS': { + 'POOL_SIZE': env('DB_MAX_CONNS', default=100), # 增加连接池大小 + 'MAX_OVERFLOW': 20, # 增加最大溢出连接数 + 'TIMEOUT': 30, # 连接超时设置 + } + }, + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db/data.sqlite3', + 'OPTIONS': { + 'timeout': 120, + 'check_same_thread': False + } + }, } -} # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators @@ -240,15 +304,25 @@ customColorPalette = [ }, ] -CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional -CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional +# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional +CKEDITOR_5_ALLOW_ALL_FILE_TYPES = True +CKEDITOR_5_UPLOAD_FILE_TYPES = [ + 'jpeg', 'png', 'jpg', 'gif', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', +] +# 允许上传非图片文件(PDF、Word、Excel、ZIP等) +CKEDITOR_ALLOW_NONIMAGE_FILES = True # 必须设为 True +CKEDITOR_5_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' # optional CKEDITOR_5_CONFIGS = { 'default': { 'toolbar': { 'items': ['heading', '|', 'bold', 'italic', 'link', - 'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ], + 'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', 'uploadFile', ], + }, + 'language': 'zh-cn', + # 2. 允许上传的文件类型 (根据需要扩展) + 'fileUploader': { + 'supportedFileTypes': ['pdf', 'doc', 'docx', 'zip', 'txt', 'jpg', 'png'], } - }, 'extends': { 'blockToolbar': [ @@ -261,12 +335,16 @@ CKEDITOR_5_CONFIGS = { 'toolbar': { 'items': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough', 'code', 'subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage', - 'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', '|', + 'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', 'uploadFile', '|', 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat', 'insertTable', ], 'shouldNotGroupWhenFull': 'true' }, + # --- 在 extends 配置中也必须加上这个 --- + 'fileUploader': { + 'supportedFileTypes': ['pdf', 'doc', 'docx', 'zip', 'txt'] + }, 'image': { 'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side', '|'], @@ -313,4 +391,3 @@ CKEDITOR_5_CONFIGS = { CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any" # CK_EDITOR_5_UPLOAD_FILE_VIEW_NAME = "custom_upload_file" - diff --git a/server/urls.py b/server/urls.py index e784c5b..0862e00 100644 --- a/server/urls.py +++ b/server/urls.py @@ -17,7 +17,7 @@ Including another URLconf from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from django.urls import path, include +from django.urls import path, include, re_path import logging import os @@ -33,8 +33,9 @@ from ninja import NinjaAPI from ninja.errors import ValidationError from ninja.security import HttpBearer +from base.views import custom_upload_file +from common.common_response import CommonResponse from .settings import SECRET_KEY -from utils.common_response import CommonResponse logger = logging.getLogger('ptools') @@ -68,6 +69,7 @@ class GlobalAuth(HttpBearer): # 2. 获取当前文件所在的目录路径 (假设此文件与各个 app 文件夹同级,或者在特定配置目录下) # 如果你的此文件在项目根目录,而 app 文件夹也在根目录: from django.conf import settings + project_root = settings.BASE_DIR @@ -123,6 +125,7 @@ def auto_load_routers(base_path, package_name_prefix=None): except Exception as e: print(f"❌ 注册失败 {modname}: {e}") + api_v1 = NinjaAPI(version='1.0.0', auth=GlobalAuth()) auto_load_routers(project_root) @@ -152,7 +155,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path("api/", api_v1.urls), path("ckeditor5/", include('django_ckeditor_5.urls')), - # path("upload/", custom_upload_function, name="custom_upload_file"), + path("upload/", custom_upload_file, name="custom_upload_file"), ] urlpatterns += staticfiles_urlpatterns() diff --git a/templates/base.html b/templates/base.html index 327a42f..cd033a9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,6 @@ {% load static %} +{% load footer_tags %} + @@ -39,32 +41,33 @@
  • 单位简介
  • -
  • 新闻中心
  • + {% show_main_menu %} - -
  • - 信息公开 -
  • - -
  • - 党建引领 -
  • - -
  • - 办事服务 -
  • - -
  • - 互动交流 -
  • - -
  • - 消防科普 -
  • + {##} + {# #} + {#
  • #} + {# 信息公开#} + {#
  • #} + {# #} + {#
  • #} + {# 党建引领#} + {#
  • #} + {# #} + {#
  • #} + {# 办事服务#} + {#
  • #} + {# #} + {#
  • #} + {# 互动交流#} + {#
  • #} + {# #} + {#
  • #} + {# 消防科普#} + {#
  • #} @@ -81,75 +84,77 @@
    {% block content %}{% endblock %}
    + +{% show_footer %} -
    -
    -
    - -
    - -
    -
    - -
    -
    -
    - -
    +{#
    #} +{#
    #} +{#
    #} +{#
    #} +{# #} +{# #} +{# 友情链接#} +{# #} +{#
    #} +{#
    #} +{# #} +{#
    #} +{#
    #} +{# #} +{#
    #} +{#
    #} +{#
    #} +{# #} +{#
    #} - \ No newline at end of file + diff --git a/templates/components/_news_card.html b/templates/components/_news_card.html index 92388a9..4f9ced9 100644 --- a/templates/components/_news_card.html +++ b/templates/components/_news_card.html @@ -47,12 +47,17 @@ {% if items %} {% for item in items %}
  • + +
    {{ item.title|truncatechars:30 }}
    - {{ item.date }} +
    + {{ item.created_at | date:"Y-m-d" }} +
  • {% endfor %} {% else %} @@ -63,4 +68,4 @@ {% endfor %} - \ No newline at end of file + diff --git a/templates/components/footer.html b/templates/components/footer.html new file mode 100644 index 0000000..8ae4818 --- /dev/null +++ b/templates/components/footer.html @@ -0,0 +1,95 @@ +{% load static %} + +
    + +
    +
    + + + {% for category in categories %} + + +
    + +
    + + {% empty %} + +
    + +
    + {% endfor %} +
    +
    + + +
    diff --git a/templates/components/main_menu.html b/templates/components/main_menu.html new file mode 100644 index 0000000..9f7b415 --- /dev/null +++ b/templates/components/main_menu.html @@ -0,0 +1,9 @@ +{% load static %} + +{% for menu in menus %} + + +{% endfor %} + diff --git a/templates/footer.html b/templates/footer.html deleted file mode 100644 index 882e83a..0000000 --- a/templates/footer.html +++ /dev/null @@ -1,45 +0,0 @@ -{% load static %} -
    - - - - - -
    \ No newline at end of file diff --git a/templates/front/details/article.html b/templates/front/details/article.html new file mode 100644 index 0000000..f29041a --- /dev/null +++ b/templates/front/details/article.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} + +{% block title %}{{ article.section.title }} - 长白山消防救援支队{% endblock %} + +{% block content %} +
    + + + + +
    +
    + + +
    +

    {{ article.title }}

    +
    + {{ article.created_at|date:"Y-m-d H:i" }} + {{ article.author.username|default:"管理员" }} + 阅读数:{{ article.views|default:0 }} +
    +
    + + + {% if article.cover %} +
    + {{ article.title }} +
    + {% endif %} + + +
    + {{ article.content|safe }} +
    + + + {% if article.attachments_list %} +
    +
    + 相关附件 +
    + +
    + {% endif %} + + + + +
    +
    +
    +{% endblock %} diff --git a/templates/articles/list.html b/templates/front/details/news.html similarity index 93% rename from templates/articles/list.html rename to templates/front/details/news.html index 566549b..e149a39 100644 --- a/templates/articles/list.html +++ b/templates/front/details/news.html @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/templates/front/error/404.html b/templates/front/error/404.html new file mode 100644 index 0000000..b671e2a --- /dev/null +++ b/templates/front/error/404.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} + +{% block title %}页面未找到 - 404 - 长白山消防救援支队{% endblock %} + +{% block content %} +
    + + + + +
    +
    + + +
    + + + + +
    + + +

    404

    + + +

    抱歉,您访问的页面不存在

    +

    + 您请求的页面可能已被删除、名称已更改或暂时不可用。
    + 请检查网址是否正确,或返回首页查找所需信息。 +

    + + +
    + + 返回首页 + + +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/front/error/500.html b/templates/front/error/500.html new file mode 100644 index 0000000..216db54 --- /dev/null +++ b/templates/front/error/500.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} + +{% block title %}服务器内部错误 - 500 - 长白山消防救援支队{% endblock %} + +{% block content %} +
    + + + + +
    +
    + + +
    + + + + +
    + + +

    500

    + + +

    系统内部错误

    +

    + 抱歉,服务器遇到了一些意外情况,无法完成您的请求。
    + 我们的技术人员已经收到了错误报告,正在紧急排查修复中。 +

    + + +
    + + 返回首页 + + + +
    + + +
    +

    + 温馨提示:
    + 如果您急需办理业务,请稍后再试,或联系网站管理员。
    + 联系电话:0433-xxxxxxx (示例) +

    +
    + +
    +
    +
    +{% endblock %} diff --git a/templates/front/h.html b/templates/front/h.html deleted file mode 100644 index d569ab3..0000000 --- a/templates/front/h.html +++ /dev/null @@ -1,21 +0,0 @@ -{% load static %} - - - - - 长白山消防救援支队 - - - - -
    -
    - -
    -

    长白山消防救援支队

    -

    CHANGBAI MOUNTAIN FIRE AND RESCUE BRIGADE

    -
    -
    -
    - - \ No newline at end of file diff --git a/templates/front/index.html b/templates/front/index.html index 040fd0b..7b55ded 100644 --- a/templates/front/index.html +++ b/templates/front/index.html @@ -66,9 +66,10 @@
    {% for section in section_data %}
    - {% include 'components/_news_card.html' with data=section.data unique_id=section.key %} + {% url "front:main_list" main=section.key as card_more_url %} + {% include 'components/_news_card.html' with data=section.data unique_id=section.key more_url=card_more_url %}
    {% endfor %}
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/front/info.html b/templates/front/info.html index 4fab362..735276d 100644 --- a/templates/front/info.html +++ b/templates/front/info.html @@ -1,19 +1,21 @@ {% extends 'base.html' %} -{% block title %}信息公开 - 长白山消防救援支队{% endblock %} +{% block title %}{{ main_section.title }} - 长白山消防救援支队{% endblock %} {% block content %}
    -
    +
    {% for section in sections %}
    - {% include 'components/_news_card.html' with data=section.data unique_id=section.key %} + {% url "front:sub_list" main=main_section.code sub=section.key as card_more_url %} + + {% include 'components/_news_card.html' with data=section.data unique_id=section.key more_url=card_more_url %}
    {% endfor %}
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/front/list.html b/templates/front/list.html new file mode 100644 index 0000000..f462927 --- /dev/null +++ b/templates/front/list.html @@ -0,0 +1,172 @@ +{% extends 'base.html' %} +{% block title %}{{ main_section.title }} - 长白山消防救援支队{% endblock %} +{% block content %} +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + + {{ sub_section.title | default:main_section.title }} +
    +
    +
    + + {# #} + {#
    #} + {#
    #} + {#
    #} + {#
    #} +
      + {# items 就是当前 key 对应的列表数据,直接使用 #} + {% if articles %} + {% for item in articles %} +
    • +
      + + {{ item.title|truncatechars:30 }} +
      + {{ item.created_at }} +
    • + {% endfor %} + {% else %} +
    • 暂无数据
    • + {% endif %} +
    +
    + +
    + +
    +
    +
    +
    + +{% endblock %} diff --git a/templates/front/news.html b/templates/front/news.html index 97f239f..b549502 100644 --- a/templates/front/news.html +++ b/templates/front/news.html @@ -8,7 +8,7 @@
    @@ -19,4 +19,4 @@ {% endfor %}
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/users/admin.py b/users/admin.py index 761733d..f130276 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,9 +1,14 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from import_export.admin import ImportExportModelAdmin + from .models import User +from .models import Department +from .models import Role, Permission + @admin.register(User) -class UserAdmin(BaseUserAdmin): +class UserAdmin(BaseUserAdmin, ImportExportModelAdmin): list_display = ('username', 'cname', 'dept', 'is_active', 'is_staff') list_filter = ('is_active', 'is_staff', 'dept') search_fields = ('username', 'cname') @@ -22,31 +27,29 @@ class UserAdmin(BaseUserAdmin): ordering = ('username',) filter_horizontal = ('groups', 'user_permissions', 'roles') # 如果 roles 是 ManyToMany -from django.contrib import admin -from .models import Department @admin.register(Department) -class DepartmentAdmin(admin.ModelAdmin): +class DepartmentAdmin(ImportExportModelAdmin): list_display = ('name', 'parent', 'order') list_filter = ('parent',) search_fields = ('name',) ordering = ('order', 'name') -from django.contrib import admin -from .models import Role, Permission + @admin.register(Role) -class RoleAdmin(admin.ModelAdmin): +class RoleAdmin(ImportExportModelAdmin): list_display = ('name', 'permission_count') search_fields = ('name',) filter_horizontal = ('permissions',) def permission_count(self, obj): return obj.permissions.count() + permission_count.short_description = "权限数量" @admin.register(Permission) -class PermissionAdmin(admin.ModelAdmin): +class PermissionAdmin(ImportExportModelAdmin): list_display = ('resource', 'action', 'desc') list_filter = ('resource', 'action') search_fields = ('resource', 'desc') diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index a8686d5..0387ec9 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.3 on 2026-03-24 10:31 +# Generated by Django 6.0.3 on 2026-04-01 00:55 import django.contrib.auth.models import django.db.models.deletion diff --git a/users/seeder.py b/users/seeder.py new file mode 100644 index 0000000..1cbfeda --- /dev/null +++ b/users/seeder.py @@ -0,0 +1,46 @@ +import os +import sys + +import django +from faker import Faker + +# 配置 Django 环境 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") # 修改为你的 settings 路径 +django.setup() + +from users.models import User + +# 初始化 Faker,设置中文(如果需要中文名) +fake = Faker('zh_CN') + + +def seed_users(count=5): + print(f"正在生成 {count} 个用户...") + + users = [] + for i in range(count): + # 生成随机用户名 + username = fake.user_name() + + # 检查用户名是否已存在,避免冲突 + if User.objects.filter(username=username).exists(): + continue + + user = User.objects.create_user( + username=username, + email=fake.safe_email(), + password='password123', # 统一密码,方便测试登录 + is_active=True, + is_staff=False, # 随机是否为管理员 + first_name=fake.first_name(), + last_name=fake.last_name() + ) + users.append(user) + print(f" - 创建用户: {username} (密码: password123)") + + print("用户生成完毕!") + + +if __name__ == "__main__": + seed_users(10) diff --git a/utils/upload_to.py b/utils/upload_to.py new file mode 100644 index 0000000..f2abe3c --- /dev/null +++ b/utils/upload_to.py @@ -0,0 +1,95 @@ +import os +import hashlib +from datetime import datetime + + +def _get_file_md5(file_obj): + """ + 通用辅助函数:计算任意文件对象的 MD5 + """ + md5 = hashlib.md5() + + # 关键步骤:重置文件指针到开头 + # 因为 Django 在处理上传时可能已经读取了一部分文件头 + try: + file_obj.seek(0) + except Exception: + pass + + # 分块读取计算,防止大文件撑爆内存 + for chunk in file_obj.chunks(): + md5.update(chunk) + + # 计算完后再次重置指针,确保不影响 Django 后续的保存操作 + try: + file_obj.seek(0) + except Exception: + pass + + return md5.hexdigest() + + +def upload_image_and_rename(instance, filename): + """ + 通用图片上传处理函数 + 逻辑:存入 images/年/月/日/md5.ext + """ + # 1. 获取后缀 + ext = os.path.splitext(filename)[1].lower() + + # 2. 生成日期路径 + date_str = datetime.now().strftime('%Y/%m/%d') + + # 3. 计算 MD5 + # 这里直接利用传入的 filename 对应的文件对象(在内存或临时文件中) + # 注意:这里假设 filename 是有效的 UploadedFile 对象名称, + # 但最稳妥的方式其实是直接操作 file_obj。 + # 由于 upload_to 签名限制,我们通常通过 instance 获取字段值, + # 但为了通用性,我们尝试从 instance 的 __dict__ 中寻找 FileField 对象。 + + file_obj = None + # 遍历 instance 的所有属性,找到第一个 FileField 类型的值 + for field in instance._meta.get_fields(): + if hasattr(field, 'upload_to') and hasattr(instance, field.name): + val = getattr(instance, field.name) + if val and hasattr(val, 'chunks'): # 确认是文件对象 + file_obj = val + break + + # 如果找不到文件对象(极少见情况),使用随机字符串防止报错 + if not file_obj: + import uuid + new_filename = f"{uuid.uuid4().hex}{ext}" + else: + md5_hash = _get_file_md5(file_obj) + new_filename = f"{md5_hash}{ext}" + + # 4. 返回路径 + return os.path.join('images', date_str, new_filename) + + +def upload_file_and_rename(instance, filename): + """ + 通用文件上传处理函数 + 逻辑:存入 uploads/年/月/日/md5.ext + """ + ext = os.path.splitext(filename)[1].lower() + date_str = datetime.now().strftime('%Y/%m/%d') + + file_obj = None + # 同样的通用查找逻辑 + for field in instance._meta.get_fields(): + if hasattr(field, 'upload_to') and hasattr(instance, field.name): + val = getattr(instance, field.name) + if val and hasattr(val, 'chunks'): + file_obj = val + break + + if not file_obj: + import uuid + new_filename = f"{uuid.uuid4().hex}{ext}" + else: + md5_hash = _get_file_md5(file_obj) + new_filename = f"{md5_hash}{ext}" + + return os.path.join('uploads', date_str, new_filename)