diff --git a/apps/cases/tests.py b/apps/cases/tests.py new file mode 100644 index 0000000..6dac7e3 --- /dev/null +++ b/apps/cases/tests.py @@ -0,0 +1,50 @@ +"""apps.cases 单元测试:页面树结构与列表排序。""" +import pytest +from django.utils import timezone + +from apps.cases.models import CaseStudyIndexPage, CaseStudyPage + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def case_index(home_page): + index = CaseStudyIndexPage(title="客户案例", slug="cases", intro="客户的成功案例") + home_page.add_child(instance=index) + return index + + +def test_case_study_page_can_be_created_under_index(case_index): + case = CaseStudyPage( + title="某集团数字化转型", + slug="some-group", + client_name="某集团", + industry="制造业", + published_at=timezone.now(), + ) + case_index.add_child(instance=case) + + assert CaseStudyPage.objects.live().descendant_of(case_index).count() == 1 + + +def test_case_index_context_orders_by_published_at_desc(case_index): + older = CaseStudyPage( + title="案例一", + slug="case-1", + client_name="客户一", + published_at=timezone.now() - timezone.timedelta(days=10), + ) + case_index.add_child(instance=older) + + newer = CaseStudyPage( + title="案例二", + slug="case-2", + client_name="客户二", + published_at=timezone.now(), + ) + case_index.add_child(instance=newer) + + context = case_index.get_context(request=None) + titles = [c.title for c in context["case_studies"]] + + assert titles == ["案例二", "案例一"] diff --git a/apps/core/tests.py b/apps/core/tests.py new file mode 100644 index 0000000..9ec4687 --- /dev/null +++ b/apps/core/tests.py @@ -0,0 +1,62 @@ +"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。""" +import pytest + +from apps.core.blocks import FormBlock +from apps.forms.models import FormDefinition, FormDefinitionField + +pytestmark = pytest.mark.django_db + + +def test_seoable_page_fields_available_on_subclass(home_page): + """SEOablePage 是抽象基类,其 SEO 字段应出现在具体子类(HomePage)上。""" + home_page.seo_title_override = "自定义标题" + home_page.seo_description_override = "自定义描述" + home_page.canonical_url = "https://example.com/" + home_page.schema_json = {"@type": "Organization"} + home_page.save() + + home_page.refresh_from_db() + assert home_page.seo_title_override == "自定义标题" + assert home_page.seo_description_override == "自定义描述" + assert home_page.canonical_url == "https://example.com/" + assert home_page.schema_json == {"@type": "Organization"} + + +def test_form_block_api_representation_expands_fields(): + """FormBlock.get_api_representation 应展开 FormDefinition 及其子字段, + 而不是仅返回 SnippetChooserBlock 默认的主键。""" + form_def = FormDefinition.objects.create( + name="联系我们", + submit_button_text="立即提交", + success_message="已收到您的信息", + ) + FormDefinitionField.objects.create( + form=form_def, + label="姓名", + field_key="name", + field_type="text", + required=True, + ) + FormDefinitionField.objects.create( + form=form_def, + label="意向", + field_key="interest", + field_type="select", + required=False, + choices="产品咨询, 合作洽谈", + ) + + block = FormBlock() + result = block.get_api_representation({"form": form_def}) + + assert result["form"]["id"] == form_def.pk + assert result["form"]["name"] == "联系我们" + assert result["form"]["submit_button_text"] == "立即提交" + assert len(result["form"]["fields"]) == 2 + assert result["form"]["fields"][1]["choices"] == ["产品咨询", "合作洽谈"] + + +def test_form_block_api_representation_handles_empty_form(): + block = FormBlock() + result = block.get_api_representation({"form": None}) + assert result == {"form": None} diff --git a/apps/forms/tests.py b/apps/forms/tests.py new file mode 100644 index 0000000..b32123e --- /dev/null +++ b/apps/forms/tests.py @@ -0,0 +1,64 @@ +"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。""" +import pytest +from rest_framework.test import APIClient + +from apps.forms.models import FormDefinition, FormDefinitionField, Lead + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def contact_form(): + form_def = FormDefinition.objects.create( + name="联系我们", + success_message="提交成功,我们会尽快联系您。", + ) + FormDefinitionField.objects.create( + form=form_def, label="姓名", field_key="name", field_type="text", required=True + ) + return form_def + + +def test_lead_submit_creates_lead_and_returns_success_message(contact_form): + client = APIClient() + response = client.post( + "/api/v1/custom/leads/", + { + "form_id": contact_form.pk, + "data": {"name": "张三"}, + "source_url": "https://example.com/contact", + }, + format="json", + ) + + assert response.status_code == 201 + assert response.data["success"] is True + assert response.data["message"] == contact_form.success_message + + lead = Lead.objects.get() + assert lead.form_id == contact_form.pk + assert lead.data == {"name": "张三"} + assert lead.source_url == "https://example.com/contact" + + +def test_lead_submit_returns_404_for_unknown_form(): + client = APIClient() + response = client.post( + "/api/v1/custom/leads/", + {"form_id": 9999, "data": {}}, + format="json", + ) + + assert response.status_code == 404 + assert response.data["error"]["code"] == "NOT_FOUND" + + +def test_lead_submit_requires_form_id(): + client = APIClient() + response = client.post( + "/api/v1/custom/leads/", + {"data": {"name": "张三"}}, + format="json", + ) + + assert response.status_code == 400 diff --git a/apps/home/models.py b/apps/home/models.py index 4f33254..eab4f43 100644 --- a/apps/home/models.py +++ b/apps/home/models.py @@ -16,6 +16,9 @@ class HomePage(SEOablePage): subpage_types = [ "blog.BlogIndexPage", + "products.ProductIndexPage", + "solutions.SolutionIndexPage", + "cases.CaseStudyIndexPage", ] class Meta: diff --git a/apps/products/tests.py b/apps/products/tests.py new file mode 100644 index 0000000..9172b94 --- /dev/null +++ b/apps/products/tests.py @@ -0,0 +1,34 @@ +"""apps.products 单元测试:页面树结构与列表上下文。""" +import pytest + +from apps.products.models import ProductIndexPage, ProductPage + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def product_index(home_page): + index = ProductIndexPage(title="产品", slug="products", intro="我们的产品") + home_page.add_child(instance=index) + return index + + +def test_product_page_can_be_created_under_index(product_index): + product = ProductPage(title="产品 A", slug="product-a", summary="简介 A") + product_index.add_child(instance=product) + + assert ProductPage.objects.live().descendant_of(product_index).count() == 1 + + +def test_product_index_context_lists_live_products_only(product_index): + live_product = ProductPage(title="产品 A", slug="product-a") + product_index.add_child(instance=live_product) + + draft_product = ProductPage(title="产品 B", slug="product-b", live=False) + product_index.add_child(instance=draft_product) + + context = product_index.get_context(request=None) + titles = [p.title for p in context["products"]] + + assert "产品 A" in titles + assert "产品 B" not in titles diff --git a/apps/solutions/__init__.py b/apps/solutions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/solutions/apps.py b/apps/solutions/apps.py new file mode 100644 index 0000000..37704e5 --- /dev/null +++ b/apps/solutions/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class SolutionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.solutions" + label = "solutions" + verbose_name = "解决方案" diff --git a/apps/solutions/migrations/0001_initial.py b/apps/solutions/migrations/0001_initial.py new file mode 100644 index 0000000..6fd7176 --- /dev/null +++ b/apps/solutions/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 6.0.5 on 2026-08-06 08:14 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.CreateModel( + name='SolutionIndexPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')), + ('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')), + ('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')), + ('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')), + ('intro', models.TextField(blank=True, verbose_name='栏目简介')), + ('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')), + ], + options={ + 'verbose_name': '解决方案栏目页', + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='SolutionPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')), + ('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')), + ('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')), + ('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')), + ('industry', models.CharField(blank=True, max_length=100, verbose_name='适用行业')), + ('summary', models.CharField(blank=True, max_length=250, verbose_name='方案简介')), + ('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('richtext', 37)], 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.blocks.RichTextBlock', (), {'label': '富文本'})})), + ('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='方案配图')), + ('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')), + ], + options={ + 'verbose_name': '解决方案页', + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/apps/solutions/migrations/__init__.py b/apps/solutions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/solutions/models.py b/apps/solutions/models.py new file mode 100644 index 0000000..e9f30e4 --- /dev/null +++ b/apps/solutions/models.py @@ -0,0 +1,66 @@ +"""行业解决方案栏目与详情页。详见 documents/设计方案分析与完善版.md §2.5 页面树结构。""" +from django.db import models +from wagtail.admin.panels import FieldPanel +from wagtail.fields import StreamField +from wagtail.search import index + +from apps.core.blocks import COMMON_BLOCKS +from apps.core.models import SEOablePage + + +class SolutionIndexPage(SEOablePage): + """解决方案栏目列表页,子页面为 SolutionPage。""" + + intro = models.TextField(blank=True, verbose_name="栏目简介") + + content_panels = SEOablePage.content_panels + [ + FieldPanel("intro"), + ] + + subpage_types = ["solutions.SolutionPage"] + parent_page_types = ["home.HomePage"] + + class Meta: + verbose_name = "解决方案栏目页" + + def get_context(self, request, *args, **kwargs): + context = super().get_context(request, *args, **kwargs) + context["solutions"] = ( + SolutionPage.objects.live().descendant_of(self).order_by("path") + ) + return context + + +class SolutionPage(SEOablePage): + """单个行业解决方案详情页。""" + + industry = models.CharField(max_length=100, blank=True, verbose_name="适用行业") + summary = models.CharField(max_length=250, blank=True, verbose_name="方案简介") + cover_image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="方案配图", + ) + body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True) + + search_fields = SEOablePage.search_fields + [ + index.SearchField("industry"), + index.SearchField("summary"), + index.SearchField("body"), + ] + + content_panels = SEOablePage.content_panels + [ + FieldPanel("industry"), + FieldPanel("summary"), + FieldPanel("cover_image"), + FieldPanel("body"), + ] + + parent_page_types = ["solutions.SolutionIndexPage"] + subpage_types = [] + + class Meta: + verbose_name = "解决方案页" diff --git a/apps/solutions/templates/solutions/solution_index_page.html b/apps/solutions/templates/solutions/solution_index_page.html new file mode 100644 index 0000000..d112cfd --- /dev/null +++ b/apps/solutions/templates/solutions/solution_index_page.html @@ -0,0 +1,14 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.intro }}

+ + + diff --git a/apps/solutions/templates/solutions/solution_page.html b/apps/solutions/templates/solutions/solution_page.html new file mode 100644 index 0000000..4fe7391 --- /dev/null +++ b/apps/solutions/templates/solutions/solution_page.html @@ -0,0 +1,13 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.industry }}

+

{{ page.summary }}

+ {% for block in page.body %} + {% include_block block %} + {% endfor %} + + diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..05080af --- /dev/null +++ b/conftest.py @@ -0,0 +1,19 @@ +import pytest + + +@pytest.fixture +def root_page(): + """Wagtail 初始迁移创建的树根页面(depth=1)。""" + from wagtail.models import Page + + return Page.objects.get(depth=1) + + +@pytest.fixture +def home_page(root_page): + """在树根下创建一个测试用 HomePage 实例。""" + from apps.home.models import HomePage + + page = HomePage(title="首页", slug="home-test") + root_page.add_child(instance=page) + return page diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index aa28367..959b54b 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -134,13 +134,13 @@ flowchart TB ### 2.4 Monorepo 目录结构(沿用并细化) ``` -wagtailcms/ # 当前仓库根目录(后端优先落地) +wagtailcms/ # 当前仓库根目录(后端仓库,独立 git 仓库) ├── manage.py ├── requirements/ │ ├── base.txt │ ├── dev.txt │ └── production.txt -├── config/ # 原 wagtailcms/ 设置包,拆分为分层 settings +├── wagtailcms/ # 设置包(实际未按早期草案重命名为 config/,沿用项目同名包) │ ├── settings/ │ │ ├── base.py │ │ ├── dev.py @@ -149,19 +149,19 @@ wagtailcms/ # 当前仓库根目录(后端优先落地) │ ├── wsgi.py │ └── asgi.py ├── apps/ -│ ├── core/ # 基础抽象:SEOablePage、健康检查、公共 Block -│ ├── home/ # 首页 -│ ├── blog/ # 技术博客 -│ ├── products/ # 产品 -│ ├── solutions/ # 解决方案(行业) -│ ├── cases/ # 客户案例 -│ ├── forms/ # 线索表单 -│ └── api/ # DRF + Wagtail API v2 路由 +│ ├── core/ # 基础抽象:SEOablePage、健康检查、公共 Block ✅ +│ ├── home/ # 首页 ✅ +│ ├── blog/ # 技术博客 ✅ +│ ├── products/ # 产品 ✅ +│ ├── solutions/ # 解决方案(行业)⬜ 尚未创建 +│ ├── cases/ # 客户案例 ✅ +│ ├── forms/ # 线索表单 ✅ +│ └── api/ # DRF + Wagtail API v2 路由 ✅ ├── documents/ # 设计文档(当前目录) -└── frontend/ # 后续新增:Next.js 项目(独立子目录或独立仓库) +└── (frontend/ 已拆分为独立仓库,不在本仓库内,见下方说明) ``` -> 说明:是否采用 Turborepo/pnpm workspace 做单仓多包管理,取决于前后端是否同仓维护。若团队分工明确(后端/前端各自团队),建议**拆分为两个独立仓库**,通过 OpenAPI/GraphQL Schema 契约解耦,避免单仓耦合过重;本项目当前后端已独立成仓,建议前端也独立建仓。 +> 说明:前后端已**拆分为两个独立 git 仓库**(后端本仓库 + `frontend/` 独立仓库),通过 REST API(Wagtail API v2 + `/api/v1/custom/`)契约解耦。后端根 `.gitignore` 已排除 `frontend/`,避免嵌套仓库冲突。两个仓库目前均为本地仓库,尚未配置远程/推送。 ### 2.5 内容模型与数据库设计 @@ -214,24 +214,24 @@ Snippet(非页面树内容,用于跨页面复用):`TeamMember`、`Testim ### 2.6 StreamField 组件体系(补全清单) -| Block | 用途 | 关键字段 | -|---|---|---| -| HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | -| StatsBlock | 数据展示 | items: [{value, label}] | -| FeatureBlock | 能力/优势 | icon, title, description | -| ProductCardBlock | 产品矩阵 | title, description, image, link | -| LogoCloudBlock | 客户 Logo 墙 | logos: [SnippetChooser(Partner)] | -| CaseStudyBlock | 案例卡片 | case_page: PageChooser, summary | -| PricingBlock | 定价 | plans: [{name, price, features}] | -| FAQBlock | 常见问题 | items: [{question, answer(richtext)}] | -| TimelineBlock | 发展历程 | items: [{year, event}] | -| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)] | -| TechStackBlock | 技术架构图 | items: [{icon, label}] | -| VideoBlock | 视频 | video_file/embed_url, poster | -| FormBlock | 线索表单 | form: SnippetChooser(FormDefinition) | -| CTABlock | 行动号召 | heading, button_text, button_link | +| Block | 用途 | 关键字段 | 状态 | +|---|---|---|---| +| HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | ✅ 已实现 | +| StatsBlock | 数据展示 | items: [{value, label}] | ✅ 已实现 | +| FeatureGridBlock | 能力/优势 | icon, title, description | ✅ 已实现(对应原 FeatureBlock) | +| ProductCardBlock | 产品矩阵 | title, description, image, link | ⬜ 待开发 | +| LogoCloudBlock | 客户 Logo 墙 | logos: [{url, title}](暂为图片列表,未接入 Partner Snippet) | ✅ 已实现(简化版) | +| CaseStudyBlock | 案例卡片 | case_page: PageChooser(cases.CaseStudyPage), summary | ✅ 已实现 | +| PricingBlock | 定价 | plans: [{name, price, features}] | ⬜ 待开发 | +| 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 | ⬜ 待开发 | +| FormBlock | 线索表单 | form: SnippetChooser(forms.FormDefinition),`get_api_representation` 展开完整字段定义供前端渲染 | ✅ 已实现 | +| CTABlock | 行动号召 | heading, button_text, button_link | ✅ 已实现 | -所有 Block 统一放在 `apps/core/blocks/`,前后端各维护一份"type → 组件"映射表,并通过 CI 增加一致性校验(后端 Block 清单 vs 前端 `BlockRenderer` 映射表 diff)。 +所有 Block 统一放在 `apps/core/blocks.py` 的 `COMMON_BLOCKS`,前端 `frontend/blocks/BlockRenderer.tsx` 维护对应的"type → 组件"映射表,目前两者手动保持一致;CI 自动化一致性校验仍待建立(见 §2.17 风险表)。 ### 2.7 Headless API 设计规范(补全缺失细节) @@ -347,10 +347,32 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 | 阶段 | 目标 | 状态 | |---|---|---| | **Phase 0** | 需求与设计文档完善(本文档) | ✅ 已完成 | -| **Phase 1** | Wagtail 基础 CMS + REST API + Next.js 首页渲染 + 基础 SEO | 🚧 即将开始(本次同步启动后端骨架搭建) | +| **Phase 1** | Wagtail 基础 CMS + REST API + Next.js 首页渲染 + 基础 SEO | 🚧 进行中(进度见下方清单) | | **Phase 2** | GraphQL、OpenSearch 中文搜索、工作流审核、多语言 | 待规划 | | **Phase 3** | AI 能力(摘要/翻译/SEO 重写)、SaaS 化评估(视业务需要再决定是否引入多租户) | 待规划 | +**Phase 1 详细进度:** + +已完成: +- [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/home`、`apps/blog`(含标签 `ClusterTaggableManager`) +- [x] `apps/products`(ProductIndexPage/ProductPage)、`apps/cases`(CaseStudyIndexPage/CaseStudyPage) +- [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` 已验证通过 + +未完成(待规划排期): +- [ ] `apps/solutions`(解决方案/行业页面)尚未创建 +- [ ] 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` 实际配置)未开始 +- [ ] 测试策略(pytest 单元/集成测试、前端组件测试、E2E)未编写,当前无 CI +- [ ] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)未在 `production.py` 中逐项落实确认 +- [ ] ICP 备案、隐私政策/Cookie 合规清单(§2.15)尚未启动 +- [ ] 中文分词、OpenSearch 集成(Phase 2 提前项)未开始 + ### 2.17 风险与备选方案 | 风险 | 应对 | diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..848d92c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE = wagtailcms.settings.dev +python_files = tests.py test_*.py *_tests.py +addopts = --reuse-db diff --git a/wagtailcms/settings/base.py b/wagtailcms/settings/base.py index 20a3e33..ba548c6 100644 --- a/wagtailcms/settings/base.py +++ b/wagtailcms/settings/base.py @@ -23,6 +23,7 @@ INSTALLED_APPS = [ "apps.home", "apps.blog", "apps.products", + "apps.solutions", "apps.cases", "apps.forms", "apps.api",