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:
2026-08-06 16:04:21 +08:00
parent 7f51a31150
commit 12f7caa158
25 changed files with 618 additions and 0 deletions
View File
+8
View File
@@ -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 = "客户案例"
+55
View File
@@ -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',),
),
]
View File
+70
View File
@@ -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>
+56
View File
@@ -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=_("富文本"))),
]
View File
+8
View File
@@ -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 = "线索表单"
+62
View File
@@ -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'],
},
),
]
View File
+113
View File
@@ -0,0 +1,113 @@
"""
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
嵌入任意 StreamField 页面;提交结果保存为 Lead。
详见 documents/设计方案分析与完善版.md §2.6FormBlock)与 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}"
+7
View File
@@ -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="")
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import LeadSubmitView
urlpatterns = [
path("leads/", LeadSubmitView.as_view(), name="lead-submit"),
]
+51
View File
@@ -0,0 +1,51 @@
"""
线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与
documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。
公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope5/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
)
View File
+8
View File
@@ -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 = "产品"
+52
View File
@@ -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',),
),
]
+63
View File
@@ -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>
+3
View File
@@ -22,6 +22,9 @@ INSTALLED_APPS = [
"apps.core",
"apps.home",
"apps.blog",
"apps.products",
"apps.cases",
"apps.forms",
"apps.api",
# Wagtail
"wagtail.contrib.forms",
+2
View File
@@ -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'),
]