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 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
)