feat: 补全剩余 6 个 StreamField Block 前端渲染 (ProductCard/Pricing/Timeline/Team/TechStack/Video)

- types/wagtail.ts 新增对应 6 个 Block 值类型接口及 StreamFieldBlock 联合类型分支
- 新建 6 个渲染组件:ProductCardGrid/Pricing/Timeline/Team/TechStack/Video,风格与既有 Block 一致
- BlockRenderer.tsx 映射表接入新组件,key 与后端 COMMON_BLOCKS 保持一致
- lint/build 均验证通过
This commit is contained in:
2026-08-07 15:59:08 +08:00
parent 2457d5ec69
commit 4bbd6947da
8 changed files with 314 additions and 1 deletions
+13 -1
View File
@@ -7,13 +7,19 @@ import CTA from "./cta/CTA";
import LogoCloud from "./logo-cloud/LogoCloud"; import LogoCloud from "./logo-cloud/LogoCloud";
import CaseStudyCard from "./case-study/CaseStudyCard"; import CaseStudyCard from "./case-study/CaseStudyCard";
import LeadForm from "./form/LeadForm"; import LeadForm from "./form/LeadForm";
import ProductCardGrid from "./product-card/ProductCardGrid";
import Pricing from "./pricing/Pricing";
import Timeline from "./timeline/Timeline";
import Team from "./team/Team";
import TechStack from "./tech-stack/TechStack";
import Video from "./video/Video";
import type { StreamFieldBlock } from "@/types/wagtail"; import type { StreamFieldBlock } from "@/types/wagtail";
/** /**
* StreamField 类型 → 组件映射表。 * StreamField 类型 → 组件映射表。
* 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致, * 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致,
* 否则新增/重命名 Block 时前后端会出现字段不对齐问题 * 否则新增/重命名 Block 时前后端会出现字段不对齐问题
* 详见 documents/设计方案分析与完善版.md §2.6 * (详见 documents/设计方案分析与完善版.md §2.6)
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const components: Record<string, ComponentType<{ value: any }>> = { const components: Record<string, ComponentType<{ value: any }>> = {
@@ -25,6 +31,12 @@ const components: Record<string, ComponentType<{ value: any }>> = {
logo_cloud: LogoCloud, logo_cloud: LogoCloud,
case_study: CaseStudyCard, case_study: CaseStudyCard,
form: LeadForm, form: LeadForm,
product_card: ProductCardGrid,
pricing: Pricing,
timeline: Timeline,
team: Team,
tech_stack: TechStack,
video: Video,
}; };
export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) { export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) {
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import type { PricingBlockValue } from "@/types/wagtail";
export default function Pricing({ value }: { value: PricingBlockValue }) {
return (
<section className="bg-slate-50 py-24">
<div className="mx-auto max-w-7xl px-6">
{value.heading && (
<h2 className="mb-16 text-center text-4xl font-bold text-slate-900">
{value.heading}
</h2>
)}
<div className="grid gap-8 lg:grid-cols-3">
{value.plans?.map((plan, index) => (
<div
key={`${plan.name}-${index}`}
className={`flex flex-col rounded-3xl border p-8 shadow-sm ${
plan.highlighted
? "border-primary bg-white ring-2 ring-primary"
: "border-slate-100 bg-white"
}`}
>
<h3 className="mb-2 text-xl font-semibold text-slate-900">
{plan.name}
</h3>
<div className="mb-6 text-3xl font-bold text-primary">{plan.price}</div>
<ul className="mb-8 flex-1 space-y-3">
{plan.features?.map((feature, featureIndex) => (
<li
key={`${feature}-${featureIndex}`}
className="text-sm text-slate-600"
>
{feature}
</li>
))}
</ul>
{plan.button && (
<Link
href={plan.button.link}
className="rounded-2xl bg-primary px-6 py-3 text-center text-white"
>
{plan.button.text}
</Link>
)}
</div>
))}
</div>
</div>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import Image from "next/image";
import type { ProductCardBlockValue } from "@/types/wagtail";
export default function ProductCardGrid({ value }: { value: ProductCardBlockValue }) {
return (
<section className="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 sm:grid-cols-2 lg:grid-cols-3">
{value.items?.map((item, index) => {
const card = (
<div className="h-full rounded-3xl border border-slate-100 p-6 shadow-sm transition hover:shadow-md">
{item.image && (
<Image
src={item.image.url}
alt={item.image.title}
width={400}
height={240}
className="mb-6 h-40 w-full rounded-2xl object-cover"
/>
)}
<h3 className="mb-2 text-xl font-semibold text-slate-900">
{item.title}
</h3>
{item.description && (
<p className="text-slate-600">{item.description}</p>
)}
</div>
);
const key = `${item.title}-${index}`;
return item.link ? (
<Link key={key} href={item.link} className="block h-full">
{card}
</Link>
) : (
<div key={key}>{card}</div>
);
})}
</div>
</div>
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import Image from "next/image";
import type { TeamBlockValue } from "@/types/wagtail";
export default function Team({ value }: { value: TeamBlockValue }) {
return (
<section className="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 sm:grid-cols-2 lg:grid-cols-4">
{value.members?.map((member) => (
<div key={member.id} className="text-center">
{member.photo ? (
<Image
src={member.photo.url}
alt={member.photo.title}
width={200}
height={200}
className="mx-auto mb-4 h-40 w-40 rounded-full object-cover"
/>
) : (
<div className="mx-auto mb-4 h-40 w-40 rounded-full bg-slate-100" />
)}
<div className="text-lg font-semibold text-slate-900">
{member.name}
</div>
{member.role && (
<div className="mb-2 text-sm text-primary">{member.role}</div>
)}
{member.bio && (
<p className="text-sm text-slate-600">{member.bio}</p>
)}
</div>
))}
</div>
</div>
</section>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { TechStackBlockValue } from "@/types/wagtail";
export default function TechStack({ value }: { value: TechStackBlockValue }) {
return (
<section className="bg-slate-50 py-24">
<div className="mx-auto max-w-6xl px-6">
{value.heading && (
<h2 className="mb-16 text-center text-4xl font-bold text-slate-900">
{value.heading}
</h2>
)}
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{value.items?.map((item, index) => (
<div
key={`${item.label}-${index}`}
className="flex flex-col items-center gap-3 rounded-2xl bg-white p-6 text-center shadow-sm"
>
{item.icon && <span className="text-3xl">{item.icon}</span>}
<span className="text-sm font-medium text-slate-700">
{item.label}
</span>
</div>
))}
</div>
</div>
</section>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { TimelineBlockValue } from "@/types/wagtail";
export default function Timeline({ value }: { value: TimelineBlockValue }) {
return (
<section className="py-24">
<div className="mx-auto max-w-4xl px-6">
{value.heading && (
<h2 className="mb-16 text-4xl font-bold text-slate-900">{value.heading}</h2>
)}
<ol className="relative space-y-10 border-l border-slate-200 pl-8">
{value.items?.map((item, index) => (
<li key={`${item.year}-${index}`} className="relative">
<span className="absolute -left-[2.35rem] top-1 h-3 w-3 rounded-full bg-primary" />
<div className="mb-1 text-lg font-semibold text-primary">
{item.year}
</div>
<p className="text-slate-600">{item.event}</p>
</li>
))}
</ol>
</div>
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { VideoBlockValue } from "@/types/wagtail";
const DIRECT_VIDEO_EXTENSIONS = [".mp4", ".webm", ".ogg"];
export default function Video({ value }: { value: VideoBlockValue }) {
if (!value.video_url) {
return null;
}
const isDirectFile = DIRECT_VIDEO_EXTENSIONS.some((ext) =>
value.video_url.toLowerCase().includes(ext)
);
return (
<section className="py-24">
<div className="mx-auto max-w-5xl px-6">
{value.heading && (
<h2 className="mb-8 text-center text-4xl font-bold text-slate-900">
{value.heading}
</h2>
)}
<div className="overflow-hidden rounded-3xl bg-black shadow-lg">
{isDirectFile ? (
<video
src={value.video_url}
poster={value.poster?.url}
controls
className="aspect-video w-full"
/>
) : (
<iframe
src={value.video_url}
allow="autoplay; fullscreen"
allowFullScreen
className="aspect-video w-full"
/>
)}
</div>
</div>
</section>
);
}
+70
View File
@@ -103,6 +103,70 @@ export interface FormBlockValue {
form: FormDefinitionValue | null; form: FormDefinitionValue | null;
} }
export interface ProductCardItemValue {
title: string;
description?: string;
image?: { url: string; title: string } | null;
link?: string;
}
export interface ProductCardBlockValue {
heading?: string;
items: ProductCardItemValue[];
}
export interface PricingPlanValue {
name: string;
price: string;
features: string[];
highlighted?: boolean;
button?: CTAButtonValue | null;
}
export interface PricingBlockValue {
heading?: string;
plans: PricingPlanValue[];
}
export interface TimelineItemValue {
year: string;
event: string;
}
export interface TimelineBlockValue {
heading?: string;
items: TimelineItemValue[];
}
export interface TeamMemberBlockValue {
id: number;
name: string;
role: string;
bio: string;
photo: { url: string; title: string } | null;
}
export interface TeamBlockValue {
heading?: string;
members: TeamMemberBlockValue[];
}
export interface TechStackItemValue {
icon?: string;
label: string;
}
export interface TechStackBlockValue {
heading?: string;
items: TechStackItemValue[];
}
export interface VideoBlockValue {
heading?: string;
video_url: string;
poster?: { url: string; title: string } | null;
}
export type StreamFieldBlock = export type StreamFieldBlock =
| { id: string; type: "hero"; value: HeroBlockValue } | { id: string; type: "hero"; value: HeroBlockValue }
| { id: string; type: "stats"; value: StatsBlockValue } | { id: string; type: "stats"; value: StatsBlockValue }
@@ -112,6 +176,12 @@ export type StreamFieldBlock =
| { id: string; type: "logo_cloud"; value: LogoCloudBlockValue } | { id: string; type: "logo_cloud"; value: LogoCloudBlockValue }
| { id: string; type: "case_study"; value: CaseStudyBlockValue } | { id: string; type: "case_study"; value: CaseStudyBlockValue }
| { id: string; type: "form"; value: FormBlockValue } | { id: string; type: "form"; value: FormBlockValue }
| { id: string; type: "product_card"; value: ProductCardBlockValue }
| { id: string; type: "pricing"; value: PricingBlockValue }
| { id: string; type: "timeline"; value: TimelineBlockValue }
| { id: string; type: "team"; value: TeamBlockValue }
| { id: string; type: "tech_stack"; value: TechStackBlockValue }
| { id: string; type: "video"; value: VideoBlockValue }
| { id: string; type: "richtext"; value: string }; | { id: string; type: "richtext"; value: string };
export interface HomePageDetail extends WagtailPageSummary { export interface HomePageDetail extends WagtailPageSummary {