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 829d1a2..0000000 Binary files a/db.sqlite3 and /dev/null differ diff --git a/front/urls.py b/front/urls.py index 5b1b3a2..59e4ea9 100644 --- a/front/urls.py +++ b/front/urls.py @@ -6,7 +6,9 @@ app_name = 'front' # 可选,用于命名空间(如 {% url 'main:index' %} urlpatterns = [ path('', views.index, name='index'), - path('h/', views.index, name='h'), + path('.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)