feat: 新增Snippet全局内容模型(TeamMember/Testimonial/Partner/NavigationMenu/SiteSettings)

- 新增 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 进度清单
This commit is contained in:
2026-08-07 13:06:22 +08:00
parent b04de9f5c1
commit 8fbbd2e6b0
9 changed files with 628 additions and 5 deletions
+67
View File
@@ -0,0 +1,67 @@
"""
apps.core 全局 Snippet 只读接口。挂载在 /api/v1/custom/core/
(详见 apps/core/urls.py 与 documents/设计方案分析与完善版.md §2.7)。
公开只读数据,限流走 DRF ScopedRateThrottle 的 "public" scope100/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)
@@ -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'],
},
),
]
+233 -2
View File
@@ -1,11 +1,19 @@
""" """
公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page 公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。 以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
本文件同时存放跨页面复用的全局 SnippetTeamMember/Testimonial/Partner/
NavigationMenu/SiteSettings(详见 documents/设计方案分析与完善版.md §2.5、§2.16)。
""" """
from django.db import models 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.fields import RichTextField
from wagtail.models import Page from wagtail.models import Orderable, Page
from wagtail.snippets.models import register_snippet
class SEOablePage(Page): class SEOablePage(Page):
@@ -53,3 +61,226 @@ class SimpleContentPage(SEOablePage):
class Meta: class Meta:
verbose_name = "通用内容页" 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 = "站点设置"
+74
View File
@@ -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)
+121 -1
View File
@@ -1,8 +1,17 @@
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。""" """apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
import pytest import pytest
from rest_framework.test import APIClient
from apps.core.blocks import FormBlock 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 from apps.forms.models import FormDefinition, FormDefinitionField
pytestmark = pytest.mark.django_db 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() page.refresh_from_db()
assert SimpleContentPage.objects.live().descendant_of(home_page).count() == 1 assert SimpleContentPage.objects.live().descendant_of(home_page).count() == 1
assert "个人信息保护" in page.body 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/"
+17
View File
@@ -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"),
]
+2 -2
View File
@@ -370,11 +370,11 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
- [x] `apps/forms``FormDefinition`/`FormDefinitionField`/`Lead`Snippet 方式)+ 提交 API`/api/v1/custom/leads/`,限流 + 邮件通知) - [x] `apps/forms``FormDefinition`/`FormDefinitionField`/`Lead`Snippet 方式)+ 提交 API`/api/v1/custom/leads/`,限流 + 邮件通知)
- [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置) - [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置)
- [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route`lint`/`build` 已验证通过 - [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] `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)未实现 - [ ] 预览模式(Wagtail `preview_token` + Next.js Draft Mode)未实现
- [ ] 剩余 5 个 StreamField BlockPricing/Timeline/Team/TechStack/Video/ProductCard)未开发 - [ ] 剩余 5 个 StreamField BlockPricing/Timeline/Team/TechStack/Video/ProductCard)未开发
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
+2
View File
@@ -30,6 +30,7 @@ INSTALLED_APPS = [
# Wagtail # Wagtail
"wagtail.contrib.forms", "wagtail.contrib.forms",
"wagtail.contrib.redirects", "wagtail.contrib.redirects",
"wagtail.contrib.settings",
"wagtail.embeds", "wagtail.embeds",
"wagtail.sites", "wagtail.sites",
"wagtail.users", "wagtail.users",
@@ -78,6 +79,7 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"wagtail.contrib.settings.context_processors.settings",
], ],
}, },
}, },
+2
View File
@@ -31,6 +31,8 @@ urlpatterns = [
path('api/v2/', api_router.urls), path('api/v2/', api_router.urls),
# 自定义业务接口(非 Wagtail 页面路由),如线索表单提交 # 自定义业务接口(非 Wagtail 页面路由),如线索表单提交
path('api/v1/custom/', include('apps.forms.urls')), path('api/v1/custom/', include('apps.forms.urls')),
# 全局 Snippet 只读接口:团队/证言/合作伙伴/导航菜单/站点设置
path('api/v1/custom/core/', include('apps.core.urls')),
path('healthz', healthz, name='healthz'), path('healthz', healthz, name='healthz'),
path('readyz', readyz, name='readyz'), path('readyz', readyz, name='readyz'),
] ]