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
+3 -3
View File
@@ -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 标记当前页) **/
+13 -8
View File
@@ -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 {
+5 -5
View File
@@ -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]" />
+27 -1
View File
@@ -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 }} />
</>
);
}
+14 -8
View File
@@ -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
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: [
+45 -4
View File
@@ -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[];
}
+10
View File
@@ -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); }