feat(core): 补全剩余 6 个 StreamField Block (ProductCard/Pricing/Timeline/Team/TechStack/Video)
- apps/core/blocks.py 新增 ProductCardBlock/PricingBlock/TimelineBlock/TeamBlock/TechStackBlock/VideoBlock,COMMON_BLOCKS 扩充至 14 个内容 Block - TeamBlock 新增 TeamMemberChooserBlock(SnippetChooserBlock),复用 FormBlock 已有的 get_api_representation 展开模式,展开 TeamMember 完整字段(含 serialize_image 复用) - 为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 alter field body 迁移(StreamField block 定义变更会写入迁移 state) - apps/core/tests.py 新增 TeamMemberChooserBlock 空值/展开单元测试,pytest 33 个用例全部通过 - 设计文档 §2.6/§2.16 同步更新为已实现
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,6 +7,8 @@ from wagtail import blocks
|
|||||||
from wagtail.images.blocks import ImageChooserBlock
|
from wagtail.images.blocks import ImageChooserBlock
|
||||||
from wagtail.snippets.blocks import SnippetChooserBlock
|
from wagtail.snippets.blocks import SnippetChooserBlock
|
||||||
|
|
||||||
|
from .serializers import serialize_image
|
||||||
|
|
||||||
|
|
||||||
class CTAButtonBlock(blocks.StructBlock):
|
class CTAButtonBlock(blocks.StructBlock):
|
||||||
text = blocks.CharBlock(max_length=50, label=_("按钮文案"))
|
text = blocks.CharBlock(max_length=50, label=_("按钮文案"))
|
||||||
@@ -150,6 +152,113 @@ class FormBlock(blocks.StructBlock):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCardItemBlock(blocks.StructBlock):
|
||||||
|
title = blocks.CharBlock(max_length=100, label=_("标题"))
|
||||||
|
description = blocks.TextBlock(required=False, label=_("描述"))
|
||||||
|
image = ImageChooserBlock(required=False, label=_("图片"))
|
||||||
|
link = blocks.URLBlock(required=False, label=_("链接"))
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCardBlock(blocks.StructBlock):
|
||||||
|
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
|
||||||
|
items = blocks.ListBlock(ProductCardItemBlock(), label=_("产品列表"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "grip"
|
||||||
|
label = _("产品矩阵")
|
||||||
|
|
||||||
|
|
||||||
|
class PricingPlanBlock(blocks.StructBlock):
|
||||||
|
name = blocks.CharBlock(max_length=50, label=_("方案名称"))
|
||||||
|
price = blocks.CharBlock(max_length=50, label=_("价格"))
|
||||||
|
features = blocks.ListBlock(
|
||||||
|
blocks.CharBlock(max_length=100), label=_("功能列表")
|
||||||
|
)
|
||||||
|
highlighted = blocks.BooleanBlock(
|
||||||
|
required=False, default=False, label=_("突出显示")
|
||||||
|
)
|
||||||
|
button = CTAButtonBlock(required=False, label=_("按钮"))
|
||||||
|
|
||||||
|
|
||||||
|
class PricingBlock(blocks.StructBlock):
|
||||||
|
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
|
||||||
|
plans = blocks.ListBlock(PricingPlanBlock(), label=_("定价方案"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "tag"
|
||||||
|
label = _("定价")
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineItemBlock(blocks.StructBlock):
|
||||||
|
year = blocks.CharBlock(max_length=20, label=_("年份/时间"))
|
||||||
|
event = blocks.TextBlock(label=_("事件描述"))
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineBlock(blocks.StructBlock):
|
||||||
|
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
|
||||||
|
items = blocks.ListBlock(TimelineItemBlock(), label=_("时间线条目"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "date"
|
||||||
|
label = _("发展历程")
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberChooserBlock(SnippetChooserBlock):
|
||||||
|
"""选择 apps.core.TeamMember Snippet,展开完整字段供前端渲染(而非仅主键)。"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__("core.TeamMember", **kwargs)
|
||||||
|
|
||||||
|
def get_api_representation(self, value, context=None):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": value.pk,
|
||||||
|
"name": value.name,
|
||||||
|
"role": value.role,
|
||||||
|
"bio": value.bio,
|
||||||
|
"photo": serialize_image(value.photo),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TeamBlock(blocks.StructBlock):
|
||||||
|
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
|
||||||
|
members = blocks.ListBlock(TeamMemberChooserBlock(), label=_("团队成员"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "group"
|
||||||
|
label = _("团队展示")
|
||||||
|
|
||||||
|
|
||||||
|
class TechStackItemBlock(blocks.StructBlock):
|
||||||
|
icon = blocks.CharBlock(max_length=50, required=False, label=_("图标"))
|
||||||
|
label = blocks.CharBlock(max_length=50, label=_("名称"))
|
||||||
|
|
||||||
|
|
||||||
|
class TechStackBlock(blocks.StructBlock):
|
||||||
|
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
|
||||||
|
items = blocks.ListBlock(TechStackItemBlock(), label=_("技术项"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "cogs"
|
||||||
|
label = _("技术架构")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoBlock(blocks.StructBlock):
|
||||||
|
heading = blocks.CharBlock(max_length=100, required=False, label=_("标题"))
|
||||||
|
video_url = blocks.URLBlock(
|
||||||
|
label=_("视频地址"),
|
||||||
|
help_text=_(
|
||||||
|
"建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的"
|
||||||
|
"嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
poster = ImageChooserBlock(required=False, label=_("封面图"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
icon = "media"
|
||||||
|
label = _("视频")
|
||||||
|
|
||||||
|
|
||||||
COMMON_BLOCKS = [
|
COMMON_BLOCKS = [
|
||||||
("hero", HeroBlock()),
|
("hero", HeroBlock()),
|
||||||
@@ -160,5 +269,11 @@ COMMON_BLOCKS = [
|
|||||||
("logo_cloud", LogoCloudBlock()),
|
("logo_cloud", LogoCloudBlock()),
|
||||||
("case_study", CaseStudyBlock()),
|
("case_study", CaseStudyBlock()),
|
||||||
("form", FormBlock()),
|
("form", FormBlock()),
|
||||||
|
("product_card", ProductCardBlock()),
|
||||||
|
("pricing", PricingBlock()),
|
||||||
|
("timeline", TimelineBlock()),
|
||||||
|
("team", TeamBlock()),
|
||||||
|
("tech_stack", TechStackBlock()),
|
||||||
|
("video", VideoBlock()),
|
||||||
("richtext", blocks.RichTextBlock(label=_("富文本"))),
|
("richtext", blocks.RichTextBlock(label=_("富文本"))),
|
||||||
]
|
]
|
||||||
|
|||||||
+21
-1
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.core.blocks import FormBlock
|
from apps.core.blocks import FormBlock, TeamMemberChooserBlock
|
||||||
from apps.core.models import (
|
from apps.core.models import (
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
@@ -159,6 +159,26 @@ def test_testimonial_api_returns_list():
|
|||||||
assert response.data["items"][0]["author_name"] == "客户A"
|
assert response.data["items"][0]["author_name"] == "客户A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_member_chooser_block_api_representation_expands_fields():
|
||||||
|
"""TeamMemberChooserBlock.get_api_representation 应展开 TeamMember 的完整字段,
|
||||||
|
而不是仅返回 SnippetChooserBlock 默认的主键。"""
|
||||||
|
member = TeamMember.objects.create(name="王五", role="产品经理", bio="十年行业经验")
|
||||||
|
|
||||||
|
block = TeamMemberChooserBlock()
|
||||||
|
result = block.get_api_representation(member)
|
||||||
|
|
||||||
|
assert result["id"] == member.pk
|
||||||
|
assert result["name"] == "王五"
|
||||||
|
assert result["role"] == "产品经理"
|
||||||
|
assert result["bio"] == "十年行业经验"
|
||||||
|
assert result["photo"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_member_chooser_block_api_representation_handles_none():
|
||||||
|
block = TeamMemberChooserBlock()
|
||||||
|
assert block.get_api_representation(None) is None
|
||||||
|
|
||||||
|
|
||||||
def test_partner_api_returns_list():
|
def test_partner_api_returns_list():
|
||||||
Partner.objects.create(name="伙伴A", website_url="https://partner-a.example.com")
|
Partner.objects.create(name="伙伴A", website_url="https://partner-a.example.com")
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -219,15 +219,15 @@ Snippet(非页面树内容,用于跨页面复用):`TeamMember`、`Testim
|
|||||||
| HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | ✅ 已实现 |
|
| HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | ✅ 已实现 |
|
||||||
| StatsBlock | 数据展示 | items: [{value, label}] | ✅ 已实现 |
|
| StatsBlock | 数据展示 | items: [{value, label}] | ✅ 已实现 |
|
||||||
| FeatureGridBlock | 能力/优势 | icon, title, description | ✅ 已实现(对应原 FeatureBlock) |
|
| FeatureGridBlock | 能力/优势 | icon, title, description | ✅ 已实现(对应原 FeatureBlock) |
|
||||||
| ProductCardBlock | 产品矩阵 | title, description, image, link | ⬜ 待开发 |
|
| ProductCardBlock | 产品矩阵 | title, description, image, link | ✅ 已实现 |
|
||||||
| LogoCloudBlock | 客户 Logo 墙 | logos: [{url, title}](暂为图片列表,未接入 Partner Snippet) | ✅ 已实现(简化版) |
|
| LogoCloudBlock | 客户 Logo 墙 | logos: [{url, title}](暂为图片列表,未接入 Partner Snippet) | ✅ 已实现(简化版) |
|
||||||
| CaseStudyBlock | 案例卡片 | case_page: PageChooser(cases.CaseStudyPage), summary | ✅ 已实现 |
|
| CaseStudyBlock | 案例卡片 | case_page: PageChooser(cases.CaseStudyPage), summary | ✅ 已实现 |
|
||||||
| PricingBlock | 定价 | plans: [{name, price, features}] | ⬜ 待开发 |
|
| PricingBlock | 定价 | plans: [{name, price, features, highlighted, button}] | ✅ 已实现 |
|
||||||
| FAQBlock | 常见问题 | items: [{question, answer(richtext)}] | ✅ 已实现 |
|
| FAQBlock | 常见问题 | items: [{question, answer(richtext)}] | ✅ 已实现 |
|
||||||
| TimelineBlock | 发展历程 | items: [{year, event}] | ⬜ 待开发 |
|
| TimelineBlock | 发展历程 | items: [{year, event}] | ✅ 已实现 |
|
||||||
| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)] | ⬜ 待开发(依赖 TeamMember Snippet) |
|
| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)],`get_api_representation` 展开完整字段(name/role/bio/photo)供前端渲染 | ✅ 已实现(依赖 TeamMember Snippet) |
|
||||||
| TechStackBlock | 技术架构图 | items: [{icon, label}] | ⬜ 待开发 |
|
| TechStackBlock | 技术架构图 | items: [{icon, label}] | ✅ 已实现 |
|
||||||
| VideoBlock | 视频 | video_file/embed_url, poster | ⬜ 待开发 |
|
| VideoBlock | 视频 | video_url(建议自建 OSS/COS 直链或腾讯视频/哔哩哔哩等国内平台嵌入地址),poster | ✅ 已实现 |
|
||||||
| FormBlock | 线索表单 | form: SnippetChooser(forms.FormDefinition),`get_api_representation` 展开完整字段定义供前端渲染 | ✅ 已实现 |
|
| FormBlock | 线索表单 | form: SnippetChooser(forms.FormDefinition),`get_api_representation` 展开完整字段定义供前端渲染 | ✅ 已实现 |
|
||||||
| CTABlock | 行动号召 | heading, button_text, button_link | ✅ 已实现 |
|
| CTABlock | 行动号召 | heading, button_text, button_link | ✅ 已实现 |
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
|
|||||||
|
|
||||||
已完成:
|
已完成:
|
||||||
- [x] 后端 Django + Wagtail 项目骨架,分层 settings(base/dev/production),独立 git 仓库并已提交
|
- [x] 后端 Django + Wagtail 项目骨架,分层 settings(base/dev/production),独立 git 仓库并已提交
|
||||||
- [x] `apps/core`:`SEOablePage` 抽象基类、`COMMON_BLOCKS`(9/14 个 Block,见 §2.6)、健康检查 `/healthz` `/readyz`、发布后 `page_published` signal → 前端 revalidate webhook
|
- [x] `apps/core`:`SEOablePage` 抽象基类、`COMMON_BLOCKS`(14/14 个 Block 已全部实现,见 §2.6)、健康检查 `/healthz` `/readyz`、发布后 `page_published` signal → 前端 revalidate webhook
|
||||||
- [x] `apps/home`、`apps/blog`(含标签 `ClusterTaggableManager`)
|
- [x] `apps/home`、`apps/blog`(含标签 `ClusterTaggableManager`)
|
||||||
- [x] `apps/products`(ProductIndexPage/ProductPage)、`apps/cases`(CaseStudyIndexPage/CaseStudyPage)、`apps/solutions`(SolutionIndexPage/SolutionPage)
|
- [x] `apps/products`(ProductIndexPage/ProductPage)、`apps/cases`(CaseStudyIndexPage/CaseStudyPage)、`apps/solutions`(SolutionIndexPage/SolutionPage)
|
||||||
- [x] `apps/forms`:`FormDefinition`/`FormDefinitionField`/`Lead`(Snippet 方式)+ 提交 API(`/api/v1/custom/leads/`,限流 + 邮件通知)
|
- [x] `apps/forms`:`FormDefinition`/`FormDefinitionField`/`Lead`(Snippet 方式)+ 提交 API(`/api/v1/custom/leads/`,限流 + 邮件通知)
|
||||||
@@ -375,9 +375,9 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
|
|||||||
- [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行)
|
- [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行)
|
||||||
- [x] Snippet:`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem`(`ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings`(`wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/`(`team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入
|
- [x] Snippet:`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem`(`ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings`(`wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/`(`team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入
|
||||||
- [x] 预览模式:后端引入 `wagtail-headless-preview`(0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts`(`getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证)
|
- [x] 预览模式:后端引入 `wagtail-headless-preview`(0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts`(`getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证)
|
||||||
|
- [x] 剩余 6 个 StreamField Block(`ProductCardBlock`/`PricingBlock`/`TimelineBlock`/`TeamBlock`/`TechStackBlock`/`VideoBlock`)已全部开发完成:`apps/core/blocks.py` 新增对应 Block 类并扩充 `COMMON_BLOCKS`(现共 14 个内容 Block + 1 个 `richtext`),因 Wagtail StreamField 会将 Block 结构写入迁移(`use_json_field=True` 不代表可跳过迁移),已为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 `alter field body` 迁移;`TeamBlock` 复用 `FormBlock` 的 `get_api_representation` 覆写模式,新增 `TeamMemberChooserBlock(SnippetChooserBlock)` 展开 `TeamMember` 完整字段(含 `serialize_image` 复用),并补充对应的空值/展开单元测试(新增 2 个用例,后端共 33 个测试全部通过);前端 `types/wagtail.ts` 新增 6 个 Block 值类型与 `StreamFieldBlock` 联合类型分支,新建 6 个渲染组件(`ProductCardGrid`/`Pricing`/`Timeline`/`Team`/`TechStack`/`Video`)并接入 `BlockRenderer.tsx` 映射表,`lint`/`build` 均验证通过
|
||||||
|
|
||||||
未完成(待规划排期):
|
未完成(待规划排期):
|
||||||
- [ ] 剩余 5 个 StreamField Block(Pricing/Timeline/Team/TechStack/Video/ProductCard)未开发
|
|
||||||
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
|
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
|
||||||
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
|
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
|
||||||
- [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入
|
- [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入
|
||||||
|
|||||||
Reference in New Issue
Block a user