v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* # 路由 兜底 /$.tsx(catch-all)
|
||||
* 处理 WordPress 风格的页面/文章路径:
|
||||
* 1. 语言前缀(如 /zh-CN/xxx/):剥离语言段,渲染主语言内容占位(title 加语言标注)。
|
||||
* 2. 单段路径:按 page 取内容。
|
||||
* 3. 两段路径:先按 post(/%category%/%postname%/)取,再按 page。
|
||||
* 4. 都没有 → 404 NotFound(导航 + 热门产品链接)。
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout, type LayoutData } from "~/components/website/Layout";
|
||||
import { Pagination } from "~/components/website/Pagination";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { ProductCard } from "~/components/website/ProductCard";
|
||||
import { isLangCode, LANG_NAMES, pickContent, type CatchAllData } from "~/components/website/catchall-shared";
|
||||
import {
|
||||
getPublishedContent,
|
||||
getPostCategoryBySlug,
|
||||
getProductChildCategory,
|
||||
hasProductSlug,
|
||||
listPosts,
|
||||
listProducts,
|
||||
resolveRedirect,
|
||||
type ProductListResult,
|
||||
} from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
// 归档分页用 ?page=N
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||
const page = Number(search.page);
|
||||
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||
loader: async ({ location, deps }) => {
|
||||
// 解析路径段:去首尾斜杠、过滤空段
|
||||
const segments = location.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
||||
let lang = "";
|
||||
if (segments.length >= 1 && isLangCode(segments[0])) {
|
||||
lang = segments[0];
|
||||
segments.shift();
|
||||
}
|
||||
const layout = await getLayoutData();
|
||||
const hotProducts: ProductListResult = await listProducts({ sort: "views", pageSize: 4 }).catch(
|
||||
() => ({ items: [], total: 0, page: 1, pageSize: 4 }) as ProductListResult,
|
||||
);
|
||||
|
||||
// 语言占位阶段:内容库暂无翻译,取主语言内容渲染(title 加语言标注)
|
||||
let content: CatchAllData["content"] = null;
|
||||
if (segments.length === 1) {
|
||||
// WP 站点文章(post)与页面(page)都可能是单段路径(见原站 sitemap),两类都试
|
||||
const page = await getPublishedContent(segments[0], "page").catch(() => null);
|
||||
if (page) content = pickContent(page);
|
||||
else {
|
||||
const post = await getPublishedContent(segments[0], "post").catch(() => null);
|
||||
if (post) content = pickContent(post);
|
||||
}
|
||||
} else if (segments.length >= 2) {
|
||||
// WP 路径结构 /%category%/.../%postname%/:末段即内容 slug(如 /blog/gateway/<slug>/)
|
||||
const slug = segments[segments.length - 1];
|
||||
// 先匹配文章再匹配页面
|
||||
const post = await getPublishedContent(slug, "post").catch(() => null);
|
||||
if (post) content = pickContent(post);
|
||||
else {
|
||||
const page = await getPublishedContent(slug, "page").catch(() => null);
|
||||
if (page) content = pickContent(page);
|
||||
}
|
||||
} else if (segments.length === 0) {
|
||||
// 语言前缀首页
|
||||
const home = await getPublishedContent("home", "page").catch(() => null);
|
||||
if (home) content = pickContent(home);
|
||||
}
|
||||
|
||||
// 未命中内容时:先按 WP 结构化旧路径改判,再查 redirects 表,最后分类归档
|
||||
if (!content && segments.length >= 1) {
|
||||
const last = segments[segments.length - 1]!;
|
||||
// 1) /product/product/<slug>/:导入落地页里的重复前缀 → /product/<slug>/(仅当产品存在)
|
||||
if (segments.length >= 3 && segments[0] === "product" && segments[1] === "product" && (await hasProductSlug(last).catch(() => false))) {
|
||||
const to = `/product/${last}/`;
|
||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||
}
|
||||
// 2) /product-specification/...:原站产品规格页。本地等价物是产品页;单段落产品列表
|
||||
if (segments[0] === "product-specification") {
|
||||
if (segments.length === 1) {
|
||||
throw redirect({ to: "/collection/", href: "/collection/", statusCode: 301 as const });
|
||||
}
|
||||
// 已导入的旧链规则优先(可能链向其它规格页 slug),无规则再按 slug 找产品,最后落产品列表
|
||||
const rule = await resolveRedirect("/" + segments.join("/"))
|
||||
.then((r) => r?.target)
|
||||
.catch(() => null);
|
||||
if (rule) throw redirect({ to: rule, href: rule, statusCode: 301 as const });
|
||||
const to = (await hasProductSlug(last).catch(() => false)) ? `/product/${last}/` : "/collection/";
|
||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||
}
|
||||
// 3) /collection/<parent>/<child>/:嵌套产品分类路径 → 子分类页(无该子分类则父分类兜底)
|
||||
if (segments[0] === "collection" && segments.length === 3) {
|
||||
const child = await getProductChildCategory(segments[1]!, segments[2]!).catch(() => null);
|
||||
const to = `/collection/${child ? child.slug : segments[1]!}/`;
|
||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||
}
|
||||
// 4) 数据库 redirects 表规则(链式解析并跳过自指规则防死循环)
|
||||
const target = await resolveRedirect("/" + segments.join("/"))
|
||||
.then((r) => r?.target)
|
||||
.catch(() => null);
|
||||
if (target) throw redirect({ to: target, href: target, statusCode: 301 as const });
|
||||
// 5) WP /%category%/ 结构:单段/双段路径末段命中文章分类 → 分类归档页
|
||||
if (segments.length <= 2) {
|
||||
const cat = await getPostCategoryBySlug(last).catch(() => null);
|
||||
if (cat) {
|
||||
const posts = await listPosts(deps.page || 1, 12, cat.slug).catch(() => ({ items: [], total: 0, page: deps.page || 1, pageSize: 12 }));
|
||||
return { layout, content: null, lang, hotProducts, archive: { name: cat.name, slug: cat.slug, posts } } satisfies CatchAllData;
|
||||
}
|
||||
}
|
||||
// 未命中任何内容 → 404(SSR 返回 404 状态码,由 notFoundComponent 渲染)
|
||||
throw notFound();
|
||||
}
|
||||
// 语言前缀首页等内容为空时的兜底
|
||||
if (!content) throw notFound();
|
||||
|
||||
return { layout, content, lang, hotProducts, archive: null } satisfies CatchAllData;
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
const langLabel = loaderData?.lang ? ` (${LANG_NAMES[loaderData.lang] || loaderData.lang})` : "";
|
||||
if (loaderData?.archive) {
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${loaderData.archive.name} | Dusun IoT` },
|
||||
{ name: "description", content: `Browse ${loaderData.archive.name} articles from Dusun IoT.` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
meta: [
|
||||
{ title: loaderData?.content ? (loaderData.content.seoTitle || loaderData.content.title) + langLabel : "Page Not Found | Dusun IoT" },
|
||||
{ name: "description", content: loaderData?.content?.seoDescription || loaderData?.content?.excerpt || "Dusun IoT - IoT gateway manufacturer and solution provider." },
|
||||
],
|
||||
};
|
||||
},
|
||||
component: CatchAllPage,
|
||||
notFoundComponent: NotFoundShell,
|
||||
});
|
||||
|
||||
/** # 组件 分类归档页(WP /%category%/ 结构):面包屑 + 文章卡片分页列表,样式与 /blog/ 一致 */
|
||||
function ArchivePage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
const a = data.archive!;
|
||||
const page = a.posts.page;
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="bg-[#f5f5f6] px-4 pt-[34px] pb-[30px] text-center">
|
||||
<h1 className="m-0 text-[34px] tracking-wide">{a.name.toUpperCase()}</h1>
|
||||
</div>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{a.name}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 gap-5 py-6 min-[960px]:grid-cols-3">
|
||||
{a.posts.items.map((post) => (
|
||||
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||
{/* 与 /blog/ 卡片同构:无特色图时灰色渐变占位 + 右上角分类徽章 */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt={post.title} loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]" />
|
||||
)}
|
||||
<span className="absolute top-3 right-3 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-white">
|
||||
{post.categoryName || a.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||
{post.excerpt ? <p className="my-1.5 text-sm text-muted-foreground">{post.excerpt.slice(0, 140)}…</p> : null}
|
||||
<div className="text-[13px] text-muted-foreground">
|
||||
{post.authorName ? `${post.authorName} · ` : ""}
|
||||
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||
</div>
|
||||
<div className="mt-2.5 text-[13px] font-bold">{t("blog_read_more")}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{a.posts.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("blog_no_posts")}</p> : null}
|
||||
</div>
|
||||
<Pagination page={page} total={a.posts.total} pageSize={a.posts.pageSize} makeHref={(p) => `?page=${p}`} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 404 外壳:先渲染静态 404,再客户端补齐布局与热门产品(loader 已抛 notFound,无 loaderData) */
|
||||
function NotFoundShell() {
|
||||
const t = useT();
|
||||
const [state, setState] = useState<{ layout: LayoutData | null; hot: ProductListResult | null }>({ layout: null, hot: null });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([getLayoutData(), listProducts({ sort: "views", pageSize: 4 }).catch(() => null)])
|
||||
.then(([layout, hot]) => {
|
||||
if (alive) setState({ layout, hot });
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
if (state.layout && state.hot) {
|
||||
return (
|
||||
<Layout layout={state.layout}>
|
||||
<NotFoundView hotProducts={state.hot} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="site-container pt-[70px] pb-10 text-center">
|
||||
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||
<p className="text-lg">{t("nf_msg")}</p>
|
||||
<div className="mx-0 my-[18px] mb-9 flex flex-wrap justify-center gap-2.5">
|
||||
<Button asChild><a href="/">Home</a></Button>
|
||||
<Button asChild variant="outline"><a href="/collection/">{t("nav_all_products")}</a></Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 兜底页面(内容模板) */
|
||||
function CatchAllPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
// 分类归档命中:走归档模板
|
||||
if (data.archive) return <ArchivePage />;
|
||||
const c = data.content;
|
||||
const elementor = extractElementor(c.meta);
|
||||
const langLabel = data.lang ? ` (${LANG_NAMES[data.lang] || data.lang})` : "";
|
||||
// Elementor 页面(原站 full-canvas 模板):通栏直出,不加面包屑/h1
|
||||
if (elementor) {
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
{data.lang ? (
|
||||
<div className="site-container">
|
||||
<div className="mt-4 rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||
You are viewing the {langLabel} placeholder version. Translated content is coming soon — the English content is shown below.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ElementorRenderer doc={elementor} />
|
||||
<section className="mx-auto mt-10 mb-14 max-w-[860px] px-4">
|
||||
<InquiryForm formKey="contact-us-form" productInterest={c.type === "post" ? "" : c.title} title="Get in Touch" />
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{c.title}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{data.lang ? (
|
||||
<div className="mb-4 rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||
You are viewing the {langLabel} placeholder version. Translated content is coming soon — the English content is shown below.
|
||||
</div>
|
||||
) : null}
|
||||
<article className="mx-auto max-w-[1080px]">
|
||||
<h1 className="text-[28px]">
|
||||
{c.title}
|
||||
{langLabel}
|
||||
</h1>
|
||||
{elementor ? (
|
||||
<ElementorRenderer doc={elementor} />
|
||||
) : c.html ? (
|
||||
<div className="content-html" dangerouslySetInnerHTML={{ __html: c.html }} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">{c.excerpt || "This page is being imported. Content will appear here soon."}</p>
|
||||
)}
|
||||
</article>
|
||||
<section className="my-8 mb-14">
|
||||
<InquiryForm formKey="contact-us-form" productInterest={c.type === "post" ? "" : c.title} title="Get in Touch" />
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 404 NotFound:导航 + 热门产品链接 */
|
||||
function NotFoundView(props: { hotProducts: ProductListResult }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="site-container pt-[70px] pb-10 text-center">
|
||||
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||
<p className="text-lg">{t("nf_msg")}</p>
|
||||
<div className="mx-0 my-[18px] mb-9 flex flex-wrap justify-center gap-2.5">
|
||||
<Button asChild><a href="/">Home</a></Button>
|
||||
<Button asChild variant="outline"><a href="/collection/">{t("nav_all_products")}</a></Button>
|
||||
<Button asChild variant="outline"><a href="/blog/">{t("nav_blog")}</a></Button>
|
||||
<Button asChild variant="outline"><a href="/contact-us/">{t("nf_contact")}</a></Button>
|
||||
</div>
|
||||
{props.hotProducts.items.length > 0 ? (
|
||||
<>
|
||||
<h2 className="text-[22px]">Popular Products</h2>
|
||||
<div className="mt-5 grid grid-cols-2 gap-[18px] text-left min-[960px]:grid-cols-4">
|
||||
{props.hotProducts.items.map((p) => (
|
||||
<ProductCard key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user