From 12f7caa1581c772af1160bfd9f69c5f980e1a118 Mon Sep 17 00:00:00 2001 From: Zhengen TANG Date: Thu, 6 Aug 2026 16:04:21 +0800 Subject: [PATCH] feat: add products/cases page models and forms lead-capture app - apps/products: ProductIndexPage/ProductPage - apps/cases: CaseStudyIndexPage/CaseStudyPage - apps/forms: FormDefinition/FormDefinitionField/Lead + submit API - core/blocks: CaseStudyBlock, FormBlock (with API representation) - register new apps, custom leads API route, migrations --- apps/cases/__init__.py | 0 apps/cases/apps.py | 8 ++ apps/cases/migrations/0001_initial.py | 55 +++++++++ apps/cases/migrations/__init__.py | 0 apps/cases/models.py | 70 +++++++++++ .../cases/case_study_index_page.html | 14 +++ .../templates/cases/case_study_page.html | 13 ++ apps/core/blocks.py | 56 +++++++++ apps/forms/__init__.py | 0 apps/forms/apps.py | 8 ++ apps/forms/migrations/0001_initial.py | 62 ++++++++++ apps/forms/migrations/__init__.py | 0 apps/forms/models.py | 113 ++++++++++++++++++ apps/forms/serializers.py | 7 ++ apps/forms/urls.py | 7 ++ apps/forms/views.py | 51 ++++++++ apps/products/__init__.py | 0 apps/products/apps.py | 8 ++ apps/products/migrations/0001_initial.py | 52 ++++++++ apps/products/migrations/__init__.py | 0 apps/products/models.py | 63 ++++++++++ .../products/product_index_page.html | 14 +++ .../templates/products/product_page.html | 12 ++ wagtailcms/settings/base.py | 3 + wagtailcms/urls.py | 2 + 25 files changed, 618 insertions(+) create mode 100644 apps/cases/__init__.py create mode 100644 apps/cases/apps.py create mode 100644 apps/cases/migrations/0001_initial.py create mode 100644 apps/cases/migrations/__init__.py create mode 100644 apps/cases/models.py create mode 100644 apps/cases/templates/cases/case_study_index_page.html create mode 100644 apps/cases/templates/cases/case_study_page.html create mode 100644 apps/forms/__init__.py create mode 100644 apps/forms/apps.py create mode 100644 apps/forms/migrations/0001_initial.py create mode 100644 apps/forms/migrations/__init__.py create mode 100644 apps/forms/models.py create mode 100644 apps/forms/serializers.py create mode 100644 apps/forms/urls.py create mode 100644 apps/forms/views.py create mode 100644 apps/products/__init__.py create mode 100644 apps/products/apps.py create mode 100644 apps/products/migrations/0001_initial.py create mode 100644 apps/products/migrations/__init__.py create mode 100644 apps/products/models.py create mode 100644 apps/products/templates/products/product_index_page.html create mode 100644 apps/products/templates/products/product_page.html diff --git a/apps/cases/__init__.py b/apps/cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/cases/apps.py b/apps/cases/apps.py new file mode 100644 index 0000000..127761b --- /dev/null +++ b/apps/cases/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class CasesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.cases" + label = "cases" + verbose_name = "客户案例" diff --git a/apps/cases/migrations/0001_initial.py b/apps/cases/migrations/0001_initial.py new file mode 100644 index 0000000..b1ad351 --- /dev/null +++ b/apps/cases/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 6.0.5 on 2026-08-06 07:59 + +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='CaseStudyIndexPage', + 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='CaseStudyPage', + 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)')), + ('client_name', models.CharField(max_length=100, verbose_name='客户名称')), + ('industry', models.CharField(blank=True, max_length=100, verbose_name='所属行业')), + ('published_at', models.DateTimeField(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/cases/migrations/__init__.py b/apps/cases/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/cases/models.py b/apps/cases/models.py new file mode 100644 index 0000000..c88af40 --- /dev/null +++ b/apps/cases/models.py @@ -0,0 +1,70 @@ +"""客户案例栏目与案例详情页。详见 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 CaseStudyIndexPage(SEOablePage): + """客户案例栏目列表页,子页面为 CaseStudyPage。""" + + intro = models.TextField(blank=True, verbose_name="栏目简介") + + content_panels = SEOablePage.content_panels + [ + FieldPanel("intro"), + ] + + subpage_types = ["cases.CaseStudyPage"] + parent_page_types = ["home.HomePage"] + + class Meta: + verbose_name = "客户案例栏目页" + + def get_context(self, request, *args, **kwargs): + context = super().get_context(request, *args, **kwargs) + context["case_studies"] = ( + CaseStudyPage.objects.live().descendant_of(self).order_by("-published_at") + ) + return context + + +class CaseStudyPage(SEOablePage): + """单个客户案例详情页。""" + + client_name = models.CharField(max_length=100, verbose_name="客户名称") + industry = models.CharField(max_length=100, blank=True, verbose_name="所属行业") + published_at = models.DateTimeField(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("client_name"), + index.SearchField("summary"), + index.SearchField("body"), + ] + + content_panels = SEOablePage.content_panels + [ + FieldPanel("client_name"), + FieldPanel("industry"), + FieldPanel("published_at"), + FieldPanel("summary"), + FieldPanel("cover_image"), + FieldPanel("body"), + ] + + parent_page_types = ["cases.CaseStudyIndexPage"] + subpage_types = [] + + class Meta: + verbose_name = "客户案例" diff --git a/apps/cases/templates/cases/case_study_index_page.html b/apps/cases/templates/cases/case_study_index_page.html new file mode 100644 index 0000000..db4624f --- /dev/null +++ b/apps/cases/templates/cases/case_study_index_page.html @@ -0,0 +1,14 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.intro }}

