feat: scaffold headless frontend integration per design doc
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { ComponentType } from "react";
|
||||
import Hero from "./hero/Hero";
|
||||
import Stats from "./stats/Stats";
|
||||
import FeatureGrid from "./feature-grid/FeatureGrid";
|
||||
import FAQ from "./faq/FAQ";
|
||||
import CTA from "./cta/CTA";
|
||||
import LogoCloud from "./logo-cloud/LogoCloud";
|
||||
import type { StreamFieldBlock } from "@/types/wagtail";
|
||||
|
||||
/**
|
||||
* StreamField type → 组件映射表。
|
||||
* 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致,
|
||||
* 否则新增/重命名 Block 时前后端会出现字段不对齐问题
|
||||
* (详见 documents/设计方案分析与完善版.md §2.6)。
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const components: Record<string, ComponentType<{ value: any }>> = {
|
||||
hero: Hero,
|
||||
stats: Stats,
|
||||
feature_grid: FeatureGrid,
|
||||
faq: FAQ,
|
||||
cta: CTA,
|
||||
logo_cloud: LogoCloud,
|
||||
};
|
||||
|
||||
export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) {
|
||||
return (
|
||||
<>
|
||||
{blocks?.map((block) => {
|
||||
if (block.type === "richtext") {
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className="prose mx-auto max-w-3xl px-6 py-12"
|
||||
dangerouslySetInnerHTML={{ __html: block.value }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = components[block.type];
|
||||
if (!Component) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(`未找到 Block 类型 "${block.type}" 对应的前端组件`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return <Component key={block.id} value={block.value} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import type { CTABlockValue } from "@/types/wagtail";
|
||||
|
||||
export default function CTA({ value }: { value: CTABlockValue }) {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="rounded-[40px] bg-gradient-to-r from-blue-600 to-cyan-500 p-20 text-center text-white">
|
||||
<h2 className="mb-6 text-4xl font-bold">{value.heading}</h2>
|
||||
{value.description && (
|
||||
<p className="mb-10 text-xl text-blue-100">{value.description}</p>
|
||||
)}
|
||||
<Link
|
||||
href={value.button.link}
|
||||
className="inline-block rounded-2xl bg-white px-10 py-4 text-lg font-semibold text-primary"
|
||||
>
|
||||
{value.button.text}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { FAQBlockValue } from "@/types/wagtail";
|
||||
|
||||
export default function FAQ({ value }: { value: FAQBlockValue }) {
|
||||
return (
|
||||
<section className="mx-auto max-w-4xl px-6 py-24">
|
||||
<h2 className="mb-12 text-4xl font-bold text-slate-900">常见问题</h2>
|
||||
<div className="space-y-8">
|
||||
{value.items?.map((item, index) => (
|
||||
<div key={`${item.question}-${index}`}>
|
||||
<h3 className="mb-2 text-xl font-semibold text-slate-900">
|
||||
{item.question}
|
||||
</h3>
|
||||
{/* answer 来自 Wagtail RichTextBlock,后端已做 bleach 白名单清洗 */}
|
||||
<div
|
||||
className="text-slate-600"
|
||||
dangerouslySetInnerHTML={{ __html: item.answer }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FeatureGridBlockValue } from "@/types/wagtail";
|
||||
|
||||
export default function FeatureGrid({ value }: { value: FeatureGridBlockValue }) {
|
||||
return (
|
||||
<section className="bg-slate-50 py-24">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
{value.heading && (
|
||||
<h2 className="mb-16 text-4xl font-bold text-slate-900">{value.heading}</h2>
|
||||
)}
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{value.items?.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="rounded-3xl bg-white p-8 shadow-sm"
|
||||
>
|
||||
<h3 className="mb-3 text-2xl font-semibold text-slate-900">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-slate-600">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import type { HeroBlockValue } from "@/types/wagtail";
|
||||
|
||||
export default function Hero({ value }: { value: HeroBlockValue }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-slate-50 to-white py-32">
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center px-6 text-center">
|
||||
<h1 className="mb-6 text-5xl font-bold leading-tight text-slate-900">
|
||||
{value.title}
|
||||
</h1>
|
||||
{value.subtitle && (
|
||||
<p className="mb-10 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
{value.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
{value.buttons?.map((button, index) => (
|
||||
<Link
|
||||
key={`${button.link}-${index}`}
|
||||
href={button.link}
|
||||
className="rounded-2xl bg-primary px-8 py-4 text-white"
|
||||
>
|
||||
{button.text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Image from "next/image";
|
||||
import type { LogoCloudBlockValue } from "@/types/wagtail";
|
||||
|
||||
export default function LogoCloud({ value }: { value: LogoCloudBlockValue }) {
|
||||
return (
|
||||
<section className="py-20">
|
||||
<div className="mx-auto max-w-6xl px-6 text-center">
|
||||
{value.heading && (
|
||||
<h3 className="mb-10 text-lg font-semibold text-slate-500">
|
||||
{value.heading}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-center gap-10 grayscale">
|
||||
{value.logos?.map((logo, index) => (
|
||||
<Image
|
||||
key={`${logo.url}-${index}`}
|
||||
src={logo.url}
|
||||
alt={logo.title}
|
||||
width={120}
|
||||
height={40}
|
||||
className="h-10 w-auto object-contain"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { StatsBlockValue } from "@/types/wagtail";
|
||||
|
||||
export default function Stats({ value }: { value: StatsBlockValue }) {
|
||||
return (
|
||||
<section className="py-20">
|
||||
<div className="mx-auto grid max-w-6xl grid-cols-2 gap-8 px-6 lg:grid-cols-4">
|
||||
{value.items?.map((item, index) => (
|
||||
<div
|
||||
key={`${item.label}-${index}`}
|
||||
className="rounded-3xl border border-slate-100 bg-white p-10 text-center shadow-sm"
|
||||
>
|
||||
<div className="mb-3 text-5xl font-bold text-primary">{item.value}</div>
|
||||
<div className="text-slate-600">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user