From b5fe5e13ab9d69e82752f64fceaf5c4019386277 Mon Sep 17 00:00:00 2001 From: Zhengen TANG Date: Thu, 6 Aug 2026 16:04:43 +0800 Subject: [PATCH] feat: add products/cases pages and lead-capture form block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types: Product/CaseStudy page types, CaseStudyBlockValue, FormBlockValue - services: product.service.ts, case.service.ts - app/products, app/cases: list + detail pages - blocks: CaseStudyCard, LeadForm (submits to /api/v1/custom/leads/) - Header nav: add 产品/案例 links --- app/cases/[slug]/page.tsx | 38 ++++++++++ app/cases/page.tsx | 39 ++++++++++ app/products/[slug]/page.tsx | 39 ++++++++++ app/products/page.tsx | 35 +++++++++ blocks/BlockRenderer.tsx | 6 +- blocks/case-study/CaseStudyCard.tsx | 24 +++++++ blocks/form/LeadForm.tsx | 108 ++++++++++++++++++++++++++++ components/layout/Header.tsx | 2 + services/case.service.ts | 26 +++++++ services/product.service.ts | 26 +++++++ types/wagtail.ts | 49 +++++++++++++ 11 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 app/cases/[slug]/page.tsx create mode 100644 app/cases/page.tsx create mode 100644 app/products/[slug]/page.tsx create mode 100644 app/products/page.tsx create mode 100644 blocks/case-study/CaseStudyCard.tsx create mode 100644 blocks/form/LeadForm.tsx create mode 100644 services/case.service.ts create mode 100644 services/product.service.ts diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx new file mode 100644 index 0000000..70d00e6 --- /dev/null +++ b/app/cases/[slug]/page.tsx @@ -0,0 +1,38 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { getCaseStudyBySlug } from "@/services/case.service"; +import { BlockRenderer } from "@/blocks/BlockRenderer"; + +interface CaseStudyDetailPageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ + params, +}: CaseStudyDetailPageProps): Promise { + const { slug } = await params; + const item = await getCaseStudyBySlug(slug).catch(() => null); + return { title: item?.title ?? "案例未找到" }; +} + +export default async function CaseStudyDetailPage({ + params, +}: CaseStudyDetailPageProps) { + const { slug } = await params; + const item = await getCaseStudyBySlug(slug).catch(() => null); + + if (!item) { + notFound(); + } + + return ( +
+

{item.title}

+

+ {item.client_name} + {item.industry ? ` · ${item.industry}` : ""} +

+ +
+ ); +} diff --git a/app/cases/page.tsx b/app/cases/page.tsx new file mode 100644 index 0000000..5013815 --- /dev/null +++ b/app/cases/page.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { getCaseStudyList } from "@/services/case.service"; + +export const metadata: Metadata = { + title: "客户案例", +}; + +export default async function CaseStudyListPage() { + const caseStudies = await getCaseStudyList().catch(() => []); + + return ( +
+

客户案例

+
    + {caseStudies.map((item) => ( +
  • + + {item.title} + +

    + {item.client_name} + {item.industry ? ` · ${item.industry}` : ""} +

    + {item.summary && ( +

    {item.summary}

    + )} +
  • + ))} + {caseStudies.length === 0 && ( +

    暂无案例,或后端服务未连接。

    + )} +
+
+ ); +} diff --git a/app/products/[slug]/page.tsx b/app/products/[slug]/page.tsx new file mode 100644 index 0000000..ac7cc81 --- /dev/null +++ b/app/products/[slug]/page.tsx @@ -0,0 +1,39 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { getProductBySlug } from "@/services/product.service"; +import { BlockRenderer } from "@/blocks/BlockRenderer"; + +interface ProductDetailPageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ + params, +}: ProductDetailPageProps): Promise { + const { slug } = await params; + const product = await getProductBySlug(slug).catch(() => null); + return { title: product?.title ?? "产品未找到" }; +} + +export default async function ProductDetailPage({ + params, +}: ProductDetailPageProps) { + const { slug } = await params; + const product = await getProductBySlug(slug).catch(() => null); + + if (!product) { + notFound(); + } + + return ( +
+

+ {product.title} +

+ {product.summary && ( +

{product.summary}

+ )} + +
+ ); +} diff --git a/app/products/page.tsx b/app/products/page.tsx new file mode 100644 index 0000000..78ead88 --- /dev/null +++ b/app/products/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { getProductList } from "@/services/product.service"; + +export const metadata: Metadata = { + title: "产品", +}; + +export default async function ProductListPage() { + const products = await getProductList().catch(() => []); + + return ( +
+

产品

+
    + {products.map((product) => ( +
  • + + {product.title} + + {product.summary && ( +

    {product.summary}

    + )} +
  • + ))} + {products.length === 0 && ( +

    暂无产品,或后端服务未连接。

    + )} +
+
+ ); +} diff --git a/blocks/BlockRenderer.tsx b/blocks/BlockRenderer.tsx index 0effc08..32d05d4 100644 --- a/blocks/BlockRenderer.tsx +++ b/blocks/BlockRenderer.tsx @@ -5,10 +5,12 @@ import FeatureGrid from "./feature-grid/FeatureGrid"; import FAQ from "./faq/FAQ"; import CTA from "./cta/CTA"; import LogoCloud from "./logo-cloud/LogoCloud"; +import CaseStudyCard from "./case-study/CaseStudyCard"; +import LeadForm from "./form/LeadForm"; import type { StreamFieldBlock } from "@/types/wagtail"; /** - * StreamField type → 组件映射表。 + * StreamField 类型 → 组件映射表。 * 必须与后端 apps/core/blocks.py 的 COMMON_BLOCKS 保持一致, * 否则新增/重命名 Block 时前后端会出现字段不对齐问题 * (详见 documents/设计方案分析与完善版.md §2.6)。 @@ -21,6 +23,8 @@ const components: Record> = { faq: FAQ, cta: CTA, logo_cloud: LogoCloud, + case_study: CaseStudyCard, + form: LeadForm, }; export function BlockRenderer({ blocks }: { blocks: StreamFieldBlock[] }) { diff --git a/blocks/case-study/CaseStudyCard.tsx b/blocks/case-study/CaseStudyCard.tsx new file mode 100644 index 0000000..d066c2f --- /dev/null +++ b/blocks/case-study/CaseStudyCard.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import type { CaseStudyBlockValue } from "@/types/wagtail"; + +export default function CaseStudyCard({ value }: { value: CaseStudyBlockValue }) { + if (!value.case_page) { + return null; + } + + return ( +
+ +

+ {value.case_page.title} +

+ {value.summary && ( +

{value.summary}

+ )} + +
+ ); +} diff --git a/blocks/form/LeadForm.tsx b/blocks/form/LeadForm.tsx new file mode 100644 index 0000000..551630b --- /dev/null +++ b/blocks/form/LeadForm.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import type { FormBlockValue } from "@/types/wagtail"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; + +export default function LeadForm({ value }: { value: FormBlockValue }) { + const [values, setValues] = useState>({}); + const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">( + "idle", + ); + + const form = value.form; + if (!form) { + return null; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setStatus("submitting"); + try { + const res = await fetch(`${API_BASE_URL}/api/v1/custom/leads/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + form_id: form!.id, + data: values, + source_url: typeof window !== "undefined" ? window.location.href : "", + }), + }); + if (!res.ok) throw new Error("submit failed"); + setStatus("done"); + } catch { + setStatus("error"); + } + } + + if (status === "done") { + return ( +
+ {form.success_message} +
+ ); + } + + return ( +
+

{form.name}

+ {form.fields.map((field) => ( +
+ + {field.field_type === "textarea" ? ( +