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
This commit is contained in:
@@ -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 = "客户案例"
|
||||||
@@ -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',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 = "客户案例"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-hans">
|
||||||
|
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.intro }}</p>
|
||||||
|
<ul>
|
||||||
|
{% for case in case_studies %}
|
||||||
|
<li><a href="{% pageurl case %}">{{ case.title }}({{ case.client_name }})</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-hans">
|
||||||
|
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.client_name }} · {{ page.industry }}</p>
|
||||||
|
<p>{{ page.summary }}</p>
|
||||||
|
{% for block in page.body %}
|
||||||
|
{% include_block block %}
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from wagtail import blocks
|
from wagtail import blocks
|
||||||
from wagtail.images.blocks import ImageChooserBlock
|
from wagtail.images.blocks import ImageChooserBlock
|
||||||
|
from wagtail.snippets.blocks import SnippetChooserBlock
|
||||||
|
|
||||||
|
|
||||||
class CTAButtonBlock(blocks.StructBlock):
|
class CTAButtonBlock(blocks.StructBlock):
|
||||||
@@ -97,6 +98,59 @@ class LogoCloudBlock(blocks.StructBlock):
|
|||||||
template = "core/blocks/logo_cloud_block.html"
|
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 = [
|
COMMON_BLOCKS = [
|
||||||
("hero", HeroBlock()),
|
("hero", HeroBlock()),
|
||||||
("stats", StatsBlock()),
|
("stats", StatsBlock()),
|
||||||
@@ -104,5 +158,7 @@ COMMON_BLOCKS = [
|
|||||||
("faq", FAQBlock()),
|
("faq", FAQBlock()),
|
||||||
("cta", CTABlock()),
|
("cta", CTABlock()),
|
||||||
("logo_cloud", LogoCloudBlock()),
|
("logo_cloud", LogoCloudBlock()),
|
||||||
|
("case_study", CaseStudyBlock()),
|
||||||
|
("form", FormBlock()),
|
||||||
("richtext", blocks.RichTextBlock(label=_("富文本"))),
|
("richtext", blocks.RichTextBlock(label=_("富文本"))),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 = "线索表单"
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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="")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import LeadSubmitView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("leads/", LeadSubmitView.as_view(), name="lead-submit"),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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 = "产品"
|
||||||
@@ -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',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 = "产品页"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-hans">
|
||||||
|
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.intro }}</p>
|
||||||
|
<ul>
|
||||||
|
{% for product in products %}
|
||||||
|
<li><a href="{% pageurl product %}">{{ product.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-hans">
|
||||||
|
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.summary }}</p>
|
||||||
|
{% for block in page.body %}
|
||||||
|
{% include_block block %}
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -22,6 +22,9 @@ INSTALLED_APPS = [
|
|||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.home",
|
"apps.home",
|
||||||
"apps.blog",
|
"apps.blog",
|
||||||
|
"apps.products",
|
||||||
|
"apps.cases",
|
||||||
|
"apps.forms",
|
||||||
"apps.api",
|
"apps.api",
|
||||||
# Wagtail
|
# Wagtail
|
||||||
"wagtail.contrib.forms",
|
"wagtail.contrib.forms",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ urlpatterns = [
|
|||||||
path('admin/', include(wagtailadmin_urls)),
|
path('admin/', include(wagtailadmin_urls)),
|
||||||
path('documents/', include(wagtaildocs_urls)),
|
path('documents/', include(wagtaildocs_urls)),
|
||||||
path('api/v2/', api_router.urls),
|
path('api/v2/', api_router.urls),
|
||||||
|
# 自定义业务接口(非 Wagtail 页面路由),如线索表单提交
|
||||||
|
path('api/v1/custom/', include('apps.forms.urls')),
|
||||||
path('healthz', healthz, name='healthz'),
|
path('healthz', healthz, name='healthz'),
|
||||||
path('readyz', readyz, name='readyz'),
|
path('readyz', readyz, name='readyz'),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user