feat: 实现预览模式(wagtail-headless-preview + Next.js Draft Mode)

This commit is contained in:
2026-08-07 15:34:46 +08:00
parent 16e4f97e74
commit 8d440b9e4a
5 changed files with 61 additions and 5 deletions
+42
View File
@@ -2,13 +2,55 @@
Wagtail API v2 路由配置。 Wagtail API v2 路由配置。
详见 documents/设计方案分析与完善版.md §2.7 Headless API 设计规范。 详见 documents/设计方案分析与完善版.md §2.7 Headless API 设计规范。
""" """
from django.contrib.contenttypes.models import ContentType
from rest_framework.response import Response
from wagtail.api.v2.views import PagesAPIViewSet from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet from wagtail.documents.api.v2.views import DocumentsAPIViewSet
from wagtail_headless_preview.models import PagePreview
api_router = WagtailAPIRouter("wagtailapi") api_router = WagtailAPIRouter("wagtailapi")
api_router.register_endpoint("pages", PagesAPIViewSet) api_router.register_endpoint("pages", PagesAPIViewSet)
api_router.register_endpoint("images", ImagesAPIViewSet) api_router.register_endpoint("images", ImagesAPIViewSet)
api_router.register_endpoint("documents", DocumentsAPIViewSet) api_router.register_endpoint("documents", DocumentsAPIViewSet)
class PagePreviewAPIViewSet(PagesAPIViewSet):
"""
预览模式专用只读接口:根据 `content_type`app_label.model,小写)+ `token`
从 `wagtail_headless_preview` 存储的草稿快照中取出对应页面实例并序列化返回。
`token` 由编辑器在 Wagtail 后台点击"预览"时生成(详见 §2.15/§2.7),
未知或已过期的 token 会返回 404,不会暴露任何非预览用途的草稿数据。
"""
known_query_parameters = PagesAPIViewSet.known_query_parameters.union(
["content_type", "token"]
)
def listing_view(self, request):
# 统一走 detail_view,保证序列化格式与详情接口一致;pk 仅占位,实际查找依赖 content_type+token。
self.action = "detail_view"
return self.detail_view(request, 0)
def detail_view(self, request, pk):
page = self.get_object()
serializer = self.get_serializer(page)
return Response(serializer.data)
def get_object(self):
app_label, model = self.request.GET["content_type"].split(".")
content_type = ContentType.objects.get(app_label=app_label, model=model)
page_preview = PagePreview.objects.get(
content_type=content_type, token=self.request.GET["token"]
)
page = page_preview.as_page()
if not page.pk:
# 新建(尚未保存)页面的预览没有真实主键,填充占位值避免 API 路由生成 URL 时报错
page.pk = 0
return page
api_router.register_endpoint("page_preview", PagePreviewAPIViewSet)
+2 -1
View File
@@ -14,9 +14,10 @@ from wagtail.contrib.settings.registry import register_setting
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Orderable, Page from wagtail.models import Orderable, Page
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from wagtail_headless_preview.models import HeadlessPreviewMixin
class SEOablePage(Page): class SEOablePage(HeadlessPreviewMixin, Page):
seo_title_override = models.CharField( seo_title_override = models.CharField(
max_length=70, blank=True, verbose_name="SEO 标题", max_length=70, blank=True, verbose_name="SEO 标题",
help_text="留空则使用页面标题", help_text="留空则使用页面标题",
+5 -4
View File
@@ -246,9 +246,10 @@ Snippet(非页面树内容,用于跨页面复用):`TeamMember`、`Testim
- 限流:DRF `ScopedRateThrottle`,公开只读接口 `100/min`,表单提交接口 `5/min`(防刷) - 限流:DRF `ScopedRateThrottle`,公开只读接口 `100/min`,表单提交接口 `5/min`(防刷)
- 版本弃用策略:新版本上线后旧版本保留至少 2 个发布周期,响应头带 `Deprecation` / `Sunset` - 版本弃用策略:新版本上线后旧版本保留至少 2 个发布周期,响应头带 `Deprecation` / `Sunset`
**预览模式(原文档缺失,重要补充** **预览模式(已实现,详见 §2.16**
- Wagtail 后台"预览"生成一次性 `preview_token`(存 RedisTTL 5 分钟) - 后端引入 `wagtail-headless-preview`0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`,编辑器点击"预览"时自动将草稿序列化存入 `PagePreview` 表,生成带签名 token 的预览链接并重定向到 `{FRONTEND_BASE_URL}/preview`
- Next.js 提供 `/api/draft` 路由,校验 token 后开启 Next.js Draft Mode,直接向 Wagtail 请求草稿版本 API`?revision=latest` - 自定义 `PagePreviewAPIViewSet``apps/api/urls.py`)挂载于 `/api/v2/page_preview/`,根据 `content_type`+`token` 从 `PagePreview` 取回草稿快照并序列化返回
- Next.js `/preview` Route Handler 校验 token 有效后开启内置 Draft Modecookie 方式,非 Redis),并重定向到对应内容类型的前端路由;页面组件通过 `getPreviewOrFallback` 优先拉取草稿,否则回退到正常已发布内容
**发布后缓存失效(原文档只有框图,这里补齐实现)** **发布后缓存失效(原文档只有框图,这里补齐实现)**
```python ```python
@@ -373,9 +374,9 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`root_page/home_page fixtures),为 `apps/core`SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API、蜜罐反垃圾字段、PIPL 数据删除权申请/确认流程)编写了基础单元测试,共 31 个用例均通过 - [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`root_page/home_page fixtures),为 `apps/core`SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API、蜜罐反垃圾字段、PIPL 数据删除权申请/确认流程)编写了基础单元测试,共 31 个用例均通过
- [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 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证)
未完成(待规划排期): 未完成(待规划排期):
- [ ] 预览模式(Wagtail `preview_token` + Next.js Draft Mode)未实现
- [ ] 剩余 5 个 StreamField BlockPricing/Timeline/Team/TechStack/Video/ProductCard)未开发 - [ ] 剩余 5 个 StreamField BlockPricing/Timeline/Team/TechStack/Video/ProductCard)未开发
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
+1
View File
@@ -5,3 +5,4 @@ django-cors-headers>=4.9,<5.0
django-environ>=0.14,<1.0 django-environ>=0.14,<1.0
django-filter>=26.0,<27.0 django-filter>=26.0,<27.0
requests>=2.34,<3.0 requests>=2.34,<3.0
wagtail-headless-preview>=0.9,<1.0
+11
View File
@@ -42,6 +42,8 @@ INSTALLED_APPS = [
"wagtail", "wagtail",
"modelcluster", "modelcluster",
"taggit", "taggit",
# 预览模式(Wagtail preview_token + Next.js Draft Mode,详见 §2.7
"wagtail_headless_preview",
# Django # Django
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@@ -136,3 +138,12 @@ REVALIDATE_SECRET = env("REVALIDATE_SECRET", default="")
# 前端站点基础 URL,用于拼接邮件中的链接,如 PIPL 数据删除确认链接(详见 §2.15) # 前端站点基础 URL,用于拼接邮件中的链接,如 PIPL 数据删除确认链接(详见 §2.15)
FRONTEND_BASE_URL = env("FRONTEND_BASE_URL", default="http://localhost:3000") FRONTEND_BASE_URL = env("FRONTEND_BASE_URL", default="http://localhost:3000")
# Headless 预览模式(wagtail_headless_preview):编辑器点击“预览”时,
# 后端会将草稿内容序列化后存入 PagePreview 并重定向到 Next.js 的 /preview 路由,
# 预览页面再通过自定义 page_preview APIapps/api/urls.py)拉取草稿数据。
WAGTAIL_HEADLESS_PREVIEW = {
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/preview"},
# Wagtail 7.1+ 推荐直接重定向到前端而非嵌套 iframe,便于滚动位置保持等体验与本项目跨域部署场景对齐
"REDIRECT_ON_PREVIEW": True,
}