feat: 实现预览模式(wagtail-headless-preview + Next.js Draft Mode)
This commit is contained in:
@@ -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
@@ -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="留空则使用页面标题",
|
||||||
|
|||||||
@@ -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`(存 Redis,TTL 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 Mode(cookie 方式,非 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 Block(Pricing/Timeline/Team/TechStack/Video/ProductCard)未开发
|
- [ ] 剩余 5 个 StreamField Block(Pricing/Timeline/Team/TechStack/Video/ProductCard)未开发
|
||||||
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
|
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
|
||||||
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
|
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 API(apps/api/urls.py)拉取草稿数据。
|
||||||
|
WAGTAIL_HEADLESS_PREVIEW = {
|
||||||
|
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/preview"},
|
||||||
|
# Wagtail 7.1+ 推荐直接重定向到前端而非嵌套 iframe,便于滚动位置保持等体验与本项目跨域部署场景对齐
|
||||||
|
"REDIRECT_ON_PREVIEW": True,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user