Files
dusuniot/src/routes/$.tsx
T

393 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 兜底 /$.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>
);
}