From 8fbbd2e6b08ab5651311b5973033f32a09b42953 Mon Sep 17 00:00:00 2001 From: Zhengen TANG Date: Fri, 7 Aug 2026 13:06:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9ESnippet=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=86=85=E5=AE=B9=E6=A8=A1=E5=9E=8B=EF=BC=88TeamMembe?= =?UTF-8?q?r/Testimonial/Partner/NavigationMenu/SiteSettings=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 apps.core.TeamMember/Testimonial/Partner:简单 register_snippet,含 order 排序字段 - 新增 apps.core.NavigationMenu+NavigationMenuItem:ClusterableModel+Orderable+InlinePanel, 支持内部页面或外部链接二选一 - 新增 apps.core.SiteSettings:接入 wagtail.contrib.settings + BaseSiteSetting, 存放公司信息/ICP备案号/社交账号等全局配置 - 新增只读 API:/api/v1/custom/core/{team,testimonials,partners,navigation,site-settings}/ - 补充 apps/core/tests.py 单元测试,pytest 用例由 11 个增至 24 个,全部通过 - 更新设计文档 §2.16 Phase 1 进度清单 --- apps/core/api.py | 67 +++++ ...enu_navigationmenuitem_partner_and_more.py | 110 ++++++++ apps/core/models.py | 235 +++++++++++++++++- apps/core/serializers.py | 74 ++++++ apps/core/tests.py | 122 ++++++++- apps/core/urls.py | 17 ++ documents/设计方案分析与完善版.md | 4 +- wagtailcms/settings/base.py | 2 + wagtailcms/urls.py | 2 + 9 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 apps/core/api.py create mode 100644 apps/core/migrations/0002_navigationmenu_navigationmenuitem_partner_and_more.py create mode 100644 apps/core/serializers.py create mode 100644 apps/core/urls.py diff --git a/apps/core/api.py b/apps/core/api.py new file mode 100644 index 0000000..c477f60 --- /dev/null +++ b/apps/core/api.py @@ -0,0 +1,67 @@ +""" +apps.core 全局 Snippet 只读接口。挂载在 /api/v1/custom/core/ +(详见 apps/core/urls.py 与 documents/设计方案分析与完善版.md §2.7)。 +公开只读数据,限流走 DRF ScopedRateThrottle 的 "public" scope(100/min)。 +""" +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from .models import NavigationMenu, Partner, SiteSettings, TeamMember, Testimonial +from .serializers import ( + NavigationMenuSerializer, + PartnerSerializer, + SiteSettingsSerializer, + TeamMemberSerializer, + TestimonialSerializer, +) + + +class TeamMemberListView(APIView): + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public" + + def get(self, request, *args, **kwargs): + members = TeamMember.objects.all() + return Response({"items": TeamMemberSerializer(members, many=True).data}) + + +class TestimonialListView(APIView): + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public" + + def get(self, request, *args, **kwargs): + testimonials = Testimonial.objects.all() + return Response({"items": TestimonialSerializer(testimonials, many=True).data}) + + +class PartnerListView(APIView): + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public" + + def get(self, request, *args, **kwargs): + partners = Partner.objects.all() + return Response({"items": PartnerSerializer(partners, many=True).data}) + + +class NavigationMenuView(APIView): + """根据 ?name= 查询指定导航菜单(如 main / footer)。""" + + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public" + + def get(self, request, *args, **kwargs): + name = request.query_params.get("name", "main") + menu = NavigationMenu.objects.prefetch_related("items").filter(name=name).first() + if menu is None: + return Response({"error": {"code": "NOT_FOUND", "message": "导航菜单不存在"}}, status=404) + return Response(NavigationMenuSerializer(menu).data) + + +class SiteSettingsView(APIView): + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public" + + def get(self, request, *args, **kwargs): + settings_obj = SiteSettings.for_request(request) + return Response(SiteSettingsSerializer(settings_obj).data) diff --git a/apps/core/migrations/0002_navigationmenu_navigationmenuitem_partner_and_more.py b/apps/core/migrations/0002_navigationmenu_navigationmenuitem_partner_and_more.py new file mode 100644 index 0000000..b5fcba8 --- /dev/null +++ b/apps/core/migrations/0002_navigationmenu_navigationmenuitem_partner_and_more.py @@ -0,0 +1,110 @@ +# Generated by Django 6.0.5 on 2026-08-07 03:14 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.CreateModel( + name='NavigationMenu', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='前端按此标识获取菜单,如 main(主导航)、footer(页脚导航)', max_length=50, unique=True, verbose_name='菜单标识')), + ], + options={ + 'verbose_name': '导航菜单', + 'verbose_name_plural': '导航菜单', + }, + ), + migrations.CreateModel( + name='NavigationMenuItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('label', models.CharField(max_length=50, verbose_name='文案')), + ('link_url', models.CharField(blank=True, help_text='留空则使用内部页面链接', max_length=255, verbose_name='外部链接')), + ('open_in_new_tab', models.BooleanField(default=False, verbose_name='新标签页打开')), + ('link_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page', verbose_name='内部页面')), + ('menu', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.navigationmenu')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Partner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='名称')), + ('website_url', models.URLField(blank=True, verbose_name='官网链接')), + ('order', models.IntegerField(default=0, verbose_name='排序')), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Logo')), + ], + options={ + 'verbose_name': '合作伙伴', + 'verbose_name_plural': '合作伙伴', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司全称')), + ('contact_phone', models.CharField(blank=True, max_length=30, verbose_name='联系电话')), + ('contact_email', models.EmailField(blank=True, max_length=254, verbose_name='联系邮箱')), + ('address', models.CharField(blank=True, max_length=255, verbose_name='公司地址')), + ('icp_number', models.CharField(blank=True, max_length=50, verbose_name='ICP备案号')), + ('icp_url', models.URLField(default='https://beian.miit.gov.cn/', verbose_name='备案查询链接')), + ('wechat_account', models.CharField(blank=True, max_length=100, verbose_name='微信公众号名称')), + ('weibo_url', models.URLField(blank=True, verbose_name='微博主页')), + ('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')), + ('wechat_qrcode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='微信公众号二维码')), + ], + options={ + 'verbose_name': '站点设置', + }, + ), + migrations.CreateModel( + name='TeamMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='姓名')), + ('role', models.CharField(blank=True, max_length=100, verbose_name='职位')), + ('bio', models.TextField(blank=True, verbose_name='简介')), + ('order', models.IntegerField(default=0, verbose_name='排序')), + ('photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='照片')), + ], + options={ + 'verbose_name': '团队成员', + 'verbose_name_plural': '团队成员', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='Testimonial', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quote', models.TextField(verbose_name='推荐语')), + ('author_name', models.CharField(max_length=50, verbose_name='客户姓名')), + ('author_title', models.CharField(blank=True, max_length=100, verbose_name='客户职位/公司')), + ('order', models.IntegerField(default=0, verbose_name='排序')), + ('author_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='客户头像')), + ], + options={ + 'verbose_name': '客户证言', + 'verbose_name_plural': '客户证言', + 'ordering': ['order', 'id'], + }, + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index f70e501..bde89f7 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,11 +1,19 @@ """ 公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page, 以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。 + +本文件同时存放跨页面复用的全局 Snippet:TeamMember/Testimonial/Partner/ +NavigationMenu/SiteSettings(详见 documents/设计方案分析与完善版.md §2.5、§2.16)。 """ from django.db import models -from wagtail.admin.panels import FieldPanel +from modelcluster.fields import ParentalKey +from modelcluster.models import ClusterableModel +from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel +from wagtail.contrib.settings.models import BaseSiteSetting +from wagtail.contrib.settings.registry import register_setting from wagtail.fields import RichTextField -from wagtail.models import Page +from wagtail.models import Orderable, Page +from wagtail.snippets.models import register_snippet class SEOablePage(Page): @@ -53,3 +61,226 @@ class SimpleContentPage(SEOablePage): class Meta: verbose_name = "通用内容页" + + +@register_snippet +class TeamMember(models.Model): + """团队成员,用于关于我们/团队介绍等场景复用。""" + + name = models.CharField(max_length=50, verbose_name="姓名") + role = models.CharField(max_length=100, blank=True, verbose_name="职位") + photo = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="照片", + ) + bio = models.TextField(blank=True, verbose_name="简介") + order = models.IntegerField(default=0, verbose_name="排序") + + panels = [ + FieldPanel("name"), + FieldPanel("role"), + FieldPanel("photo"), + FieldPanel("bio"), + FieldPanel("order"), + ] + + class Meta: + ordering = ["order", "id"] + verbose_name = "团队成员" + verbose_name_plural = "团队成员" + + def __str__(self): + return self.name + + +@register_snippet +class Testimonial(models.Model): + """客户证言/推荐语,用于首页、案例页等场景复用。""" + + quote = models.TextField(verbose_name="推荐语") + author_name = models.CharField(max_length=50, verbose_name="客户姓名") + author_title = models.CharField( + max_length=100, blank=True, verbose_name="客户职位/公司" + ) + author_photo = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="客户头像", + ) + order = models.IntegerField(default=0, verbose_name="排序") + + panels = [ + FieldPanel("quote"), + FieldPanel("author_name"), + FieldPanel("author_title"), + FieldPanel("author_photo"), + FieldPanel("order"), + ] + + class Meta: + ordering = ["order", "id"] + verbose_name = "客户证言" + verbose_name_plural = "客户证言" + + def __str__(self): + return f"{self.author_name} - {self.quote[:20]}" + + +@register_snippet +class Partner(models.Model): + """合作伙伴/客户 Logo,用于 Logo 墙展示。""" + + name = models.CharField(max_length=100, verbose_name="名称") + logo = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="Logo", + ) + website_url = models.URLField(blank=True, verbose_name="官网链接") + order = models.IntegerField(default=0, verbose_name="排序") + + panels = [ + FieldPanel("name"), + FieldPanel("logo"), + FieldPanel("website_url"), + FieldPanel("order"), + ] + + class Meta: + ordering = ["order", "id"] + verbose_name = "合作伙伴" + verbose_name_plural = "合作伙伴" + + def __str__(self): + return self.name + + +@register_snippet +class NavigationMenu(ClusterableModel): + """导航菜单容器,通过 name 区分主导航/页脚导航等多套菜单。""" + + name = models.CharField( + max_length=50, + unique=True, + verbose_name="菜单标识", + help_text="前端按此标识获取菜单,如 main(主导航)、footer(页脚导航)", + ) + + panels = [ + FieldPanel("name"), + InlinePanel("items", label="菜单项"), + ] + + class Meta: + verbose_name = "导航菜单" + verbose_name_plural = "导航菜单" + + def __str__(self): + return self.name + + +class NavigationMenuItem(Orderable): + """导航菜单项:可链接到内部页面,也可填写外部链接。""" + + menu = ParentalKey( + NavigationMenu, on_delete=models.CASCADE, related_name="items" + ) + label = models.CharField(max_length=50, verbose_name="文案") + link_page = models.ForeignKey( + Page, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="内部页面", + ) + link_url = models.CharField( + max_length=255, + blank=True, + verbose_name="外部链接", + help_text="留空则使用内部页面链接", + ) + open_in_new_tab = models.BooleanField(default=False, verbose_name="新标签页打开") + + panels = [ + FieldPanel("label"), + FieldPanel("link_page"), + FieldPanel("link_url"), + FieldPanel("open_in_new_tab"), + ] + + @property + def url(self): + if self.link_url: + return self.link_url + if self.link_page_id: + return self.link_page.url + return "#" + + def __str__(self): + return self.label + + +@register_setting(icon="cogs") +class SiteSettings(BaseSiteSetting): + """站点全局配置:公司信息/联系方式/备案号/社交账号,供 Footer 等全局位置消费。""" + + company_name = models.CharField(max_length=100, blank=True, verbose_name="公司全称") + contact_phone = models.CharField(max_length=30, blank=True, verbose_name="联系电话") + contact_email = models.EmailField(blank=True, verbose_name="联系邮箱") + address = models.CharField(max_length=255, blank=True, verbose_name="公司地址") + + icp_number = models.CharField(max_length=50, blank=True, verbose_name="ICP备案号") + icp_url = models.URLField( + default="https://beian.miit.gov.cn/", verbose_name="备案查询链接" + ) + + wechat_qrcode = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="微信公众号二维码", + ) + wechat_account = models.CharField( + max_length=100, blank=True, verbose_name="微信公众号名称" + ) + weibo_url = models.URLField(blank=True, verbose_name="微博主页") + + panels = [ + MultiFieldPanel( + [ + FieldPanel("company_name"), + FieldPanel("contact_phone"), + FieldPanel("contact_email"), + FieldPanel("address"), + ], + heading="联系方式", + ), + MultiFieldPanel( + [FieldPanel("icp_number"), FieldPanel("icp_url")], + heading="备案信息", + ), + MultiFieldPanel( + [ + FieldPanel("wechat_qrcode"), + FieldPanel("wechat_account"), + FieldPanel("weibo_url"), + ], + heading="社交账号", + ), + ] + + class Meta: + verbose_name = "站点设置" diff --git a/apps/core/serializers.py b/apps/core/serializers.py new file mode 100644 index 0000000..85453de --- /dev/null +++ b/apps/core/serializers.py @@ -0,0 +1,74 @@ +""" +apps.core 全局 Snippet 的只读序列化器,供自定义业务接口 +(/api/v1/custom/core/...)使用。图片字段统一序列化为 +{"url": ..., "title": ...},与前端 frontend/types/wagtail.ts +中既有的 background_image/logos 字段结构保持一致。 +""" +from rest_framework import serializers + + +def serialize_image(image): + if not image: + return None + return {"url": image.file.url, "title": image.title} + + +class TeamMemberSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + role = serializers.CharField() + bio = serializers.CharField() + photo = serializers.SerializerMethodField() + + def get_photo(self, obj): + return serialize_image(obj.photo) + + +class TestimonialSerializer(serializers.Serializer): + id = serializers.IntegerField() + quote = serializers.CharField() + author_name = serializers.CharField() + author_title = serializers.CharField() + author_photo = serializers.SerializerMethodField() + + def get_author_photo(self, obj): + return serialize_image(obj.author_photo) + + +class PartnerSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + website_url = serializers.CharField() + logo = serializers.SerializerMethodField() + + def get_logo(self, obj): + return serialize_image(obj.logo) + + +class NavigationMenuItemSerializer(serializers.Serializer): + label = serializers.CharField() + url = serializers.CharField() + open_in_new_tab = serializers.BooleanField() + + +class NavigationMenuSerializer(serializers.Serializer): + name = serializers.CharField() + items = serializers.SerializerMethodField() + + def get_items(self, obj): + return NavigationMenuItemSerializer(obj.items.all(), many=True).data + + +class SiteSettingsSerializer(serializers.Serializer): + company_name = serializers.CharField() + contact_phone = serializers.CharField() + contact_email = serializers.CharField() + address = serializers.CharField() + icp_number = serializers.CharField() + icp_url = serializers.CharField() + wechat_account = serializers.CharField() + weibo_url = serializers.CharField() + wechat_qrcode = serializers.SerializerMethodField() + + def get_wechat_qrcode(self, obj): + return serialize_image(obj.wechat_qrcode) diff --git a/apps/core/tests.py b/apps/core/tests.py index bf83f00..9a3ff73 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -1,8 +1,17 @@ """apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。""" import pytest +from rest_framework.test import APIClient from apps.core.blocks import FormBlock -from apps.core.models import SimpleContentPage +from apps.core.models import ( + NavigationMenu, + NavigationMenuItem, + Partner, + SimpleContentPage, + SiteSettings, + TeamMember, + Testimonial, +) from apps.forms.models import FormDefinition, FormDefinitionField pytestmark = pytest.mark.django_db @@ -75,3 +84,114 @@ def test_simple_content_page_can_be_created_under_home(home_page): page.refresh_from_db() assert SimpleContentPage.objects.live().descendant_of(home_page).count() == 1 assert "个人信息保护" in page.body + + +def test_team_member_default_ordering(): + """团队成员应按 order 字段升序排列。""" + TeamMember.objects.create(name="李四", order=2) + TeamMember.objects.create(name="张三", order=1) + names = list(TeamMember.objects.values_list("name", flat=True)) + assert names == ["张三", "李四"] + + +def test_testimonial_str_includes_author_and_quote(): + testimonial = Testimonial.objects.create( + quote="非常专业的服务,效果超出预期。", author_name="王经理" + ) + assert "王经理" in str(testimonial) + + +def test_partner_str_returns_name(): + partner = Partner.objects.create(name="示例合作伙伴") + assert str(partner) == "示例合作伙伴" + + +def test_navigation_menu_item_url_prefers_external_link_over_page(home_page): + menu = NavigationMenu.objects.create(name="main") + item = NavigationMenuItem.objects.create( + menu=menu, label="首页", link_page=home_page, link_url="https://example.com" + ) + assert item.url == "https://example.com" + + +def test_navigation_menu_item_url_falls_back_to_page_url(home_page): + menu = NavigationMenu.objects.create(name="main") + item = NavigationMenuItem.objects.create(menu=menu, label="首页", link_page=home_page) + assert item.url == home_page.url + + +def test_navigation_menu_item_url_defaults_to_hash_when_empty(): + menu = NavigationMenu.objects.create(name="main") + item = NavigationMenuItem.objects.create(menu=menu, label="占位") + assert item.url == "#" + + +def test_site_settings_for_site_creates_singleton_with_defaults(): + from wagtail.models import Site + + site = Site.objects.get(is_default_site=True) + settings_obj = SiteSettings.for_site(site) + assert settings_obj.icp_url == "https://beian.miit.gov.cn/" + # 再次获取应复用同一条记录而非重复创建 + assert SiteSettings.for_site(site).pk == settings_obj.pk + + +def test_team_api_returns_ordered_members(): + TeamMember.objects.create(name="李四", role="工程师", order=2) + TeamMember.objects.create(name="张三", role="总经理", order=1) + + client = APIClient() + response = client.get("/api/v1/custom/core/team/") + + assert response.status_code == 200 + items = response.data["items"] + assert [item["name"] for item in items] == ["张三", "李四"] + assert items[0]["role"] == "总经理" + + +def test_testimonial_api_returns_list(): + Testimonial.objects.create(quote="效果很好", author_name="客户A") + + client = APIClient() + response = client.get("/api/v1/custom/core/testimonials/") + + assert response.status_code == 200 + assert response.data["items"][0]["author_name"] == "客户A" + + +def test_partner_api_returns_list(): + Partner.objects.create(name="伙伴A", website_url="https://partner-a.example.com") + + client = APIClient() + response = client.get("/api/v1/custom/core/partners/") + + assert response.status_code == 200 + assert response.data["items"][0]["name"] == "伙伴A" + + +def test_navigation_menu_api_returns_items_by_name(home_page): + menu = NavigationMenu.objects.create(name="main") + NavigationMenuItem.objects.create(menu=menu, label="首页", link_page=home_page, sort_order=0) + NavigationMenuItem.objects.create(menu=menu, label="外部链接", link_url="https://example.com", sort_order=1) + + client = APIClient() + response = client.get("/api/v1/custom/core/navigation/?name=main") + + assert response.status_code == 200 + assert response.data["name"] == "main" + assert len(response.data["items"]) == 2 + assert response.data["items"][1]["url"] == "https://example.com" + + +def test_navigation_menu_api_404_when_missing(): + client = APIClient() + response = client.get("/api/v1/custom/core/navigation/?name=does-not-exist") + assert response.status_code == 404 + + +def test_site_settings_api_returns_defaults(): + client = APIClient() + response = client.get("/api/v1/custom/core/site-settings/") + + assert response.status_code == 200 + assert response.data["icp_url"] == "https://beian.miit.gov.cn/" diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..1aad467 --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .api import ( + NavigationMenuView, + PartnerListView, + SiteSettingsView, + TeamMemberListView, + TestimonialListView, +) + +urlpatterns = [ + path("team/", TeamMemberListView.as_view(), name="team-list"), + path("testimonials/", TestimonialListView.as_view(), name="testimonial-list"), + path("partners/", PartnerListView.as_view(), name="partner-list"), + path("navigation/", NavigationMenuView.as_view(), name="navigation-menu"), + path("site-settings/", SiteSettingsView.as_view(), name="site-settings"), +] diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index 2a7232b..9501c38 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -370,11 +370,11 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [x] `apps/forms`:`FormDefinition`/`FormDefinitionField`/`Lead`(Snippet 方式)+ 提交 API(`/api/v1/custom/leads/`,限流 + 邮件通知) - [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置) - [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route,`lint`/`build` 已验证通过 -- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`(root_page/home_page fixtures),为 `apps/core`(SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API)编写了基础单元测试,共 11 个用例均通过 +- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`(root_page/home_page fixtures),为 `apps/core`(SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API)编写了基础单元测试,共 24 个用例均通过 - [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行) +- [x] Snippet:`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem`(`ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings`(`wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/`(`team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入 未完成(待规划排期): -- [ ] Snippet:`TeamMember`/`Testimonial`/`Partner`/`NavigationMenu`/`SiteSettings`(`BaseSiteSetting`)均未创建 - [ ] 预览模式(Wagtail `preview_token` + Next.js Draft Mode)未实现 - [ ] 剩余 5 个 StreamField Block(Pricing/Timeline/Team/TechStack/Video/ProductCard)未开发 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 diff --git a/wagtailcms/settings/base.py b/wagtailcms/settings/base.py index ba548c6..3362d0c 100644 --- a/wagtailcms/settings/base.py +++ b/wagtailcms/settings/base.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ # Wagtail "wagtail.contrib.forms", "wagtail.contrib.redirects", + "wagtail.contrib.settings", "wagtail.embeds", "wagtail.sites", "wagtail.users", @@ -78,6 +79,7 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "wagtail.contrib.settings.context_processors.settings", ], }, }, diff --git a/wagtailcms/urls.py b/wagtailcms/urls.py index f874163..cb2a657 100644 --- a/wagtailcms/urls.py +++ b/wagtailcms/urls.py @@ -31,6 +31,8 @@ urlpatterns = [ path('api/v2/', api_router.urls), # 自定义业务接口(非 Wagtail 页面路由),如线索表单提交 path('api/v1/custom/', include('apps.forms.urls')), + # 全局 Snippet 只读接口:团队/证言/合作伙伴/导航菜单/站点设置 + path('api/v1/custom/core/', include('apps.core.urls')), path('healthz', healthz, name='healthz'), path('readyz', readyz, name='readyz'), ]