From 8d440b9e4a2bc8d965688c443be4b79bfdf97e7a Mon Sep 17 00:00:00 2001 From: Zhengen TANG Date: Fri, 7 Aug 2026 15:34:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=88wagtail-headless-preview=20+=20Next.?= =?UTF-8?q?js=20Draft=20Mode=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/urls.py | 42 +++++++++++++++++++++++++++++++ apps/core/models.py | 3 ++- documents/设计方案分析与完善版.md | 9 ++++--- requirements/base.txt | 1 + wagtailcms/settings/base.py | 11 ++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/api/urls.py b/apps/api/urls.py index e722004..98a384b 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -2,13 +2,55 @@ Wagtail API v2 路由配置。 详见 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.router import WagtailAPIRouter from wagtail.images.api.v2.views import ImagesAPIViewSet from wagtail.documents.api.v2.views import DocumentsAPIViewSet +from wagtail_headless_preview.models import PagePreview api_router = WagtailAPIRouter("wagtailapi") api_router.register_endpoint("pages", PagesAPIViewSet) api_router.register_endpoint("images", ImagesAPIViewSet) 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) + diff --git a/apps/core/models.py b/apps/core/models.py index bde89f7..620423b 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -14,9 +14,10 @@ from wagtail.contrib.settings.registry import register_setting from wagtail.fields import RichTextField from wagtail.models import Orderable, Page 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( max_length=70, blank=True, verbose_name="SEO 标题", help_text="留空则使用页面标题", diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index 768491d..4ea757c 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -246,9 +246,10 @@ Snippet(非页面树内容,用于跨页面复用):`TeamMember`、`Testim - 限流:DRF `ScopedRateThrottle`,公开只读接口 `100/min`,表单提交接口 `5/min`(防刷) - 版本弃用策略:新版本上线后旧版本保留至少 2 个发布周期,响应头带 `Deprecation` / `Sunset` -**预览模式(原文档缺失,重要补充)** -- Wagtail 后台"预览"生成一次性 `preview_token`(存 Redis,TTL 5 分钟) -- Next.js 提供 `/api/draft` 路由,校验 token 后开启 Next.js Draft Mode,直接向 Wagtail 请求草稿版本 API(`?revision=latest`) +**预览模式(已实现,详见 §2.16)** +- 后端引入 `wagtail-headless-preview`(0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`,编辑器点击"预览"时自动将草稿序列化存入 `PagePreview` 表,生成带签名 token 的预览链接并重定向到 `{FRONTEND_BASE_URL}/preview` +- 自定义 `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 @@ -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] `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] 预览模式:后端引入 `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)未开发 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) diff --git a/requirements/base.txt b/requirements/base.txt index c8b85bd..4136140 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -5,3 +5,4 @@ django-cors-headers>=4.9,<5.0 django-environ>=0.14,<1.0 django-filter>=26.0,<27.0 requests>=2.34,<3.0 +wagtail-headless-preview>=0.9,<1.0 diff --git a/wagtailcms/settings/base.py b/wagtailcms/settings/base.py index 8cccae4..d003481 100644 --- a/wagtailcms/settings/base.py +++ b/wagtailcms/settings/base.py @@ -42,6 +42,8 @@ INSTALLED_APPS = [ "wagtail", "modelcluster", "taggit", + # 预览模式(Wagtail preview_token + Next.js Draft Mode,详见 §2.7) + "wagtail_headless_preview", # Django "django.contrib.admin", "django.contrib.auth", @@ -136,3 +138,12 @@ REVALIDATE_SECRET = env("REVALIDATE_SECRET", default="") # 前端站点基础 URL,用于拼接邮件中的链接,如 PIPL 数据删除确认链接(详见 §2.15) 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, +}