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 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[] }) {
|
||||||
|
|||||||
@@ -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;
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user