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
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
本文件同时存放跨页面复用的全局 SnippetTeamMember/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 = "站点设置"
+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。"""
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/"
+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] 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 BlockPricing/Timeline/Team/TechStack/Video/ProductCard)未开发
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
+2
View File
@@ -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",
],
},
},
+2
View File
@@ -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'),
]