diff --git a/apps/blog/migrations/0002_alter_blogpage_body.py b/apps/blog/migrations/0002_alter_blogpage_body.py new file mode 100644 index 0000000..c25b494 --- /dev/null +++ b/apps/blog/migrations/0002_alter_blogpage_body.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-07 07:43 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='blogpage', + name='body', + field=wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('product_card', 41), ('pricing', 50), ('timeline', 55), ('team', 58), ('tech_stack', 62), ('video', 65), ('richtext', 66)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '图片', 'required': False}), 38: ('wagtail.blocks.URLBlock', (), {'label': '链接', 'required': False}), 39: ('wagtail.blocks.StructBlock', [[('title', 15), ('description', 25), ('image', 37), ('link', 38)]], {}), 40: ('wagtail.blocks.ListBlock', (39,), {'label': '产品列表'}), 41: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 40)]], {}), 42: ('wagtail.blocks.CharBlock', (), {'label': '方案名称', 'max_length': 50}), 43: ('wagtail.blocks.CharBlock', (), {'label': '价格', 'max_length': 50}), 44: ('wagtail.blocks.CharBlock', (), {'max_length': 100}), 45: ('wagtail.blocks.ListBlock', (44,), {'label': '功能列表'}), 46: ('wagtail.blocks.BooleanBlock', (), {'default': False, 'label': '突出显示', 'required': False}), 47: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮', 'required': False}), 48: ('wagtail.blocks.StructBlock', [[('name', 42), ('price', 43), ('features', 45), ('highlighted', 46), ('button', 47)]], {}), 49: ('wagtail.blocks.ListBlock', (48,), {'label': '定价方案'}), 50: ('wagtail.blocks.StructBlock', [[('heading', 13), ('plans', 49)]], {}), 51: ('wagtail.blocks.CharBlock', (), {'label': '年份/时间', 'max_length': 20}), 52: ('wagtail.blocks.TextBlock', (), {'label': '事件描述'}), 53: ('wagtail.blocks.StructBlock', [[('year', 51), ('event', 52)]], {}), 54: ('wagtail.blocks.ListBlock', (53,), {'label': '时间线条目'}), 55: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 54)]], {}), 56: ('apps.core.blocks.TeamMemberChooserBlock', (), {}), 57: ('wagtail.blocks.ListBlock', (56,), {'label': '团队成员'}), 58: ('wagtail.blocks.StructBlock', [[('heading', 13), ('members', 57)]], {}), 59: ('wagtail.blocks.CharBlock', (), {'label': '名称', 'max_length': 50}), 60: ('wagtail.blocks.StructBlock', [[('icon', 14), ('label', 59)]], {}), 61: ('wagtail.blocks.ListBlock', (60,), {'label': '技术项'}), 62: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 61)]], {}), 63: ('wagtail.blocks.URLBlock', (), {'help_text': '建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)', 'label': '视频地址'}), 64: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '封面图', 'required': False}), 65: ('wagtail.blocks.StructBlock', [[('heading', 28), ('video_url', 63), ('poster', 64)]], {}), 66: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})}), + ), + ] diff --git a/apps/cases/migrations/0002_alter_casestudypage_body.py b/apps/cases/migrations/0002_alter_casestudypage_body.py new file mode 100644 index 0000000..9bdc2be --- /dev/null +++ b/apps/cases/migrations/0002_alter_casestudypage_body.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-07 07:43 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='casestudypage', + name='body', + field=wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('product_card', 41), ('pricing', 50), ('timeline', 55), ('team', 58), ('tech_stack', 62), ('video', 65), ('richtext', 66)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '图片', 'required': False}), 38: ('wagtail.blocks.URLBlock', (), {'label': '链接', 'required': False}), 39: ('wagtail.blocks.StructBlock', [[('title', 15), ('description', 25), ('image', 37), ('link', 38)]], {}), 40: ('wagtail.blocks.ListBlock', (39,), {'label': '产品列表'}), 41: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 40)]], {}), 42: ('wagtail.blocks.CharBlock', (), {'label': '方案名称', 'max_length': 50}), 43: ('wagtail.blocks.CharBlock', (), {'label': '价格', 'max_length': 50}), 44: ('wagtail.blocks.CharBlock', (), {'max_length': 100}), 45: ('wagtail.blocks.ListBlock', (44,), {'label': '功能列表'}), 46: ('wagtail.blocks.BooleanBlock', (), {'default': False, 'label': '突出显示', 'required': False}), 47: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮', 'required': False}), 48: ('wagtail.blocks.StructBlock', [[('name', 42), ('price', 43), ('features', 45), ('highlighted', 46), ('button', 47)]], {}), 49: ('wagtail.blocks.ListBlock', (48,), {'label': '定价方案'}), 50: ('wagtail.blocks.StructBlock', [[('heading', 13), ('plans', 49)]], {}), 51: ('wagtail.blocks.CharBlock', (), {'label': '年份/时间', 'max_length': 20}), 52: ('wagtail.blocks.TextBlock', (), {'label': '事件描述'}), 53: ('wagtail.blocks.StructBlock', [[('year', 51), ('event', 52)]], {}), 54: ('wagtail.blocks.ListBlock', (53,), {'label': '时间线条目'}), 55: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 54)]], {}), 56: ('apps.core.blocks.TeamMemberChooserBlock', (), {}), 57: ('wagtail.blocks.ListBlock', (56,), {'label': '团队成员'}), 58: ('wagtail.blocks.StructBlock', [[('heading', 13), ('members', 57)]], {}), 59: ('wagtail.blocks.CharBlock', (), {'label': '名称', 'max_length': 50}), 60: ('wagtail.blocks.StructBlock', [[('icon', 14), ('label', 59)]], {}), 61: ('wagtail.blocks.ListBlock', (60,), {'label': '技术项'}), 62: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 61)]], {}), 63: ('wagtail.blocks.URLBlock', (), {'help_text': '建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)', 'label': '视频地址'}), 64: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '封面图', 'required': False}), 65: ('wagtail.blocks.StructBlock', [[('heading', 28), ('video_url', 63), ('poster', 64)]], {}), 66: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})}), + ), + ] diff --git a/apps/core/blocks.py b/apps/core/blocks.py index 84b848b..8814a79 100644 --- a/apps/core/blocks.py +++ b/apps/core/blocks.py @@ -7,6 +7,8 @@ from wagtail import blocks from wagtail.images.blocks import ImageChooserBlock from wagtail.snippets.blocks import SnippetChooserBlock +from .serializers import serialize_image + class CTAButtonBlock(blocks.StructBlock): text = blocks.CharBlock(max_length=50, label=_("按钮文案")) @@ -150,6 +152,113 @@ class FormBlock(blocks.StructBlock): } +class ProductCardItemBlock(blocks.StructBlock): + title = blocks.CharBlock(max_length=100, label=_("标题")) + description = blocks.TextBlock(required=False, label=_("描述")) + image = ImageChooserBlock(required=False, label=_("图片")) + link = blocks.URLBlock(required=False, label=_("链接")) + + +class ProductCardBlock(blocks.StructBlock): + heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题")) + items = blocks.ListBlock(ProductCardItemBlock(), label=_("产品列表")) + + class Meta: + icon = "grip" + label = _("产品矩阵") + + +class PricingPlanBlock(blocks.StructBlock): + name = blocks.CharBlock(max_length=50, label=_("方案名称")) + price = blocks.CharBlock(max_length=50, label=_("价格")) + features = blocks.ListBlock( + blocks.CharBlock(max_length=100), label=_("功能列表") + ) + highlighted = blocks.BooleanBlock( + required=False, default=False, label=_("突出显示") + ) + button = CTAButtonBlock(required=False, label=_("按钮")) + + +class PricingBlock(blocks.StructBlock): + heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题")) + plans = blocks.ListBlock(PricingPlanBlock(), label=_("定价方案")) + + class Meta: + icon = "tag" + label = _("定价") + + +class TimelineItemBlock(blocks.StructBlock): + year = blocks.CharBlock(max_length=20, label=_("年份/时间")) + event = blocks.TextBlock(label=_("事件描述")) + + +class TimelineBlock(blocks.StructBlock): + heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题")) + items = blocks.ListBlock(TimelineItemBlock(), label=_("时间线条目")) + + class Meta: + icon = "date" + label = _("发展历程") + + +class TeamMemberChooserBlock(SnippetChooserBlock): + """选择 apps.core.TeamMember Snippet,展开完整字段供前端渲染(而非仅主键)。""" + + def __init__(self, **kwargs): + super().__init__("core.TeamMember", **kwargs) + + def get_api_representation(self, value, context=None): + if value is None: + return None + return { + "id": value.pk, + "name": value.name, + "role": value.role, + "bio": value.bio, + "photo": serialize_image(value.photo), + } + + +class TeamBlock(blocks.StructBlock): + heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题")) + members = blocks.ListBlock(TeamMemberChooserBlock(), label=_("团队成员")) + + class Meta: + icon = "group" + label = _("团队展示") + + +class TechStackItemBlock(blocks.StructBlock): + icon = blocks.CharBlock(max_length=50, required=False, label=_("图标")) + label = blocks.CharBlock(max_length=50, label=_("名称")) + + +class TechStackBlock(blocks.StructBlock): + heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题")) + items = blocks.ListBlock(TechStackItemBlock(), label=_("技术项")) + + class Meta: + icon = "cogs" + label = _("技术架构") + + +class VideoBlock(blocks.StructBlock): + heading = blocks.CharBlock(max_length=100, required=False, label=_("标题")) + video_url = blocks.URLBlock( + label=_("视频地址"), + help_text=_( + "建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的" + "嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)" + ), + ) + poster = ImageChooserBlock(required=False, label=_("封面图")) + + class Meta: + icon = "media" + label = _("视频") + COMMON_BLOCKS = [ ("hero", HeroBlock()), @@ -160,5 +269,11 @@ COMMON_BLOCKS = [ ("logo_cloud", LogoCloudBlock()), ("case_study", CaseStudyBlock()), ("form", FormBlock()), + ("product_card", ProductCardBlock()), + ("pricing", PricingBlock()), + ("timeline", TimelineBlock()), + ("team", TeamBlock()), + ("tech_stack", TechStackBlock()), + ("video", VideoBlock()), ("richtext", blocks.RichTextBlock(label=_("富文本"))), ] diff --git a/apps/core/tests.py b/apps/core/tests.py index 9a3ff73..971f69c 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -2,7 +2,7 @@ import pytest from rest_framework.test import APIClient -from apps.core.blocks import FormBlock +from apps.core.blocks import FormBlock, TeamMemberChooserBlock from apps.core.models import ( NavigationMenu, NavigationMenuItem, @@ -159,6 +159,26 @@ def test_testimonial_api_returns_list(): assert response.data["items"][0]["author_name"] == "客户A" +def test_team_member_chooser_block_api_representation_expands_fields(): + """TeamMemberChooserBlock.get_api_representation 应展开 TeamMember 的完整字段, + 而不是仅返回 SnippetChooserBlock 默认的主键。""" + member = TeamMember.objects.create(name="王五", role="产品经理", bio="十年行业经验") + + block = TeamMemberChooserBlock() + result = block.get_api_representation(member) + + assert result["id"] == member.pk + assert result["name"] == "王五" + assert result["role"] == "产品经理" + assert result["bio"] == "十年行业经验" + assert result["photo"] is None + + +def test_team_member_chooser_block_api_representation_handles_none(): + block = TeamMemberChooserBlock() + assert block.get_api_representation(None) is None + + def test_partner_api_returns_list(): Partner.objects.create(name="伙伴A", website_url="https://partner-a.example.com") diff --git a/apps/home/migrations/0002_alter_homepage_body.py b/apps/home/migrations/0002_alter_homepage_body.py new file mode 100644 index 0000000..a8af834 --- /dev/null +++ b/apps/home/migrations/0002_alter_homepage_body.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-07 07:43 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='homepage', + name='body', + field=wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('product_card', 41), ('pricing', 50), ('timeline', 55), ('team', 58), ('tech_stack', 62), ('video', 65), ('richtext', 66)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '图片', 'required': False}), 38: ('wagtail.blocks.URLBlock', (), {'label': '链接', 'required': False}), 39: ('wagtail.blocks.StructBlock', [[('title', 15), ('description', 25), ('image', 37), ('link', 38)]], {}), 40: ('wagtail.blocks.ListBlock', (39,), {'label': '产品列表'}), 41: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 40)]], {}), 42: ('wagtail.blocks.CharBlock', (), {'label': '方案名称', 'max_length': 50}), 43: ('wagtail.blocks.CharBlock', (), {'label': '价格', 'max_length': 50}), 44: ('wagtail.blocks.CharBlock', (), {'max_length': 100}), 45: ('wagtail.blocks.ListBlock', (44,), {'label': '功能列表'}), 46: ('wagtail.blocks.BooleanBlock', (), {'default': False, 'label': '突出显示', 'required': False}), 47: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮', 'required': False}), 48: ('wagtail.blocks.StructBlock', [[('name', 42), ('price', 43), ('features', 45), ('highlighted', 46), ('button', 47)]], {}), 49: ('wagtail.blocks.ListBlock', (48,), {'label': '定价方案'}), 50: ('wagtail.blocks.StructBlock', [[('heading', 13), ('plans', 49)]], {}), 51: ('wagtail.blocks.CharBlock', (), {'label': '年份/时间', 'max_length': 20}), 52: ('wagtail.blocks.TextBlock', (), {'label': '事件描述'}), 53: ('wagtail.blocks.StructBlock', [[('year', 51), ('event', 52)]], {}), 54: ('wagtail.blocks.ListBlock', (53,), {'label': '时间线条目'}), 55: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 54)]], {}), 56: ('apps.core.blocks.TeamMemberChooserBlock', (), {}), 57: ('wagtail.blocks.ListBlock', (56,), {'label': '团队成员'}), 58: ('wagtail.blocks.StructBlock', [[('heading', 13), ('members', 57)]], {}), 59: ('wagtail.blocks.CharBlock', (), {'label': '名称', 'max_length': 50}), 60: ('wagtail.blocks.StructBlock', [[('icon', 14), ('label', 59)]], {}), 61: ('wagtail.blocks.ListBlock', (60,), {'label': '技术项'}), 62: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 61)]], {}), 63: ('wagtail.blocks.URLBlock', (), {'help_text': '建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)', 'label': '视频地址'}), 64: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '封面图', 'required': False}), 65: ('wagtail.blocks.StructBlock', [[('heading', 28), ('video_url', 63), ('poster', 64)]], {}), 66: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})}), + ), + ] diff --git a/apps/products/migrations/0002_alter_productpage_body.py b/apps/products/migrations/0002_alter_productpage_body.py new file mode 100644 index 0000000..af396ae --- /dev/null +++ b/apps/products/migrations/0002_alter_productpage_body.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-07 07:43 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='productpage', + name='body', + field=wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('product_card', 41), ('pricing', 50), ('timeline', 55), ('team', 58), ('tech_stack', 62), ('video', 65), ('richtext', 66)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '图片', 'required': False}), 38: ('wagtail.blocks.URLBlock', (), {'label': '链接', 'required': False}), 39: ('wagtail.blocks.StructBlock', [[('title', 15), ('description', 25), ('image', 37), ('link', 38)]], {}), 40: ('wagtail.blocks.ListBlock', (39,), {'label': '产品列表'}), 41: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 40)]], {}), 42: ('wagtail.blocks.CharBlock', (), {'label': '方案名称', 'max_length': 50}), 43: ('wagtail.blocks.CharBlock', (), {'label': '价格', 'max_length': 50}), 44: ('wagtail.blocks.CharBlock', (), {'max_length': 100}), 45: ('wagtail.blocks.ListBlock', (44,), {'label': '功能列表'}), 46: ('wagtail.blocks.BooleanBlock', (), {'default': False, 'label': '突出显示', 'required': False}), 47: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮', 'required': False}), 48: ('wagtail.blocks.StructBlock', [[('name', 42), ('price', 43), ('features', 45), ('highlighted', 46), ('button', 47)]], {}), 49: ('wagtail.blocks.ListBlock', (48,), {'label': '定价方案'}), 50: ('wagtail.blocks.StructBlock', [[('heading', 13), ('plans', 49)]], {}), 51: ('wagtail.blocks.CharBlock', (), {'label': '年份/时间', 'max_length': 20}), 52: ('wagtail.blocks.TextBlock', (), {'label': '事件描述'}), 53: ('wagtail.blocks.StructBlock', [[('year', 51), ('event', 52)]], {}), 54: ('wagtail.blocks.ListBlock', (53,), {'label': '时间线条目'}), 55: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 54)]], {}), 56: ('apps.core.blocks.TeamMemberChooserBlock', (), {}), 57: ('wagtail.blocks.ListBlock', (56,), {'label': '团队成员'}), 58: ('wagtail.blocks.StructBlock', [[('heading', 13), ('members', 57)]], {}), 59: ('wagtail.blocks.CharBlock', (), {'label': '名称', 'max_length': 50}), 60: ('wagtail.blocks.StructBlock', [[('icon', 14), ('label', 59)]], {}), 61: ('wagtail.blocks.ListBlock', (60,), {'label': '技术项'}), 62: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 61)]], {}), 63: ('wagtail.blocks.URLBlock', (), {'help_text': '建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)', 'label': '视频地址'}), 64: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '封面图', 'required': False}), 65: ('wagtail.blocks.StructBlock', [[('heading', 28), ('video_url', 63), ('poster', 64)]], {}), 66: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})}), + ), + ] diff --git a/apps/solutions/migrations/0002_alter_solutionpage_body.py b/apps/solutions/migrations/0002_alter_solutionpage_body.py new file mode 100644 index 0000000..54cccf5 --- /dev/null +++ b/apps/solutions/migrations/0002_alter_solutionpage_body.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-07 07:43 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('solutions', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='solutionpage', + name='body', + field=wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('product_card', 41), ('pricing', 50), ('timeline', 55), ('team', 58), ('tech_stack', 62), ('video', 65), ('richtext', 66)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '图片', 'required': False}), 38: ('wagtail.blocks.URLBlock', (), {'label': '链接', 'required': False}), 39: ('wagtail.blocks.StructBlock', [[('title', 15), ('description', 25), ('image', 37), ('link', 38)]], {}), 40: ('wagtail.blocks.ListBlock', (39,), {'label': '产品列表'}), 41: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 40)]], {}), 42: ('wagtail.blocks.CharBlock', (), {'label': '方案名称', 'max_length': 50}), 43: ('wagtail.blocks.CharBlock', (), {'label': '价格', 'max_length': 50}), 44: ('wagtail.blocks.CharBlock', (), {'max_length': 100}), 45: ('wagtail.blocks.ListBlock', (44,), {'label': '功能列表'}), 46: ('wagtail.blocks.BooleanBlock', (), {'default': False, 'label': '突出显示', 'required': False}), 47: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮', 'required': False}), 48: ('wagtail.blocks.StructBlock', [[('name', 42), ('price', 43), ('features', 45), ('highlighted', 46), ('button', 47)]], {}), 49: ('wagtail.blocks.ListBlock', (48,), {'label': '定价方案'}), 50: ('wagtail.blocks.StructBlock', [[('heading', 13), ('plans', 49)]], {}), 51: ('wagtail.blocks.CharBlock', (), {'label': '年份/时间', 'max_length': 20}), 52: ('wagtail.blocks.TextBlock', (), {'label': '事件描述'}), 53: ('wagtail.blocks.StructBlock', [[('year', 51), ('event', 52)]], {}), 54: ('wagtail.blocks.ListBlock', (53,), {'label': '时间线条目'}), 55: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 54)]], {}), 56: ('apps.core.blocks.TeamMemberChooserBlock', (), {}), 57: ('wagtail.blocks.ListBlock', (56,), {'label': '团队成员'}), 58: ('wagtail.blocks.StructBlock', [[('heading', 13), ('members', 57)]], {}), 59: ('wagtail.blocks.CharBlock', (), {'label': '名称', 'max_length': 50}), 60: ('wagtail.blocks.StructBlock', [[('icon', 14), ('label', 59)]], {}), 61: ('wagtail.blocks.ListBlock', (60,), {'label': '技术项'}), 62: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 61)]], {}), 63: ('wagtail.blocks.URLBlock', (), {'help_text': '建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)', 'label': '视频地址'}), 64: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '封面图', 'required': False}), 65: ('wagtail.blocks.StructBlock', [[('heading', 28), ('video_url', 63), ('poster', 64)]], {}), 66: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})}), + ), + ] diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index 4ea757c..cc3a1d5 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -219,15 +219,15 @@ Snippet(非页面树内容,用于跨页面复用):`TeamMember`、`Testim | HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | ✅ 已实现 | | StatsBlock | 数据展示 | items: [{value, label}] | ✅ 已实现 | | FeatureGridBlock | 能力/优势 | icon, title, description | ✅ 已实现(对应原 FeatureBlock) | -| ProductCardBlock | 产品矩阵 | title, description, image, link | ⬜ 待开发 | +| ProductCardBlock | 产品矩阵 | title, description, image, link | ✅ 已实现 | | LogoCloudBlock | 客户 Logo 墙 | logos: [{url, title}](暂为图片列表,未接入 Partner Snippet) | ✅ 已实现(简化版) | | CaseStudyBlock | 案例卡片 | case_page: PageChooser(cases.CaseStudyPage), summary | ✅ 已实现 | -| PricingBlock | 定价 | plans: [{name, price, features}] | ⬜ 待开发 | +| PricingBlock | 定价 | plans: [{name, price, features, highlighted, button}] | ✅ 已实现 | | FAQBlock | 常见问题 | items: [{question, answer(richtext)}] | ✅ 已实现 | -| TimelineBlock | 发展历程 | items: [{year, event}] | ⬜ 待开发 | -| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)] | ⬜ 待开发(依赖 TeamMember Snippet) | -| TechStackBlock | 技术架构图 | items: [{icon, label}] | ⬜ 待开发 | -| VideoBlock | 视频 | video_file/embed_url, poster | ⬜ 待开发 | +| TimelineBlock | 发展历程 | items: [{year, event}] | ✅ 已实现 | +| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)],`get_api_representation` 展开完整字段(name/role/bio/photo)供前端渲染 | ✅ 已实现(依赖 TeamMember Snippet) | +| TechStackBlock | 技术架构图 | items: [{icon, label}] | ✅ 已实现 | +| VideoBlock | 视频 | video_url(建议自建 OSS/COS 直链或腾讯视频/哔哩哔哩等国内平台嵌入地址),poster | ✅ 已实现 | | FormBlock | 线索表单 | form: SnippetChooser(forms.FormDefinition),`get_api_representation` 展开完整字段定义供前端渲染 | ✅ 已实现 | | CTABlock | 行动号召 | heading, button_text, button_link | ✅ 已实现 | @@ -365,7 +365,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 已完成: - [x] 后端 Django + Wagtail 项目骨架,分层 settings(base/dev/production),独立 git 仓库并已提交 -- [x] `apps/core`:`SEOablePage` 抽象基类、`COMMON_BLOCKS`(9/14 个 Block,见 §2.6)、健康检查 `/healthz` `/readyz`、发布后 `page_published` signal → 前端 revalidate webhook +- [x] `apps/core`:`SEOablePage` 抽象基类、`COMMON_BLOCKS`(14/14 个 Block 已全部实现,见 §2.6)、健康检查 `/healthz` `/readyz`、发布后 `page_published` signal → 前端 revalidate webhook - [x] `apps/home`、`apps/blog`(含标签 `ClusterTaggableManager`) - [x] `apps/products`(ProductIndexPage/ProductPage)、`apps/cases`(CaseStudyIndexPage/CaseStudyPage)、`apps/solutions`(SolutionIndexPage/SolutionPage) - [x] `apps/forms`:`FormDefinition`/`FormDefinitionField`/`Lead`(Snippet 方式)+ 提交 API(`/api/v1/custom/leads/`,限流 + 邮件通知) @@ -375,9 +375,9 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [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(见下)落地时接入 - [x] 预览模式:后端引入 `wagtail-headless-preview`(0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts`(`getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证) +- [x] 剩余 6 个 StreamField Block(`ProductCardBlock`/`PricingBlock`/`TimelineBlock`/`TeamBlock`/`TechStackBlock`/`VideoBlock`)已全部开发完成:`apps/core/blocks.py` 新增对应 Block 类并扩充 `COMMON_BLOCKS`(现共 14 个内容 Block + 1 个 `richtext`),因 Wagtail StreamField 会将 Block 结构写入迁移(`use_json_field=True` 不代表可跳过迁移),已为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 `alter field body` 迁移;`TeamBlock` 复用 `FormBlock` 的 `get_api_representation` 覆写模式,新增 `TeamMemberChooserBlock(SnippetChooserBlock)` 展开 `TeamMember` 完整字段(含 `serialize_image` 复用),并补充对应的空值/展开单元测试(新增 2 个用例,后端共 33 个测试全部通过);前端 `types/wagtail.ts` 新增 6 个 Block 值类型与 `StreamFieldBlock` 联合类型分支,新建 6 个渲染组件(`ProductCardGrid`/`Pricing`/`Timeline`/`Team`/`TechStack`/`Video`)并接入 `BlockRenderer.tsx` 映射表,`lint`/`build` 均验证通过 未完成(待规划排期): -- [ ] 剩余 5 个 StreamField Block(Pricing/Timeline/Team/TechStack/Video/ProductCard)未开发 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) - [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入