v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+320
View File
@@ -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>
);
}