feat: add industry demo gallery and isolated site presentation
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ApiError } from "@/services/api-client";
|
||||||
|
import { getDemoPage, getIndustryDemos } from "@/services/demo.service";
|
||||||
|
import { BlockRenderer } from "@/blocks/BlockRenderer";
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ industry: string; path?: string[] }> };
|
||||||
|
|
||||||
|
async function load(params: Props["params"]) {
|
||||||
|
const { industry, path } = await params;
|
||||||
|
try {
|
||||||
|
return await getDemoPage(industry, path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) notFound();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const page = await load(params);
|
||||||
|
return { title: { absolute: `${page.title} | ${page.label}演示` }, robots: { index: false, follow: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function IndustryDemoPage({ params }: Props) {
|
||||||
|
const [page, demos, route] = await Promise.all([load(params), getIndustryDemos(), params]);
|
||||||
|
const base = `/demo/${page.industry}`;
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
software: "border-sky-500", manufacturing: "border-orange-500", consulting: "border-emerald-500",
|
||||||
|
education: "border-violet-500", healthcare: "border-teal-500", dining: "border-rose-500",
|
||||||
|
construction: "border-amber-500", "real-estate": "border-indigo-500", "home-renovation": "border-lime-500",
|
||||||
|
"agri-food": "border-green-500", "energy-environment": "border-cyan-500", "auto-services": "border-red-500",
|
||||||
|
"culture-media": "border-fuchsia-500", "sports-fitness": "border-blue-500", "pet-services": "border-yellow-500",
|
||||||
|
"legal-services": "border-slate-700", "finance-tax": "border-purple-500", "human-resources": "border-pink-500",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={`border-t-4 ${colors[page.industry] ?? "border-slate-500"}`}>
|
||||||
|
<aside className="border-b bg-amber-50 px-6 py-3 text-center text-sm text-amber-950">
|
||||||
|
虚构行业演示 · 不代表真实客户、资质或业绩 · 请勿提交真实个人或商业敏感信息
|
||||||
|
</aside>
|
||||||
|
<header className="border-b bg-white px-6 py-5">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4">
|
||||||
|
<Link href={base} className="text-xl font-bold">{page.company}</Link>
|
||||||
|
<nav aria-label="切换行业" className="flex flex-wrap gap-3 text-sm">
|
||||||
|
{demos.items.map((item) => <Link key={item.slug} href={item.url} aria-current={item.slug === page.industry ? "page" : undefined} className={item.slug === page.industry ? "font-bold underline" : "text-slate-600"}>{item.label}</Link>)}
|
||||||
|
<Link href="/demo" className="text-slate-500">全部模板</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="行业导航" className="mx-auto mt-5 flex max-w-7xl flex-wrap gap-5 text-sm">
|
||||||
|
{page.navigation.map((item) => <Link key={item.url} href={item.url} className="text-slate-700 hover:underline">{item.title}</Link>)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{route.path?.length ? <div className="mx-auto max-w-7xl px-6 pt-10"><h1 className="text-3xl font-bold">{page.title}</h1>{page.intro && <p className="mt-4 text-slate-600">{page.intro}</p>}</div> : null}
|
||||||
|
{page.html && <div className="prose mx-auto max-w-3xl px-6 py-12" dangerouslySetInnerHTML={{ __html: page.html }} />}
|
||||||
|
<BlockRenderer blocks={page.blocks} />
|
||||||
|
{!!page.children.length && <div className="mx-auto grid max-w-7xl gap-5 px-6 py-12 md:grid-cols-2">{page.children.map((child) => <Link key={child.url} href={child.url} className="rounded-xl border border-slate-200 bg-white p-6 font-semibold hover:shadow-md">{child.title} →</Link>)}</div>}
|
||||||
|
<footer className="border-t bg-slate-50 px-6 py-8 text-center text-sm text-slate-600">
|
||||||
|
<p>{page.company} · 模板内容仅供演示</p>
|
||||||
|
<div className="mt-3 flex justify-center gap-6"><Link href={`${base}/privacy-policy`}>演示隐私说明</Link><Link href="/demo">切换模板</Link><Link href="/">返回当前官网</Link></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getIndustryDemos } from "@/services/demo.service";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: { absolute: "行业官网模板演示" }, robots: { index: false, follow: false } };
|
||||||
|
|
||||||
|
export default async function DemoGallery() {
|
||||||
|
const result = await getIndustryDemos().catch(() => null);
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-16">
|
||||||
|
<Link href="/" className="text-sm text-slate-600 underline">返回当前官网</Link>
|
||||||
|
<h1 className="mt-8 text-4xl font-bold text-slate-900">行业官网模板演示</h1>
|
||||||
|
<p className="mt-4 text-slate-600">浏览下方行业模板:独立内容,无损切换。所有企业及案例均为虚构演示。</p>
|
||||||
|
{!result?.items.length && <p className="mt-8">演示内容尚未就绪,请启动后端并运行 seed_industry_templates 初始化命令。</p>}
|
||||||
|
<div className="mt-10 grid gap-6 md:grid-cols-3">
|
||||||
|
{result?.items.map((item, index) => (
|
||||||
|
<Link key={item.slug} href={item.url} className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:-translate-y-1 hover:shadow-lg">
|
||||||
|
<span className="text-sm font-semibold text-sky-700">模板 {String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<h2 className="mt-4 text-2xl font-bold">{item.label}</h2>
|
||||||
|
<p className="mt-3 text-sm text-slate-500">{item.company}</p>
|
||||||
|
<p className="mt-4 leading-7 text-slate-600">{item.description}</p>
|
||||||
|
<span className="mt-6 inline-block font-medium text-sky-700">浏览演示 →</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-2
@@ -6,6 +6,7 @@ import Footer from "@/components/layout/Footer";
|
|||||||
import CookieConsent from "@/components/layout/CookieConsent";
|
import CookieConsent from "@/components/layout/CookieConsent";
|
||||||
import ReactQueryProvider from "@/providers/react-query-provider";
|
import ReactQueryProvider from "@/providers/react-query-provider";
|
||||||
import { getSiteSettings } from "@/services/site-settings.service";
|
import { getSiteSettings } from "@/services/site-settings.service";
|
||||||
|
import SiteChrome from "@/components/layout/SiteChrome";
|
||||||
|
|
||||||
// 面向中国大陆用户,避免依赖 next/font/google(构建期需访问 Google 服务器)。
|
// 面向中国大陆用户,避免依赖 next/font/google(构建期需访问 Google 服务器)。
|
||||||
// 生产环境建议自托管思源黑体 / HarmonyOS Sans 子集,这里先用系统字体栈占位。
|
// 生产环境建议自托管思源黑体 / HarmonyOS Sans 子集,这里先用系统字体栈占位。
|
||||||
@@ -37,9 +38,9 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Header />
|
<SiteChrome><Header /></SiteChrome>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<SiteChrome><Footer /></SiteChrome>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
</ReactQueryProvider>
|
</ReactQueryProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function SiteChrome({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname === "/demo" || pathname.startsWith("/demo/")) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { fetchAPI } from "./api-client";
|
||||||
|
import type { StreamFieldBlock } from "@/types/wagtail";
|
||||||
|
|
||||||
|
export interface IndustryDemo {
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
company: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoPage {
|
||||||
|
industry: string;
|
||||||
|
label: string;
|
||||||
|
company: string;
|
||||||
|
title: string;
|
||||||
|
blocks: StreamFieldBlock[];
|
||||||
|
html: string;
|
||||||
|
intro: string;
|
||||||
|
children: { title: string; url: string }[];
|
||||||
|
navigation: { title: string; url: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIndustryDemos() {
|
||||||
|
return fetchAPI<{ items: IndustryDemo[] }>("/api/v1/custom/core/demos/", { revalidate: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDemoPage(industry: string, segments: string[] = []) {
|
||||||
|
const path = encodeURIComponent(segments.join("/"));
|
||||||
|
return fetchAPI<DemoPage>(`/api/v1/custom/core/demos/${encodeURIComponent(industry)}/?path=${path}`, { revalidate: 0 });
|
||||||
|
}
|
||||||
@@ -75,3 +75,27 @@ test("simple content preview resolves only supported about route", () => {
|
|||||||
assert.equal(resolvePreviewPath("core.simplecontentpage", { meta: { slug: "about" } }), "/about");
|
assert.equal(resolvePreviewPath("core.simplecontentpage", { meta: { slug: "about" } }), "/about");
|
||||||
assert.equal(resolvePreviewPath("core.simplecontentpage", { meta: { slug: "unknown" } }), null);
|
assert.equal(resolvePreviewPath("core.simplecontentpage", { meta: { slug: "unknown" } }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("demo requests isolate industry paths and bypass shared caches", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const { getDemoPage } = loadModule("services/demo.service.ts", {
|
||||||
|
"./api-client": { fetchAPI: async (url, options) => { calls.push({ url, options }); return {}; } },
|
||||||
|
});
|
||||||
|
await getDemoPage("manufacturing", ["products", "precision-parts"]);
|
||||||
|
await getDemoPage("consulting", ["about"]);
|
||||||
|
assert.equal(calls[0].url, "/api/v1/custom/core/demos/manufacturing/?path=products%2Fprecision-parts");
|
||||||
|
assert.equal(calls[1].url, "/api/v1/custom/core/demos/consulting/?path=about");
|
||||||
|
assert.ok(calls.every((call) => call.options.revalidate === 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default site chrome is hidden only on demo routes", () => {
|
||||||
|
let pathname = "/demo/software/products";
|
||||||
|
const { default: SiteChrome } = loadModule("components/layout/SiteChrome.tsx", {
|
||||||
|
"next/navigation": { usePathname: () => pathname },
|
||||||
|
});
|
||||||
|
assert.equal(SiteChrome({ children: "default header" }), null);
|
||||||
|
pathname = "/demo";
|
||||||
|
assert.equal(SiteChrome({ children: "default header" }), null);
|
||||||
|
pathname = "/products";
|
||||||
|
assert.equal(SiteChrome({ children: "default header" }).props.children, "default header");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user