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 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
|
||||
)
|
||||
Reference in New Issue
Block a user