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:
@@ -7,13 +7,19 @@ import CTA from "./cta/CTA";
|
||||
import LogoCloud from "./logo-cloud/LogoCloud";
|
||||
import CaseStudyCard from "./case-study/CaseStudyCard";
|
||||
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";
|
||||
|
||||
/**
|
||||
* StreamField 类型 → 组件映射表。
|
||||
* 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致,
|
||||
* 否则新增/重命名 Block 时前后端会出现字段不对齐问题
|
||||
* (详见 documents/设计方案分析与完善版.md §2.6)。
|
||||
* (详见 documents/设计方案分析与完善版.md §2.6)。
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const components: Record<string, ComponentType<{ value: any }>> = {
|
||||
@@ -25,6 +31,12 @@ const components: Record<string, ComponentType<{ value: any }>> = {
|
||||
logo_cloud: LogoCloud,
|
||||
case_study: CaseStudyCard,
|
||||
form: LeadForm,
|
||||
product_card: ProductCardGrid,
|
||||
pricing: Pricing,
|
||||
timeline: Timeline,
|
||||
team: Team,
|
||||
tech_stack: TechStack,
|
||||
video: Video,
|
||||
};
|
||||
|
||||
export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,70 @@ export interface FormBlockValue {
|
||||
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 =
|
||||
| { id: string; type: "hero"; value: HeroBlockValue }
|
||||
| { id: string; type: "stats"; value: StatsBlockValue }
|
||||
@@ -112,6 +176,12 @@ export type StreamFieldBlock =
|
||||
| { id: string; type: "logo_cloud"; value: LogoCloudBlockValue }
|
||||
| { id: string; type: "case_study"; value: CaseStudyBlockValue }
|
||||
| { 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 };
|
||||
|
||||
export interface HomePageDetail extends WagtailPageSummary {
|
||||
|
||||
Reference in New Issue
Block a user