393 lines
18 KiB
TypeScript
393 lines
18 KiB
TypeScript
/**
|
||
* # 路由 兜底 /$.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, type ElElement } 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 {
|
||
getEmbeddedTemplate,
|
||
getPublishedContent,
|
||
getPostCategoryBySlug,
|
||
getProductChildCategory,
|
||
hasProductSlug,
|
||
listPosts,
|
||
listProducts,
|
||
loadPostsWidgetsData,
|
||
resolveRedirect,
|
||
type PostCardItem,
|
||
type ProductListResult,
|
||
} from "~/server/data";
|
||
import { useT } from "~/i18n";
|
||
|
||
/** # 函数 把 Elementor 文档归一化为元素数组(裸数组 / {elements} 两种形态) */
|
||
function asElements(doc: unknown): ElElement[] {
|
||
if (Array.isArray(doc)) return doc as ElElement[];
|
||
const els = (doc as { elements?: unknown } | null)?.elements;
|
||
return Array.isArray(els) ? (els as ElElement[]) : [];
|
||
}
|
||
|
||
/** # 函数 扫描文档里的 [elementor-template id="N"] 短代码并加载模板
|
||
* 参数 elements 主文档元素树
|
||
* 返回值 templates: shortcode widget id → 模板元素树;elements: 模板元素树拼接
|
||
* (供 posts widget 预取)
|
||
*/
|
||
async function loadEmbeddedTemplates(elements: ElElement[]): Promise<{ templates: Record<string, ElElement[]>; elements: ElElement[] }> {
|
||
const templates: Record<string, ElElement[]> = {};
|
||
const collected: ElElement[] = [];
|
||
const ids = new Map<number, ElElement[]>();
|
||
const walk = (els: ElElement[] | undefined) => {
|
||
for (const el of els || []) {
|
||
const st = (el.settings || {}) as Record<string, unknown>;
|
||
const shortcode = typeof st.shortcode === "string" ? st.shortcode : "";
|
||
const m = /\[elementor-template\s+id=["']?(\d+)/.exec(shortcode);
|
||
if (m && el.id) ids.set(Number(m[1]), []);
|
||
if (el.elements?.length) walk(el.elements);
|
||
}
|
||
};
|
||
walk(elements);
|
||
for (const id of ids.keys()) {
|
||
const row = await getEmbeddedTemplate(id);
|
||
const doc = asElements(row?.meta ? (row.meta as Record<string, unknown>).elementor : null);
|
||
if (doc.length) {
|
||
ids.set(id, doc);
|
||
collected.push(...doc);
|
||
}
|
||
}
|
||
// 按 widget id 回填模板(同一文档仅取第一个匹配的 widget)
|
||
const walk2 = (els: ElElement[] | undefined) => {
|
||
for (const el of els || []) {
|
||
const st = (el.settings || {}) as Record<string, unknown>;
|
||
const shortcode = typeof st.shortcode === "string" ? st.shortcode : "";
|
||
const m = /\[elementor-template\s+id=["']?(\d+)/.exec(shortcode);
|
||
if (m && el.id) {
|
||
const doc = ids.get(Number(m[1]));
|
||
if (doc?.length) templates[el.id] = doc;
|
||
}
|
||
if (el.elements?.length) walk2(el.elements);
|
||
}
|
||
};
|
||
walk2(elements);
|
||
return { templates, elements: collected };
|
||
}
|
||
|
||
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 }, templates: {}, postsData: {} } satisfies CatchAllData;
|
||
}
|
||
}
|
||
// 未命中任何内容 → 404(SSR 返回 404 状态码,由 notFoundComponent 渲染)
|
||
throw notFound();
|
||
}
|
||
// 语言前缀首页等内容为空时的兜底
|
||
if (!content) throw notFound();
|
||
|
||
// 内嵌 Elementor 模板([elementor-template id="N"])+ posts widget 数据预取
|
||
const mainEls = asElements(extractElementor(content.meta));
|
||
const embedded = await loadEmbeddedTemplates(mainEls);
|
||
const postsData = await loadPostsWidgetsData([...mainEls, ...embedded.elements]);
|
||
|
||
return { layout, content, lang, hotProducts, archive: null, templates: embedded.templates, postsData } 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}
|
||
postsData={data.postsData}
|
||
shortcodes={
|
||
Object.keys(data.templates || {}).length
|
||
? Object.fromEntries(
|
||
Object.entries(data.templates).map(([id, doc]) => [
|
||
id,
|
||
<ElementorRenderer key={id} doc={doc} postsData={data.postsData} />,
|
||
]),
|
||
)
|
||
: undefined
|
||
}
|
||
/>
|
||
<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>
|
||
);
|
||
}
|