v0.13.0: 前台导航修复(loader 服务函数化防客户端退化)、全站图片占位图+懒加载、favicon、分页圆点、导航 1400px 居中、缺失图片补齐脚本
This commit is contained in:
@@ -11,14 +11,14 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="Pagination" className={cn("mx-0 my-8 flex flex-wrap justify-center gap-1.5", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** # 组件 分页内容列表 */
|
||||
/** # 组件 分页内容列表(list-none:全局未重置列表符号,需显式去圆点) */
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return <ul className={cn("flex flex-wrap items-center gap-1.5", className)} {...props} />;
|
||||
return <ul className={cn("flex list-none flex-wrap items-center gap-1.5 p-0 m-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** # 组件 分页项 */
|
||||
function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return <li className={cn("", className)} {...props} />;
|
||||
return <li className={cn("list-none", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** # 类型 分页链接属性(href 由调用方生成,isActive 标记当前页) **/
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* - 卡片阴影/hover 与 solution 一致;分页同款(当前页 #FFA11B)
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { listPosts } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
@@ -30,14 +31,18 @@ export type BlogArchiveData = {
|
||||
page: number;
|
||||
};
|
||||
|
||||
/** # 函数 加载博客归档数据(layout + 分页列表;原站 /blog/ 为 category-blog 归档) **/
|
||||
export async function loadBlogArchive(page: number) {
|
||||
const [layout, list] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listPosts(page, 12, "blog").catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
]);
|
||||
return { layout, list, page };
|
||||
}
|
||||
/** # 函数 加载博客归档数据(Server Function:loader 是同构运行,客户端导航在浏览器重跑
|
||||
* loader 时 db 依赖必须由服务端执行;调用处用 { data: page }。原站 /blog/ 为 category-blog 归档) */
|
||||
export const loadBlogArchive = createServerFn({ method: "GET" })
|
||||
.validator((d: number) => d)
|
||||
.handler(async ({ data: page }: { data: number }): Promise<any> => {
|
||||
const n = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const [layout, list] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listPosts(n, 12, "blog").catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
]);
|
||||
return { layout, list, page: n };
|
||||
});
|
||||
|
||||
/** # 函数 日期长格式(原站 November 2, 2026) **/
|
||||
function fmtDate(d: Date | string | null): string {
|
||||
|
||||
@@ -119,10 +119,10 @@ export function Header(props: { menu: MenuNode[] }) {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-[100] bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
|
||||
{/* topbar:口号 + 社交图标(原站实测 2026-11-03:高 36px、容器 1350、文字 16px/16、
|
||||
图标 36×36 盒 gap 10、顺序 Linkedin/Youtube/Facebook、无登录项;<768 隐藏) */}
|
||||
{/* topbar:口号 + 社交图标(原站实测 2026-11-04 用户指定:容器 1400 居中、
|
||||
高 36px、图标 36×36 盒 gap 10、顺序 Linkedin/Youtube/Facebook、无登录项;<768 隐藏) */}
|
||||
<div className="hidden bg-footer text-white min-[768px]:block">
|
||||
<div className="mx-auto flex h-9 w-full max-w-[1350px] items-center justify-between">
|
||||
<div className="mx-auto flex h-9 w-full max-w-[1400px] items-center justify-between px-4 min-[960px]:px-0">
|
||||
<a href="/landing-pages/oem-odm/" className="text-[16px] leading-4 text-white hover:text-[#f5b278]">
|
||||
{t("topbar_oem")}
|
||||
</a>
|
||||
@@ -140,10 +140,10 @@ export function Header(props: { menu: MenuNode[] }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主导航(原站实测 2026-11-03:桌面 68px 容器 40/25、logo 后 80px 起菜单、项 pad 8/6 项距 6;
|
||||
{/* 主导航(原站 2026-11-04 用户指定:导航容器 1400px 居中;桌面 68px、logo 后 80px 起菜单;
|
||||
平板 768-959 为汉堡导航(顶栏 36 + 导航 48,logo 缩放,右侧 CONTACT US+邮件);
|
||||
手机 <768 无顶栏、导航 48、汉堡在 logo 右 96px、右侧橙色气泡+邮件) */}
|
||||
<div className="flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:pl-10 min-[960px]:pr-[25px]">
|
||||
<div className="mx-auto flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:max-w-[1400px] min-[960px]:px-10">
|
||||
{/* 直接复用原站 header logo(Elementor site-logo 同一图片,实测渲染 171×42.75px) */}
|
||||
<Link to="/" className="shrink-0">
|
||||
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="block h-auto w-[109px] min-[768px]:w-[103px] min-[880px]:w-[124px] min-[960px]:w-[171px]" />
|
||||
|
||||
@@ -39,15 +39,41 @@ export const getLayoutData = createServerFn({ method: "GET" }).handler(async ():
|
||||
return { menu, footerMenu, categories: cats, footerDoc } satisfies LayoutData;
|
||||
});
|
||||
|
||||
/** # 常量 全站图片懒加载脚本(SSR 直出的 HTML 里 WP 图片多数已带 loading=lazy;
|
||||
* 专管手工组件/用户编辑图片不带 lazy 的情况):
|
||||
* - 未带 loading 属性的 <img> 全部补 loading="lazy" + decoding="async"
|
||||
* - MutationObserver 覆盖后续新增节点
|
||||
* - 加载失败兜底占位图(CSS 灰底占位背景在 img 未加载时可见)
|
||||
*/
|
||||
const LAZY_IMG_SCRIPT = `(function(){
|
||||
var fix = function(root){
|
||||
root.querySelectorAll && root.querySelectorAll('img').forEach(function(img){
|
||||
if (img.hasAttribute('loading') || img.closest('header')) return;
|
||||
img.setAttribute('loading','lazy');
|
||||
if (!img.hasAttribute('decoding')) img.setAttribute('decoding','async');
|
||||
});
|
||||
};
|
||||
var boot = function(){
|
||||
fix(document);
|
||||
new MutationObserver(function(muts){
|
||||
for (var m of muts) for (var n of m.addedNodes) if (n.nodeType===1) fix(n);
|
||||
}).observe(document.body,{childList:true,subtree:true});
|
||||
};
|
||||
document.readyState==='loading' ? document.addEventListener('DOMContentLoaded',boot) : boot();
|
||||
})();`;
|
||||
|
||||
/** # 类型 Layout 属性 */
|
||||
export function Layout(props: { layout: LayoutData; children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Header menu={props.layout.menu} />
|
||||
<main>{props.children}</main>
|
||||
{/* ds-site 前台标识:全局 img 占位图/懒加载样式按此作用域(不影响后台) */}
|
||||
<main className="ds-site">{props.children}</main>
|
||||
<Footer categories={props.layout.categories} menu={props.layout.footerMenu} doc={props.layout.footerDoc} />
|
||||
{/* 全局询价弹窗:接管页面内所有 Elementor popup 1129 链接(Make an Inquiry / 按钮) */}
|
||||
<InquiryPopup />
|
||||
{/* 全站图片懒加载兜底脚本(绕过 React 水合,直接 setAttribute) */}
|
||||
<script dangerouslySetInnerHTML={{ __html: LAZY_IMG_SCRIPT }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* - 分页:居中,页码 margin 0 10px,当前页 #FFA11B,当前页/端点为 span 其余为 a
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { listSolutionLandings } from "~/server/data";
|
||||
import { pageWindow } from "~/components/website/BlogArchive";
|
||||
@@ -20,14 +21,19 @@ import { useT } from "~/i18n";
|
||||
/** # 类型 方案归档页 props **/
|
||||
export type SolutionArchiveProps = { page: number };
|
||||
|
||||
/** # 函数 加载方案归档数据(layout + 分页列表) **/
|
||||
export async function loadSolutionArchive(page: number) {
|
||||
const [layout, list] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listSolutionLandings(page, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
]);
|
||||
return { layout, list, page };
|
||||
}
|
||||
/** # 函数 加载方案归档数据(Server Function:loader 是同构运行,客户端导航在浏览器重跑
|
||||
* loader 时 db 依赖必须由服务端执行;这里以 serverFn 承载,调用处用 { data: page })
|
||||
* 返回值 layout + 分页列表
|
||||
**/
|
||||
export const loadSolutionArchive = createServerFn({ method: "GET" })
|
||||
.validator((d: number) => d)
|
||||
.handler(async ({ data: page }: { data: number }): Promise<any> => {
|
||||
const [layout, list] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listSolutionLandings(Number.isFinite(page) && page > 0 ? Math.floor(page) : 1, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
]);
|
||||
return { layout, list, page: Number.isFinite(page) && page > 0 ? Math.floor(page) : 1 };
|
||||
});
|
||||
|
||||
/** # 常量 摘录显示词数上限(原站 archive-posts excerpt_length,取证:12 张卡片全部 ≤24 词、DB 全文更长) **/
|
||||
const EXCERPT_WORDS = 24;
|
||||
|
||||
+55
-36
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+45
-4
@@ -390,6 +390,17 @@ function firstUploadedImage(meta: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** # 函数 从 WP 导出 HTML 里提取第一张 <img> 的 src(部分文章 meta.elementor 为空、
|
||||
* 只有 html 富文本,列表图回退必须扫正文)
|
||||
* 参数 html 内容 HTML
|
||||
* 返回值 图片 URL(含原站绝对地址,展示层统一改写)或 null
|
||||
*/
|
||||
function firstHtmlImage(html: string | null): string | null {
|
||||
if (!html) return null;
|
||||
const m = /<img[^>]*\ssrc=["']([^"']+)["']/i.exec(html);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** # 类型 博客列表项(列表卡片 + 文章页侧栏/相关文章复用) */
|
||||
export type PostListItem = Awaited<ReturnType<typeof listPosts>>["items"][number];
|
||||
|
||||
@@ -439,8 +450,23 @@ export async function listSolutionLandings(page = 1, pageSize = 12) {
|
||||
.orderBy(desc(contents.publishedAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
const items = rows.map((r) => ({ ...r, title: decodeHtmlEntities(r.title), excerpt: r.excerpt ?? "" }));
|
||||
// 无特色图:meta(elementor 文档/HTML)→ 正文富文本提取第一张图作列表图回退
|
||||
const needFallback = items.filter((it) => !it.imageUrl);
|
||||
if (needFallback.length) {
|
||||
const metas = await db
|
||||
.select({ id: contents.id, meta: contents.meta, html: contents.html })
|
||||
.from(contents)
|
||||
.where(inArray(contents.id, needFallback.map((it) => it.id)));
|
||||
const firstImg = new Map<number, string>();
|
||||
for (const m of metas) {
|
||||
const url = firstUploadedImage(m.meta) ?? firstHtmlImage(m.html);
|
||||
if (url) firstImg.set(m.id, url);
|
||||
}
|
||||
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
|
||||
}
|
||||
return {
|
||||
items: rows.map((r) => ({ ...r, title: decodeHtmlEntities(r.title), excerpt: r.excerpt ?? "" })),
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -476,16 +502,16 @@ export async function listPosts(page = 1, pageSize = 12, categorySlug?: string)
|
||||
.orderBy(desc(contents.publishedAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
// 无特色图的文章:从 elementor 内容里提取第一张上传图片作列表图回退
|
||||
// 无特色图的文章:meta(elementor 文档/HTML)→ 正文富文本逐层提取第一张图片作列表图回退
|
||||
const needFallback = items.filter((it) => !it.imageUrl);
|
||||
if (needFallback.length) {
|
||||
const metas = await db
|
||||
.select({ id: contents.id, meta: contents.meta })
|
||||
.select({ id: contents.id, meta: contents.meta, html: contents.html })
|
||||
.from(contents)
|
||||
.where(inArray(contents.id, needFallback.map((it) => it.id)));
|
||||
const firstImg = new Map<number, string>();
|
||||
for (const m of metas) {
|
||||
const url = firstUploadedImage(m.meta);
|
||||
const url = firstUploadedImage(m.meta) ?? firstHtmlImage(m.html);
|
||||
if (url) firstImg.set(m.id, url);
|
||||
}
|
||||
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
|
||||
@@ -690,6 +716,7 @@ export async function listPostsByTermsLegacy(legacyTermIds: number[], limit: num
|
||||
const catIds = catRows.map((c) => c.id);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: contents.id,
|
||||
slug: contents.slug,
|
||||
title: contents.title,
|
||||
excerpt: contents.excerpt,
|
||||
@@ -711,5 +738,19 @@ export async function listPostsByTermsLegacy(legacyTermIds: number[], limit: num
|
||||
)
|
||||
.orderBy(desc(contents.publishedAt))
|
||||
.limit(Math.max(1, Math.min(20, limit)));
|
||||
// 无特色图:meta(elementor 文档/HTML)→ 正文富文本提取第一张图(列表卡片缩略图兜底)
|
||||
const needFallback = rows.filter((r) => !r.imageUrl);
|
||||
if (needFallback.length) {
|
||||
const metas = await db
|
||||
.select({ id: contents.id, meta: contents.meta, html: contents.html })
|
||||
.from(contents)
|
||||
.where(inArray(contents.id, needFallback.map((r) => r.id)));
|
||||
const firstImg = new Map<number, string>();
|
||||
for (const m of metas) {
|
||||
const url = firstUploadedImage(m.meta) ?? firstHtmlImage(m.html);
|
||||
if (url) firstImg.set(m.id, url);
|
||||
}
|
||||
for (const r of rows) if (!r.imageUrl) r.imageUrl = firstImg.get(r.id) ?? null;
|
||||
}
|
||||
return rows as PostCardItem[];
|
||||
}
|
||||
|
||||
@@ -164,6 +164,16 @@
|
||||
background: #fff;
|
||||
}
|
||||
img { max-width: 100%; }
|
||||
/* 全站 <img> 占位图(.ds-site 作用域,Layout.tsx 的 main):
|
||||
* 未加载完成时先显示灰底 + 低透明度 Dusun logo(JPG,public/uploads/placeholder.jpg);
|
||||
* 图片加载完成后被像素覆盖(透明 PNG 慎用,会透出占位图) */
|
||||
.ds-site img {
|
||||
background-color: #e8eaee;
|
||||
background-image: url("/uploads/placeholder.jpg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 42% auto;
|
||||
}
|
||||
a { color: var(--brand); text-decoration: none; }
|
||||
/* 原站 kit-15(post-15.css):a:hover = --e-global-color-secondary #FFA11B */
|
||||
a:hover { color: var(--e-global-color-secondary, #ffa11b); }
|
||||
|
||||
Reference in New Issue
Block a user