+ + + diff --git a/apps/cases/templates/cases/case_study_page.html b/apps/cases/templates/cases/case_study_page.html new file mode 100644 index 0000000..2a77f12 --- /dev/null +++ b/apps/cases/templates/cases/case_study_page.html @@ -0,0 +1,13 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.client_name }} · {{ page.industry }}

+

{{ page.summary }}

+ {% for block in page.body %} + {% include_block block %} + {% endfor %} + + diff --git a/apps/core/blocks.py b/apps/core/blocks.py index 8809ae8..84b848b 100644 --- a/apps/core/blocks.py +++ b/apps/core/blocks.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from wagtail import blocks from wagtail.images.blocks import ImageChooserBlock +from wagtail.snippets.blocks import SnippetChooserBlock class CTAButtonBlock(blocks.StructBlock): @@ -97,6 +98,59 @@ class LogoCloudBlock(blocks.StructBlock): template = "core/blocks/logo_cloud_block.html" +class CaseStudyBlock(blocks.StructBlock): + case_page = blocks.PageChooserBlock( + page_type="cases.CaseStudyPage", label=_("案例页面") + ) + summary = blocks.CharBlock(max_length=250, required=False, label=_("摘要覆盖")) + + class Meta: + icon = "doc-full" + label = _("客户案例卡片") + + +class FormBlock(blocks.StructBlock): + """嵌入可复用的线索表单(apps.forms.FormDefinition Snippet)。""" + + form = SnippetChooserBlock("forms.FormDefinition", label=_("选择表单")) + + class Meta: + icon = "form" + label = _("线索表单") + + def get_api_representation(self, value, context=None): + """ + 默认 SnippetChooserBlock 只会序列化为主键,前端渲染动态表单需要完整字段定义, + 因此这里手动展开 FormDefinition 及其子字段(详见 apps/forms/models.py)。 + """ + form_def = value.get("form") + if form_def is None: + return {"form": None} + return { + "form": { + "id": form_def.pk, + "name": form_def.name, + "submit_button_text": form_def.submit_button_text, + "success_message": form_def.success_message, + "fields": [ + { + "label": field.label, + "field_key": field.field_key, + "field_type": field.field_type, + "required": field.required, + "choices": [ + choice.strip() + for choice in field.choices.split(",") + if choice.strip() + ], + } + for field in form_def.fields.all() + ], + } + } + + + COMMON_BLOCKS = [ ("hero", HeroBlock()), ("stats", StatsBlock()), @@ -104,5 +158,7 @@ COMMON_BLOCKS = [ ("faq", FAQBlock()), ("cta", CTABlock()), ("logo_cloud", LogoCloudBlock()), + ("case_study", CaseStudyBlock()), + ("form", FormBlock()), ("richtext", blocks.RichTextBlock(label=_("富文本"))), ] diff --git a/apps/forms/__init__.py b/apps/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/forms/apps.py b/apps/forms/apps.py new file mode 100644 index 0000000..5bc7a6b --- /dev/null +++ b/apps/forms/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FormsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.forms" + label = "forms" + verbose_name = "线索表单" diff --git a/apps/forms/migrations/0001_initial.py b/apps/forms/migrations/0001_initial.py new file mode 100644 index 0000000..6ecbf9a --- /dev/null +++ b/apps/forms/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 6.0.5 on 2026-08-06 07:59 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='FormDefinition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='表单名称')), + ('submit_button_text', models.CharField(default='提交', max_length=50, verbose_name='提交按钮文案')), + ('success_message', models.CharField(default='提交成功,我们会尽快与您联系。', max_length=200, verbose_name='提交成功提示')), + ('notification_email', models.EmailField(blank=True, max_length=254, verbose_name='通知邮箱')), + ], + options={ + 'verbose_name': '线索表单', + 'verbose_name_plural': '线索表单', + }, + ), + migrations.CreateModel( + name='FormDefinitionField', + 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=100, verbose_name='字段标签')), + ('field_key', models.SlugField(help_text='提交数据中使用的 key,例如 name / email / phone', verbose_name='字段标识')), + ('field_type', models.CharField(choices=[('text', '单行文本'), ('textarea', '多行文本'), ('email', '邮箱'), ('tel', '电话'), ('select', '下拉选择')], default='text', max_length=20, verbose_name='字段类型')), + ('required', models.BooleanField(default=True, verbose_name='是否必填')), + ('choices', models.CharField(blank=True, help_text='逗号分隔,仅“下拉选择”类型使用', max_length=255, verbose_name='可选项')), + ('form', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='forms.formdefinition')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Lead', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.JSONField(default=dict, verbose_name='提交数据')), + ('source_url', models.URLField(blank=True, verbose_name='来源页面')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='提交时间')), + ('form', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to='forms.formdefinition', verbose_name='来源表单')), + ], + options={ + 'verbose_name': '线索', + 'verbose_name_plural': '线索', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/forms/migrations/__init__.py b/apps/forms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/forms/models.py b/apps/forms/models.py new file mode 100644 index 0000000..ac69e81 --- /dev/null +++ b/apps/forms/models.py @@ -0,0 +1,113 @@ +""" +线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock +嵌入任意 StreamField 页面;提交结果保存为 Lead。 +详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 B2B 官网核心转化路径。 +""" +from django.db import models +from modelcluster.fields import ParentalKey +from modelcluster.models import ClusterableModel +from wagtail.admin.panels import FieldPanel, InlinePanel +from wagtail.models import Orderable +from wagtail.snippets.models import register_snippet + +FIELD_TYPE_CHOICES = [ + ("text", "单行文本"), + ("textarea", "多行文本"), + ("email", "邮箱"), + ("tel", "电话"), + ("select", "下拉选择"), +] + + +@register_snippet +class FormDefinition(ClusterableModel): + """可复用的线索表单定义。""" + + name = models.CharField(max_length=100, verbose_name="表单名称") + submit_button_text = models.CharField( + max_length=50, default="提交", verbose_name="提交按钮文案" + ) + success_message = models.CharField( + max_length=200, + default="提交成功,我们会尽快与您联系。", + verbose_name="提交成功提示", + ) + notification_email = models.EmailField(blank=True, verbose_name="通知邮箱") + + panels = [ + FieldPanel("name"), + FieldPanel("submit_button_text"), + FieldPanel("success_message"), + FieldPanel("notification_email"), + InlinePanel("fields", label="表单字段"), + ] + + class Meta: + verbose_name = "线索表单" + verbose_name_plural = "线索表单" + + def __str__(self): + return self.name + + +class FormDefinitionField(Orderable): + """FormDefinition 的子字段,编辑器在后台可自由增删排序。""" + + form = ParentalKey( + FormDefinition, on_delete=models.CASCADE, related_name="fields" + ) + label = models.CharField(max_length=100, verbose_name="字段标签") + field_key = models.SlugField( + max_length=50, + verbose_name="字段标识", + help_text="提交数据中使用的 key,例如 name / email / phone", + ) + field_type = models.CharField( + max_length=20, choices=FIELD_TYPE_CHOICES, default="text", verbose_name="字段类型" + ) + required = models.BooleanField(default=True, verbose_name="是否必填") + choices = models.CharField( + max_length=255, + blank=True, + verbose_name="可选项", + help_text="逗号分隔,仅“下拉选择”类型使用", + ) + + panels = [ + FieldPanel("label"), + FieldPanel("field_key"), + FieldPanel("field_type"), + FieldPanel("required"), + FieldPanel("choices"), + ] + + +@register_snippet +class Lead(models.Model): + """表单提交产生的线索记录,供销售/市场团队在后台查看。""" + + form = models.ForeignKey( + FormDefinition, + on_delete=models.SET_NULL, + null=True, + related_name="leads", + verbose_name="来源表单", + ) + data = models.JSONField(default=dict, verbose_name="提交数据") + source_url = models.URLField(blank=True, verbose_name="来源页面") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="提交时间") + + panels = [ + FieldPanel("form"), + FieldPanel("data"), + FieldPanel("source_url"), + ] + + class Meta: + verbose_name = "线索" + verbose_name_plural = "线索" + ordering = ["-created_at"] + + def __str__(self): + form_name = self.form.name if self.form_id else "(表单已删除)" + return f"{form_name} - {self.created_at:%Y-%m-%d %H:%M}" diff --git a/apps/forms/serializers.py b/apps/forms/serializers.py new file mode 100644 index 0000000..8c80122 --- /dev/null +++ b/apps/forms/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class LeadSubmitSerializer(serializers.Serializer): + form_id = serializers.IntegerField() + data = serializers.DictField() + source_url = serializers.URLField(required=False, allow_blank=True, default="") diff --git a/apps/forms/urls.py b/apps/forms/urls.py new file mode 100644 index 0000000..26e7f99 --- /dev/null +++ b/apps/forms/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import LeadSubmitView + +urlpatterns = [ + path("leads/", LeadSubmitView.as_view(), name="lead-submit"), +] diff --git a/apps/forms/views.py b/apps/forms/views.py new file mode 100644 index 0000000..feb16f9 --- /dev/null +++ b/apps/forms/views.py @@ -0,0 +1,51 @@ +""" +线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与 +documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。 +公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope(5/min,防刷)。 +""" +import json + +from django.conf import settings +from django.core.mail import send_mail +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from .models import FormDefinition, Lead +from .serializers import LeadSubmitSerializer + + +class LeadSubmitView(APIView): + throttle_classes = [ScopedRateThrottle] + throttle_scope = "forms" + + def post(self, request, *args, **kwargs): + serializer = LeadSubmitSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + form_id = serializer.validated_data["form_id"] + + try: + form_def = FormDefinition.objects.get(pk=form_id) + except FormDefinition.DoesNotExist: + return Response( + {"error": {"code": "NOT_FOUND", "message": "表单不存在"}}, status=404 + ) + + lead = Lead.objects.create( + form=form_def, + data=serializer.validated_data["data"], + source_url=serializer.validated_data.get("source_url", ""), + ) + + if form_def.notification_email: + send_mail( + subject=f"【新线索】{form_def.name}", + message=json.dumps(lead.data, ensure_ascii=False, indent=2), + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + recipient_list=[form_def.notification_email], + fail_silently=True, + ) + + return Response( + {"success": True, "message": form_def.success_message}, status=201 + ) diff --git a/apps/products/__init__.py b/apps/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/products/apps.py b/apps/products/apps.py new file mode 100644 index 0000000..23c7393 --- /dev/null +++ b/apps/products/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.products" + label = "products" + verbose_name = "产品" diff --git a/apps/products/migrations/0001_initial.py b/apps/products/migrations/0001_initial.py new file mode 100644 index 0000000..fa72773 --- /dev/null +++ b/apps/products/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 6.0.5 on 2026-08-06 07:59 + +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='ProductIndexPage', + 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='ProductPage', + 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)')), + ('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/products/migrations/__init__.py b/apps/products/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/products/models.py b/apps/products/models.py new file mode 100644 index 0000000..e2a2c46 --- /dev/null +++ b/apps/products/models.py @@ -0,0 +1,63 @@ +"""产品栏目与产品详情页。详见 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 ProductIndexPage(SEOablePage): + """产品栏目列表页,子页面为 ProductPage。""" + + intro = models.TextField(blank=True, verbose_name="栏目简介") + + content_panels = SEOablePage.content_panels + [ + FieldPanel("intro"), + ] + + subpage_types = ["products.ProductPage"] + parent_page_types = ["home.HomePage"] + + class Meta: + verbose_name = "产品栏目页" + + def get_context(self, request, *args, **kwargs): + context = super().get_context(request, *args, **kwargs) + context["products"] = ( + ProductPage.objects.live().descendant_of(self).order_by("path") + ) + return context + + +class ProductPage(SEOablePage): + """单个产品详情页。""" + + 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("summary"), + index.SearchField("body"), + ] + + content_panels = SEOablePage.content_panels + [ + FieldPanel("summary"), + FieldPanel("cover_image"), + FieldPanel("body"), + ] + + parent_page_types = ["products.ProductIndexPage"] + subpage_types = [] + + class Meta: + verbose_name = "产品页" diff --git a/apps/products/templates/products/product_index_page.html b/apps/products/templates/products/product_index_page.html new file mode 100644 index 0000000..a1625e4 --- /dev/null +++ b/apps/products/templates/products/product_index_page.html @@ -0,0 +1,14 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.intro }}

