v0.13.0: 前台导航修复(loader 服务函数化防客户端退化)、全站图片占位图+懒加载、favicon、分页圆点、导航 1400px 居中、缺失图片补齐脚本

This commit is contained in:
bigcode
2026-11-04 10:30:16 +08:00
parent 4fba80a216
commit 7fd9e75887
28 changed files with 599 additions and 208 deletions
+55 -36
View File
@@ -8,6 +8,7 @@
*/
import { useEffect, useState } from "react";
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout, type LayoutData } from "~/components/website/Layout";
import { Pagination } from "~/components/website/Pagination";
import { ElementorRenderer, extractElementor, type ElElement } from "~/components/elementor/Renderer";
@@ -89,9 +90,49 @@ export const Route = createFileRoute("/$")({
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
},
loaderDeps: ({ search }) => ({ page: search.page }),
// loader 本体抽为 Server Function:客户端导航时浏览器里重跑 loader,若直接调
// db/查询层(bun:sql 在客户端是 stub)会全部失败并误判 404。redirect / notFound
// 用协议标记回传,由 loader 本地抛出(保证 Router 自身语义处理)。
loader: async ({ location, deps }) => {
// 协议标记由 loader 本地抛出(redirect/notFound 不能在 createServerFn 里抛给 Router)
const r: any = await loadCatchAll({ data: { pathname: location.pathname, page: deps.page } });
if (r?.__redirect) throw redirect({ to: String(r.__redirect), href: String(r.__redirect), statusCode: (Number(r.__code) || 301) as 301 });
if (r?.__notFound) throw notFound();
return r as 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,
});
/** # 类型 兜底加载协议:redirect / notFound 标记或正常数据(CatchAllData 形状) **/
type CatchAllLoadResult = { __redirect?: string; __code?: number; __notFound?: boolean | true } & CatchAllData;
/** # 函数 兜底页数据加载(Server Function:执行于服务端,返回纯 JSON)
* 逻辑与原先 loader 相同;redirect / notFound 以协议标记返回。
*/
const loadCatchAll = createServerFn({ method: "GET" })
.validator((d: { pathname: string; page?: number }) => d)
.handler(async ({ data }): Promise<any> => {
const { pathname, page: depsPage } = data;
// 解析路径段:去首尾斜杠、过滤空段
const segments = location.pathname.split("/").filter(Boolean).map(decodeURIComponent);
const segments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
let lang = "";
if (segments.length >= 1 && isLangCode(segments[0])) {
lang = segments[0];
@@ -133,74 +174,51 @@ export const Route = createFileRoute("/$")({
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 });
return { __redirect: `/product/${last}/`, __code: 301 } as CatchAllLoadResult;
}
// 2) /product-specification/...:原站产品规格页。本地等价物是产品页;单段落产品列表
if (segments[0] === "product-specification") {
if (segments.length === 1) {
throw redirect({ to: "/collection/", href: "/collection/", statusCode: 301 as const });
return { __redirect: "/collection/", __code: 301 } as CatchAllLoadResult;
}
// 已导入的旧链规则优先(可能链向其它规格页 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 });
if (rule) return { __redirect: rule, __code: 301 } as CatchAllLoadResult;
return { __redirect: (await hasProductSlug(last).catch(() => false)) ? `/product/${last}/` : "/collection/", __code: 301 } as CatchAllLoadResult;
}
// 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 });
return { __redirect: `/collection/${child ? child.slug : segments[1]!}/`, __code: 301 } as CatchAllLoadResult;
}
// 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 });
if (target) return { __redirect: target, __code: 301 } as CatchAllLoadResult;
// 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 }));
const posts = await listPosts(depsPage || 1, 12, cat.slug).catch(() => ({ items: [], total: 0, page: depsPage || 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();
return { __notFound: true } as CatchAllLoadResult;
}
// 语言前缀首页等内容为空时的兜底
if (!content) throw notFound();
if (!content) return { __notFound: true } as CatchAllLoadResult;
// 内嵌 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,
});
return { layout, content, lang, hotProducts, archive: null, templates: embedded.templates, postsData } satisfies CatchAllLoadResult;
});
/** # 组件 分类归档页(WP /%category%/ 结构):面包屑 + 文章卡片分页列表,样式与 /blog/ 一致 */
function ArchivePage() {
@@ -294,7 +312,8 @@ function CatchAllPage() {
const data = Route.useLoaderData();
// 分类归档命中:走归档模板
if (data.archive) return <ArchivePage />;
const c = data.content;
// 加载协议保证 redirect/notFound 之后 content 必非空
const c = data.content!;
const elementor = extractElementor(c.meta);
const langLabel = data.lang ? ` (${LANG_NAMES[data.lang] || data.lang})` : "";
// Elementor 页面(原站 full-canvas 模板):通栏直出,不加面包屑/h1
+12 -1
View File
@@ -9,6 +9,7 @@ async function loader() {
/** # 函数 根路由文档组件
* 应用全局 HTML 骨架,前台与后台共用;各路由通过 head() 注入 meta/link。
* 根级注入 favicon / apple-touch-icon(与原站一致)。
* LocaleProvider 将语言注入全站(后台不使用 t(),不受影响)。
*/
function RootDocument() {
@@ -32,4 +33,14 @@ function RootDocument() {
* (取证:原站 head 无 Poppins css link、无 woff2 请求,Poppins 仅作 CSS 声明,
* 实际渲染走系统 fallback;本地同样不加载字体文件,保持两边一致回退。)
**/
export const Route = createRootRoute({ component: RootDocument, loader });
export const Route = createRootRoute({
component: RootDocument,
loader,
// 全局 head 资源:favicon / apple-touch-icon(public/ 同名文件)
head: () => ({
links: [
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
],
}),
});
+20 -12
View File
@@ -4,6 +4,7 @@
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
@@ -96,10 +97,25 @@ function BrandIcon(props: { label: string; bg: string; path: string }) {
}
export const Route = createFileRoute("/blog/$slug")({
loader: async ({ params }) => {
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: (ctx) => loadBlogPost({ data: { slug: ctx.params.slug } }),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
],
}),
component: BlogPostPage,
});
/** # 函数 文章详情数据加载(Server Function:客户端导航时由服务端执行;返回纯 JSON) */
const loadBlogPost = createServerFn({ method: "GET" })
.validator((d: { slug: string }) => d)
.handler(async ({ data }): Promise<any> => {
const slug = data.slug;
const [layout, post, latest, productMenu] = await Promise.all([
getLayoutData(),
getPublishedContent(params.slug, "post").catch(() => null),
getPublishedContent(slug, "post").catch(() => null),
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
]);
@@ -110,17 +126,9 @@ export const Route = createFileRoute("/blog/$slug")({
const meta = (post.meta ?? null) as Record<string, unknown> | null;
resolveImageVariants(meta?.elementor);
const terms = post.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy }));
const related = latest.items.filter((p) => p.slug !== params.slug).slice(0, 4);
const related = latest.items.filter((p) => p.slug !== slug).slice(0, 4);
return { layout, post: { ...post, meta, terms }, related, gwGroups };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
],
}),
component: BlogPostPage,
});
});
/** # 组件 文章详情页 */
function BlogPostPage() {
+1 -1
View File
@@ -6,7 +6,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
export const Route = createFileRoute("/blog/")({
loader: () => loadBlogArchive(1),
loader: () => loadBlogArchive({ data: 1 }),
head: () => ({
meta: [
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
+1 -1
View File
@@ -8,7 +8,7 @@ import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
export const Route = createFileRoute("/blog/page/$page")({
loader: async ({ params }) => {
const n = Number(params.page);
const data = await loadBlogArchive(Number.isFinite(n) && n > 1 ? Math.floor(n) : 1);
const data = await loadBlogArchive({ data: Number.isFinite(n) && n > 1 ? Math.floor(n) : 1 });
// 越界页真 404(原站 /blog/page/28/ 404;软 404 影响 SEO)
if (!Number.isFinite(n) || n < 1 || (data.page > 1 && data.page > Math.ceil(data.list.total / data.list.pageSize)))
throw notFound();
+47 -28
View File
@@ -4,6 +4,7 @@
* 主区:分类标题 + 产品网格 + 分页。
*/
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ProductCard } from "~/components/website/ProductCard";
import { Pagination } from "~/components/website/Pagination";
@@ -17,32 +18,8 @@ export const Route = createFileRoute("/collection/$slug")({
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
// 该版本 loader 拿不到 search,需要 loaderDeps 显式声明依赖
loaderDeps: ({ search }) => ({ search }),
loader: async ({ params, deps }) => {
const [layout, cats, filterConfig] = await Promise.all([
getLayoutData(),
getProductCategoryTree().catch(() => []),
getFilterConfig().catch(() => []),
]);
const list = await listProducts({
category: params.slug,
filters: searchToFilters(deps.search),
page: deps.search.page,
pageSize: 24,
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
// 分类名从分类树里找;找不到(无效 slug)也给出列表页(空态)
const cat = cats.find((c) => c.slug === params.slug) ?? null;
// 原站 /product-category/<slug>/ 直接渲染该分类的 Elementor 营销落地页(内容以
// 同 slug 文章形式导入;smart-gateway 这类单复数变体也试)。无落地页才走筛选列表。
let landing: Awaited<ReturnType<typeof getPublishedContent>> = null;
for (const s of [params.slug, `${params.slug}s`, params.slug.replace(/s$/, "")]) {
const row = await getPublishedContent(s, "post").catch(() => null);
if (row && extractElementor(row.meta)) {
landing = row;
break;
}
}
return { layout, cats, filterConfig, list, cat, slug: params.slug, landing };
},
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: (ctx) => loadCollectionCategory({ data: { slug: ctx.params.slug, search: ctx.deps.search } }),
head: ({ loaderData }) => ({
meta: [
{
@@ -57,17 +34,59 @@ export const Route = createFileRoute("/collection/$slug")({
component: CollectionCategoryPage,
});
/** # 函数 分类列表数据加载(Server Function:客户端 navigation 由服务端执行,返回纯 JSON) */
const loadCollectionCategory = createServerFn({ method: "GET" })
.validator((d: { slug: string; search?: unknown }) => d)
.handler(async ({ data }): Promise<any> => {
const { search: depsSearch, slug } = data;
const [layout, cats, filterConfig] = await Promise.all([
getLayoutData(),
getProductCategoryTree().catch(() => []),
getFilterConfig().catch(() => []),
]);
const list = await listProducts({
category: slug,
filters: searchToFilters((depsSearch ?? {}) as CollectionSearch),
page: (depsSearch as { page?: number } | null)?.page,
pageSize: 24,
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
// 分类名从分类树里找;找不到(无效 slug)也给出列表页(空态)
const cat = cats.find((c) => c.slug === slug) ?? null;
// 原站 /product-category/<slug>/ 直接渲染该分类的 Elementor 营销落地页(内容以
// 同 slug 文章形式导入;smart-gateway 这类单复数变体也试)。无落地页才走筛选列表。
let landing: unknown = null;
for (const s of [slug, `${slug}s`, slug.replace(/s$/, "")]) {
const row = await getPublishedContent(s, "post").catch(() => null);
if (row && extractElementor(row.meta)) {
landing = row;
break;
}
}
return { layout, cats, filterConfig, list, cat, slug, landing };
});
/** # 类型 分类树节点 */
type Cat = { id: number; name: string; slug: string; parentId: number | null; count: number };
/** # 类型 分类列表页 loader 数据(serverFn 返回 any,展示层按此收窄) */
type CollectionCategoryData = {
layout: any;
cats: Cat[];
filterConfig: { name: string; slug: string; values: { value: string; slug: string; count: number }[] }[];
list: ProductListResult;
cat: Cat | null;
slug: string;
landing: unknown;
};
/** # 组件 分类列表页 */
function CollectionCategoryPage() {
const t = useT();
const data = Route.useLoaderData();
const data = Route.useLoaderData() as CollectionCategoryData;
const search = Route.useSearch();
const router = useRouter();
const page = data.list.page;
const landingElementor = extractElementor(data.landing?.meta ?? null);
const landingElementor = extractElementor((data.landing as Record<string, unknown> | null)?.meta ?? null);
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
function toggleFilter(attrSlug: string, valueSlug: string) {
+13 -8
View File
@@ -3,6 +3,7 @@
* 有 Elementor 内容则渲染;否则标准布局:联系方式卡片 + 询盘表单。
*/
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
@@ -10,14 +11,8 @@ import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbP
import { getPublishedContent } from "~/server/data";
export const Route = createFileRoute("/contact-us")({
loader: async () => {
const [layout, page] = await Promise.all([
getLayoutData(),
getPublishedContent("contact-us", "page").catch(() => null),
]);
const meta = (page?.meta ?? null) as Record<string, unknown> | null;
return { layout, page, meta };
},
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: () => loadContactData(),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.page?.seoTitle || "Contact Us | Dusun IoT" },
@@ -27,6 +22,16 @@ export const Route = createFileRoute("/contact-us")({
component: ContactPage,
});
/** # 函数 联系页数据加载(Server Function:客户端导航时由服务端执行) */
const loadContactData = createServerFn({ method: "GET" }).handler(async (): Promise<any> => {
const [layout, page] = await Promise.all([
getLayoutData(),
getPublishedContent("contact-us", "page").catch(() => null),
]);
const meta = (page?.meta ?? null) as Record<string, unknown> | null;
return { layout, page, meta };
});
/** # 组件 联系页(原站为 full-canvas Elementor 模板:hero 通栏 + 表单卡叠压,不加 site-container) */
function ContactPage() {
const data = Route.useLoaderData();
+47 -43
View File
@@ -3,55 +3,18 @@
* 有 Elementor 内容则渲染渲染器;否则渲染后备首页(Hero + 分类卡片 + 最新产品 + 最新文章 + CTA)。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor, type ElElement } from "~/components/elementor/Renderer";
import { ProductCard } from "~/components/website/ProductCard";
import { Button } from "~/components/ui/button";
import { getHomePage, getProductCategoryTree, listProducts, listPosts, listPostsByTermLegacy, type PostCardItem } from "~/server/data";
/** # 函数 收集 Elementor 文档中 posts widget 的查询并预取文章(H18)
* posts widget(skin cards)按 settings.posts_include_term_ids(WP 术语 legacy_id)+
* cards_posts_per_page 查询最新文章,结果按 widget id 传给渲染器。
* 参数 meta 首页 contents.meta
* 返回值 widgetId → 文章卡数组的映射
*/
async function loadPostsWidgets(meta: Record<string, unknown> | null): Promise<Record<string, PostCardItem[]>> {
const out: Record<string, PostCardItem[]> = {};
if (!meta) return out;
const walk = async (els: ElElement[] | undefined) => {
for (const el of els || []) {
if (el.widgetType === "posts" && el.id) {
const st = (el.settings || {}) as Record<string, unknown>;
const termIds = Array.isArray(st.posts_include_term_ids) ? (st.posts_include_term_ids as number[]) : [];
const perPage = Number(st.cards_posts_per_page) || 5;
if (termIds.length) {
out[el.id] = await listPostsByTermLegacy(Number(termIds[0]), perPage).catch(() => []);
}
}
await walk(el.elements);
}
};
const elementor = (meta.elementor ?? null) as { elements?: ElElement[] } | ElElement[] | null;
// 文档可能为裸数组或 {elements} 包装两种形态(extractElementor 同款兼容)
await walk(Array.isArray(elementor) ? elementor : elementor?.elements);
return out;
}
import { getHomePage, getProductCategoryTree, listProducts, listPosts, listPostsByTermLegacy } from "~/server/data";
export const Route = createFileRoute("/")({
loader: async () => {
// 并行取布局 + 首页内容 + 后备数据;DB 为空时各自返回空结构(优雅降级)
const [layout, home, cats, products, posts] = await Promise.all([
getLayoutData(),
getHomePage().catch(() => null),
getProductCategoryTree().catch(() => []),
listProducts({ page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 })),
listPosts(1, 3).catch(() => ({ items: [], total: 0, page: 1, pageSize: 3 })),
]);
const meta = (home?.meta ?? null) as Record<string, unknown> | null;
// Elementor posts widget 预取(H18:左右两个 posts widget)
const postsData = await loadPostsWidgets(meta);
return { layout, home, meta, cats, products, posts, postsData };
},
// loader 本体抽为 Server Function(TanStack Start loader 是同构运行:客户端导航会在
// 浏览器再跑一遍,若直接调 db/查询层(依赖 bun:sql stub)会全部失败——页面退化为
// 空数据兜底。数据获取必须走 createServerFn,由服务端执行返回纯 JSON)
loader: async () => loadHomePage(),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.home?.seoTitle || "Dusun IoT | IoT Gateway Manufacturer & Solution Provider" },
@@ -66,6 +29,47 @@ export const Route = createFileRoute("/")({
component: HomePage,
});
/** # 函数 首页数据加载(Server Function:客户端导航时由服务端执行,返回纯 JSON)
* 内容:布局 + Elementor 首页 + 分类树 + 最新产品 + 最新文章 + posts widget 预取。
*/
const loadHomePage = createServerFn({ method: "GET" }).handler(async (): Promise<any> => {
// 并行取布局 + 首页内容 + 后备数据;DB 为空时各自返回空结构(优雅降级)
const [layout, home, cats, products, posts] = await Promise.all([
getLayoutData(),
getHomePage().catch(() => null),
getProductCategoryTree().catch(() => []),
listProducts({ page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 })),
listPosts(1, 3).catch(() => ({ items: [], total: 0, page: 1, pageSize: 3 })),
]);
const meta = (home?.meta ?? null) as Record<string, unknown> | null;
// Elementor posts widget 预取(H18:左右两个 posts widget)
const postsData = await loadPostsWidgetsHome(meta);
return { layout, home, meta, cats, products, posts, postsData };
});
/** # 函数 收集首页 Elementor 文档中 posts widget 的查询并预取文章(仅供 serverFn 内部使用) */
async function loadPostsWidgetsHome(meta: unknown): Promise<Record<string, unknown>> {
const out: Record<string, unknown> = {};
if (!meta || typeof meta !== "object") return out;
const walk = async (els: ElElement[] | undefined) => {
for (const el of els || []) {
if (el.widgetType === "posts" && el.id) {
const st = (el.settings || {}) as Record<string, unknown>;
const termIds = Array.isArray(st.posts_include_term_ids) ? (st.posts_include_term_ids as number[]) : [];
const perPage = Number(st.cards_posts_per_page) || 5;
if (termIds.length) {
out[el.id] = await listPostsByTermLegacy(Number(termIds[0]), perPage).catch(() => []);
}
}
await walk(el.elements);
}
};
const elementor = ((meta as Record<string, unknown>).elementor ?? null) as { elements?: ElElement[] } | ElElement[] | null;
// 文档可能为裸数组或 {elements} 包装两种形态(extractElementor 同款兼容)
await walk(Array.isArray(elementor) ? elementor : elementor?.elements);
return out;
}
/** # 组件 首页 */
function HomePage() {
const data = Route.useLoaderData();
+18 -10
View File
@@ -8,6 +8,7 @@
* popup 按钮 → elementor-action 触发全局询盘弹窗。
*/
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor, type ElDocument, type ElElement, type WooProductContext } from "~/components/elementor/Renderer";
import { Button } from "~/components/ui/button";
@@ -17,7 +18,22 @@ import { resolveMediaVariantUrl } from "~/utils/image-variant";
import { useT } from "~/i18n";
export const Route = createFileRoute("/product/$slug")({
loader: async ({ params }) => {
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: (ctx) => loadProduct({ data: { slug: ctx.params.slug } }),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.detail ? loaderData.detail.seoTitle || `${loaderData.detail.title} | Dusun IoT` : "Product Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.detail?.seoDescription || loaderData?.detail?.shortDescription || "IoT gateway and device product details from Dusun IoT." },
],
}),
component: ProductPage,
});
/** # 函数 产品详情页数据加载(Server Function:返回纯 JSON;slug 经 data 传入) */
const loadProduct = createServerFn({ method: "GET" })
.validator((d: { slug: string }) => d)
.handler(async ({ data }): Promise<any> => {
const params = data;
const [layout, detail, template] = await Promise.all([
getLayoutData(),
getProductDetail(params.slug).catch(() => null),
@@ -110,15 +126,7 @@ export const Route = createFileRoute("/product/$slug")({
? await loadPostsWidgetsData([...(doc as unknown as ElElement[]), ...((Array.isArray(productDoc) ? productDoc : []) as ElElement[])])
: {};
return { layout, detail: { ...detail, meta, gallery }, tpl: doc ? { doc, pageCss, woo, templates, postsData } : null };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.detail ? loaderData.detail.seoTitle || `${loaderData.detail.title} | Dusun IoT` : "Product Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.detail?.seoDescription || loaderData?.detail?.shortDescription || "IoT gateway and device product details from Dusun IoT." },
],
}),
component: ProductPage,
});
});
/** # 组件 产品详情页(模板 2741 直出) */
function ProductPage() {
+19 -11
View File
@@ -3,6 +3,7 @@
* 服务端 listProducts({q}) 全文匹配产品标题/SKU。
*/
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ProductCard } from "~/components/website/ProductCard";
import { Button } from "~/components/ui/button";
@@ -17,7 +18,22 @@ export const Route = createFileRoute("/search")({
return q ? { q } : {};
},
loaderDeps: ({ search }) => ({ q: search.q }),
loader: async ({ deps }) => {
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: async ({ deps }) => loadSearchData({ data: { q: deps.q } }),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.q ? `Search: ${loaderData.q} | Dusun IoT` : "Search | Dusun IoT" },
{ name: "description", content: "Search Dusun IoT products by keyword or SKU." },
],
}),
component: SearchPage,
});
/** # 函数 搜索页数据加载(Server Function:客户端导航时由服务端执行) */
const loadSearchData = createServerFn({ method: "GET" })
.validator((d: { q?: string }) => d)
.handler(async ({ data }): Promise<any> => {
const deps = { q: data?.q };
const layout = await getLayoutData();
const empty: ProductListResult = { items: [], total: 0, page: 1, pageSize: 48 };
if (!deps.q) return { layout, list: empty, q: deps.q };
@@ -36,15 +52,7 @@ export const Route = createFileRoute("/search")({
list = await listProducts({ q: capWords, pageSize: 48 }).catch(() => empty);
}
return { layout, list, q: deps.q };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.q ? `Search: ${loaderData.q} | Dusun IoT` : "Search | Dusun IoT" },
{ name: "description", content: "Search Dusun IoT products by keyword or SKU." },
],
}),
component: SearchPage,
});
});
/** # 组件 搜索结果页 */
function SearchPage() {
@@ -67,7 +75,7 @@ function SearchPage() {
<Button type="submit">{t("search_button")}</Button>
</form>
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{data.list.items.map((p) => (
{data.list.items.map((p: { id: number; slug: string; title: string; sku: string | null; imageUrl: string | null; imageAlt?: string | null }) => (
<ProductCard key={p.id} p={p} />
))}
</div>
+27 -13
View File
@@ -4,6 +4,7 @@
* 「IoT Products / Showing x–y of z results」+ 产品网格 + 分页。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ProductCard } from "~/components/website/ProductCard";
import { Pagination } from "~/components/website/Pagination";
@@ -15,18 +16,8 @@ import { useT } from "~/i18n";
export const Route = createFileRoute("/shop")({
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps }) => {
const [layout, filterConfig] = await Promise.all([
getLayoutData(),
getFilterConfig().catch(() => []),
]);
const list = await listProducts({
filters: searchToFilters(deps.search),
page: deps.search.page,
pageSize: 24,
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
return { layout, filterConfig, list };
},
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: async ({ deps }) => loadShopData({ data: deps.search }),
head: () => ({
meta: [
{ title: "All Products - DusunIoT" },
@@ -36,10 +27,33 @@ export const Route = createFileRoute("/shop")({
component: ShopPage,
});
/** # 函数 /shop/ 产品列表数据加载(Server Function:客户端导航时由服务端执行) */
const loadShopData = createServerFn({ method: "GET" })
.validator((d: unknown) => d)
.handler(async ({ data: search }): Promise<any> => {
const [layout, filterConfig] = await Promise.all([
getLayoutData(),
getFilterConfig().catch(() => []),
]);
const list = await listProducts({
filters: searchToFilters((search ?? {}) as CollectionSearch),
page: (search as { page?: number } | null)?.page,
pageSize: 24,
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
return { layout, filterConfig, list };
});
/** # 类型 /shop/ loader 数据(serverFn 返回 any,展示层按此收窄) */
type ShopData = {
layout: any;
filterConfig: { name: string; slug: string; values: { value: string; slug: string; count: number }[] }[];
list: ProductListResult;
};
/** # 函数 组件 /shop/ 页面 */
function ShopPage() {
const t = useT();
const data = Route.useLoaderData();
const data = Route.useLoaderData() as ShopData;
const search = Route.useSearch();
const router = useRouter();
const page = data.list.page;
+18 -11
View File
@@ -3,23 +3,15 @@
* 方案营销页(Elementor 通栏直出);无内容回退 404。
*/
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { getPublishedContent } from "~/server/data";
import { resolveImageVariants } from "~/utils/image-variant";
export const Route = createFileRoute("/solution/$slug")({
loader: async ({ params }) => {
const [layout, page] = await Promise.all([
getLayoutData(),
getPublishedContent(params.slug, "post").catch(() => null),
]);
if (!page || !extractElementor(page.meta)) return { layout, page: null };
const meta = (page.meta ?? null) as Record<string, unknown> | null;
// image widget 的 WP 尺寸变体(medium/thumbnail)解析为本地 -300x272 类文件
resolveImageVariants(meta?.elementor);
return { layout, page: { ...page, meta } };
},
// loader 本体抽为 Server Function(客户端导航在浏览器重跑 loader,db 依赖必须在服务端执行)
loader: (ctx) => loadSolutionPage({ data: { slug: ctx.params.slug } }),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.page ? loaderData.page.seoTitle || loaderData.page.title : "Solution Not Found | Dusun IoT" },
@@ -29,6 +21,21 @@ export const Route = createFileRoute("/solution/$slug")({
component: SolutionPage,
});
/** # 函数 方案页数据加载(Server Function:客户端导航时由服务端执行;返回纯 JSON) */
const loadSolutionPage = createServerFn({ method: "GET" })
.validator((d: { slug: string }) => d)
.handler(async ({ data }): Promise<any> => {
const [layout, page] = await Promise.all([
getLayoutData(),
getPublishedContent(data.slug, "post").catch(() => null),
]);
if (!page || !extractElementor(page.meta)) return { layout, page: null };
const meta = (page.meta ?? null) as Record<string, unknown> | null;
// image widget 的 WP 尺寸变体(medium/thumbnail)解析为本地 -300x272 类文件
resolveImageVariants(meta?.elementor);
return { layout, page: { ...page, meta } };
});
/** # 组件 方案落地页 */
function SolutionPage() {
const data = Route.useLoaderData();
+1 -1
View File
@@ -6,7 +6,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { SolutionArchive, loadSolutionArchive } from "~/components/website/SolutionArchive";
export const Route = createFileRoute("/solution/")({
loader: () => loadSolutionArchive(1),
loader: () => loadSolutionArchive({ data: 1 }),
head: () => ({
meta: [
{ title: "IoT Gateways & Devices Solution - DusunIoT" },
+1 -1
View File
@@ -8,7 +8,7 @@ import { SolutionArchive, loadSolutionArchive } from "~/components/website/Solut
export const Route = createFileRoute("/solution/page/$page")({
loader: async ({ params }) => {
const n = Number(params.page);
return loadSolutionArchive(Number.isFinite(n) && n > 1 ? Math.floor(n) : 1);
return loadSolutionArchive({ data: Number.isFinite(n) && n > 1 ? Math.floor(n) : 1 });
},
head: ({ loaderData }) => ({
meta: [