feat: 接入预览模式(Next.js Draft Mode)

This commit is contained in:
2026-08-07 15:34:59 +08:00
parent 1abadd04b8
commit 2457d5ec69
9 changed files with 203 additions and 6 deletions
+7 -1
View File
@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getBlogBySlug } from "@/services/blog.service";
import { getPreviewOrFallback } from "@/services/preview.service";
import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { BlogPageDetail } from "@/types/wagtail";
interface BlogDetailPageProps {
params: Promise<{ slug: string }>;
@@ -17,7 +19,11 @@ export async function generateMetadata({
export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
const { slug } = await params;
const post = await getBlogBySlug(slug).catch(() => null);
const post = await getPreviewOrFallback<BlogPageDetail>(
"blog.blogpage",
"body,intro,published_at",
() => getBlogBySlug(slug).catch(() => null),
);
if (!post) {
notFound();
+7 -1
View File
@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getCaseStudyBySlug } from "@/services/case.service";
import { getPreviewOrFallback } from "@/services/preview.service";
import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { CaseStudyPageDetail } from "@/types/wagtail";
interface CaseStudyDetailPageProps {
params: Promise<{ slug: string }>;
@@ -19,7 +21,11 @@ export default async function CaseStudyDetailPage({
params,
}: CaseStudyDetailPageProps) {
const { slug } = await params;
const item = await getCaseStudyBySlug(slug).catch(() => null);
const item = await getPreviewOrFallback<CaseStudyPageDetail>(
"cases.casestudypage",
"body,client_name,industry,published_at,summary",
() => getCaseStudyBySlug(slug).catch(() => null),
);
if (!item) {
notFound();
+15 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import "./globals.css";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
@@ -16,11 +17,24 @@ export const metadata: Metadata = {
description: "企业级 Headless CMS 驱动的现代化官网",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default async function RootLayout({ children }: LayoutProps<"/">) {
const { isEnabled: isPreview } = await draftMode();
return (
<html lang="zh-hans" className="h-full antialiased">
<body className="min-h-full flex flex-col font-sans">
<ReactQueryProvider>
{isPreview && (
<div className="flex items-center justify-center gap-4 bg-amber-400 px-4 py-2 text-sm font-medium text-amber-950">
<span>稿</span>
<a
href="/preview/disable"
className="underline underline-offset-2"
>
退
</a>
</div>
)}
<Header />
<main className="flex-1">{children}</main>
<Footer />
+7 -1
View File
@@ -1,13 +1,19 @@
import type { Metadata } from "next";
import { getHomePage } from "@/services/page.service";
import { getPreviewOrFallback } from "@/services/preview.service";
import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { HomePageDetail } from "@/types/wagtail";
export const metadata: Metadata = {
title: "首页",
};
export default async function Home() {
const homePage = await getHomePage().catch(() => null);
const homePage = await getPreviewOrFallback<HomePageDetail>(
"home.homepage",
"body,seo_title_override,seo_description_override",
() => getHomePage().catch(() => null),
);
if (!homePage) {
return (
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { draftMode, cookies } from "next/headers";
import {
PREVIEW_CONTENT_TYPE_COOKIE,
PREVIEW_TOKEN_COOKIE,
} from "@/services/preview.service";
/** 退出预览模式:关闭 Draft Mode 并清除预览专用 cookie,重定向回首页。 */
export async function GET(request: NextRequest) {
const draft = await draftMode();
draft.disable();
const cookieStore = await cookies();
cookieStore.delete(PREVIEW_CONTENT_TYPE_COOKIE);
cookieStore.delete(PREVIEW_TOKEN_COOKIE);
return NextResponse.redirect(new URL("/", request.url));
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { draftMode, cookies } from "next/headers";
import {
fetchPreviewPage,
resolvePreviewPath,
PREVIEW_CONTENT_TYPE_COOKIE,
PREVIEW_TOKEN_COOKIE,
} from "@/services/preview.service";
import type { WagtailPageSummary } from "@/types/wagtail";
/**
* Wagtail 编辑器点击"预览"后会重定向到此路由(详见 wagtailcms/settings/base.py
* 中 WAGTAIL_HEADLESS_PREVIEW.CLIENT_URLS)。校验 token 有效后开启 Next.js
* Draft Mode 并重定向到对应内容的前端路由,由页面组件通过 getPreviewOrFallback
* 拉取草稿数据渲染。
*/
export async function GET(request: NextRequest) {
const contentType = request.nextUrl.searchParams.get("content_type");
const token = request.nextUrl.searchParams.get("token");
if (!contentType || !token) {
return NextResponse.json(
{ error: "缺少 content_type 或 token 参数" },
{ status: 400 },
);
}
// 先校验 token 是否有效并取回 slug,未开启 Draft Mode,避免暴露无效预览。
const page = await fetchPreviewPage<WagtailPageSummary>(contentType, token);
const path = page ? resolvePreviewPath(contentType, page) : null;
if (!page || !path) {
return NextResponse.json(
{ error: "预览链接无效或已过期" },
{ status: 404 },
);
}
const draft = await draftMode();
draft.enable();
const isProduction = process.env.NODE_ENV === "production";
const cookieStore = await cookies();
const cookieOptions = {
httpOnly: true,
sameSite: "lax" as const,
secure: isProduction,
path: "/",
};
cookieStore.set(PREVIEW_CONTENT_TYPE_COOKIE, contentType, cookieOptions);
cookieStore.set(PREVIEW_TOKEN_COOKIE, token, cookieOptions);
return NextResponse.redirect(new URL(path, request.url));
}
+7 -1
View File
@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getProductBySlug } from "@/services/product.service";
import { getPreviewOrFallback } from "@/services/preview.service";
import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { ProductPageDetail } from "@/types/wagtail";
interface ProductDetailPageProps {
params: Promise<{ slug: string }>;
@@ -19,7 +21,11 @@ export default async function ProductDetailPage({
params,
}: ProductDetailPageProps) {
const { slug } = await params;
const product = await getProductBySlug(slug).catch(() => null);
const product = await getPreviewOrFallback<ProductPageDetail>(
"products.productpage",
"body,summary",
() => getProductBySlug(slug).catch(() => null),
);
if (!product) {
notFound();
+7 -1
View File
@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getSolutionBySlug } from "@/services/solution.service";
import { getPreviewOrFallback } from "@/services/preview.service";
import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { SolutionPageDetail } from "@/types/wagtail";
interface SolutionDetailPageProps {
params: Promise<{ slug: string }>;
@@ -19,7 +21,11 @@ export default async function SolutionDetailPage({
params,
}: SolutionDetailPageProps) {
const { slug } = await params;
const solution = await getSolutionBySlug(slug).catch(() => null);
const solution = await getPreviewOrFallback<SolutionPageDetail>(
"solutions.solutionpage",
"body,industry,summary",
() => getSolutionBySlug(slug).catch(() => null),
);
if (!solution) {
notFound();