+ + + diff --git a/apps/products/templates/products/product_page.html b/apps/products/templates/products/product_page.html new file mode 100644 index 0000000..39e8613 --- /dev/null +++ b/apps/products/templates/products/product_page.html @@ -0,0 +1,12 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.summary }}

+ {% for block in page.body %} + {% include_block block %} + {% endfor %} + + diff --git a/wagtailcms/settings/base.py b/wagtailcms/settings/base.py index d4faca0..20a3e33 100644 --- a/wagtailcms/settings/base.py +++ b/wagtailcms/settings/base.py @@ -22,6 +22,9 @@ INSTALLED_APPS = [ "apps.core", "apps.home", "apps.blog", + "apps.products", + "apps.cases", + "apps.forms", "apps.api", # Wagtail "wagtail.contrib.forms", diff --git a/wagtailcms/urls.py b/wagtailcms/urls.py index 0be20f9..f874163 100644 --- a/wagtailcms/urls.py +++ b/wagtailcms/urls.py @@ -29,6 +29,8 @@ urlpatterns = [ path('admin/', include(wagtailadmin_urls)), path('documents/', include(wagtaildocs_urls)), path('api/v2/', api_router.urls), + # 自定义业务接口(非 Wagtail 页面路由),如线索表单提交 + path('api/v1/custom/', include('apps.forms.urls')), path('healthz', healthz, name='healthz'), path('readyz', readyz, name='readyz'), ]