v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
/**
* # 路由 文章详情 /blog/$slug/
* 原站模板形态:左主栏(白卡正文 + 目录)+ 右侧栏(社交图标 / CONTACT US 表单 /
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
import { Button } from "~/components/ui/button";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
import { getPublishedContent, getMenuTree, listPosts, type MenuNode, type PostListItem } from "~/server/data";
import { useT } from "~/i18n";
/** # 组件 社交品牌图标(lucide 新版已移除品牌图标,内联 SVG) */
function BrandIcon(props: { label: string; bg: string; path: string }) {
return (
<a href="#" aria-label={props.label} className="flex h-9 w-9 items-center justify-center rounded text-white" style={{ background: props.bg }}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden>
<path d={props.path} />
</svg>
</a>
);
}
export const Route = createFileRoute("/blog/$slug")({
loader: async ({ params }) => {
const [layout, post, latest, productMenu] = await Promise.all([
getLayoutData(),
getPublishedContent(params.slug, "post").catch(() => null),
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
]);
// 侧栏 IoT Gateways 折叠组 = 主菜单 Products 分支的子分组(By Protocols / Applications ...)
const gwGroups = productMenu.find((n) => n.title.trim().toLowerCase() === "products")?.children ?? [];
if (!post) return { layout, post: null, related: [] as PostListItem[], gwGroups };
// meta 收窄为具体 JSON 类型
const meta = (post.meta ?? null) as Record<string, unknown> | null;
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);
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() {
const t = useT();
const data = Route.useLoaderData();
if (!data.post) {
return (
<Layout layout={data.layout}>
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("blog_not_found")}</p>
<Button asChild>
<Link to="/blog/">{t("blog_back")}</Link>
</Button>
</div>
</Layout>
);
}
const post = data.post;
const cats = post.terms.filter((t) => t.taxonomy === "category");
const elementor = extractElementor(post.meta);
// Elementor 文章(原站 full-canvas 模板):正文通栏直出,不加面包屑/h1/日期
if (elementor) {
return (
<Layout layout={data.layout}>
<ElementorRenderer doc={elementor} />
</Layout>
);
}
// HTML 文章:从正文提取 h2/h3 生成目录(并注入锚点 id)
const toc = post.html ? buildToc(post.html) : null;
return (
<Layout layout={data.layout}>
<div className="site-container">
<Breadcrumb aria-label="Breadcrumb">
<BreadcrumbList>
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbLink to="/blog/">Blog</BreadcrumbLink></BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem><BreadcrumbPage>{post.title}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 items-start gap-7 min-[1080px]:grid-cols-[1fr_340px]">
{/* 主栏:白卡正文(目录 + 内容) */}
<article className="rounded-lg bg-white p-6 min-[760px]:p-9">
<h1 className="mt-0 text-[30px] leading-[1.35]">{post.title}</h1>
<div className="mb-5 text-[13px] text-muted-foreground">
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
{cats.length > 0 ? (
<>
{" · "}
{cats.map((c, i) => (
<span key={c.slug}>
{i > 0 ? ", " : ""}
{c.name}
</span>
))}
</>
) : null}
</div>
{post.featuredUrl ? (
<img src={post.featuredUrl} alt={post.featuredAlt || post.title} className="my-3 mb-5 w-full rounded-lg" />
) : null}
{toc && toc.items.length >= 3 ? (
<Accordion type="single" collapsible defaultValue="toc" className="mb-6 rounded-md border px-4">
<AccordionItem value="toc" className="border-b">
<AccordionTrigger>{t("blog_toc")}</AccordionTrigger>
<AccordionContent>
<ul className="m-0 list-none p-0">
{toc.items.map((it) => (
<li key={it.id} className={it.level === 3 ? "ml-5" : ""}>
<a href={`#${it.id}`} className="block py-1 text-[14px] text-brand hover:underline">
{it.text}
</a>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
) : null}
{elementor ? (
<ElementorRenderer doc={elementor} />
) : post.html ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: toc?.html || post.html }} />
) : (
<p className="text-muted-foreground">{post.excerpt || t("blog_importing")}</p>
)}
</article>
{/* 侧栏:社交图标 / CONTACT US 表单 / IoT Gateways 产品分组 */}
<aside className="space-y-5">
<div className="flex justify-center gap-3 rounded-lg bg-white p-4">
<BrandIcon label="LinkedIn" bg="#0a66c2" path="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z" />
<BrandIcon label="YouTube" bg="#ff0000" path="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" />
<BrandIcon label="Facebook" bg="#1877f2" path="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.7 4.53-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07z" />
</div>
<div className="rounded-lg bg-white p-5">
<InquiryForm formKey="contact-us-form-sidebar" productInterest={post.title} title={t("blog_contact_title")} />
</div>
{data.gwGroups.length > 0 ? (
<div className="rounded-lg bg-white p-5">
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
<Accordion type="multiple" className="border-t">
{data.gwGroups.map((g) => (
<AccordionItem key={g.id} value={`gw-${g.id}`}>
<AccordionTrigger className="text-[15px] font-medium">{g.title}</AccordionTrigger>
<AccordionContent>
<ul className="m-0 list-none p-0">
{g.children.map((c) => (
<li key={c.id} className="py-1">
<a href={c.url} className="text-[14px] text-muted-foreground hover:text-brand hover:underline">{c.title}</a>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
) : null}
</aside>
</div>
{/* 底部 Related Posts(原站:4 列卡片,灰渐变占位 + 标题) */}
{data.related.length > 0 ? (
<section className="my-12">
<h2 className="mb-6 text-center text-[26px]">{t("blog_related_posts")}</h2>
<div className="grid grid-cols-2 gap-5 min-[960px]:grid-cols-4">
{data.related.map((p) => (
<Link key={p.id} to="/blog/$slug/" params={{ slug: p.slug }} className="block">
<div className="mb-3 aspect-[4/3] overflow-hidden rounded-md bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
{p.imageUrl ? <img src={p.imageUrl} alt={p.title} loading="lazy" className="h-full w-full object-cover" /> : null}
</div>
<div className="text-[15px] leading-[1.45] font-medium hover:text-brand">{p.title}</div>
</Link>
))}
</div>
</section>
) : null}
</div>
</Layout>
);
}
/** # 函数 从 HTML 提取 h2/h3 生成目录条目,并给标题注入锚点 id
* 参数 html 文章正文 HTML
* 返回值 items 目录条目(level/text/id);html 注入 id 后的正文
*/
function buildToc(html: string): { items: { level: number; text: string; id: string }[]; html: string } {
const items: { level: number; text: string; id: string }[] = [];
let i = 0;
const out = html.replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/gi, (m, lvl: string, attrs: string, inner: string) => {
const text = inner.replace(/<[^>]*>/g, "").trim();
if (!text || items.length >= 40) return m;
const id = `toc-${i++}`;
items.push({ level: Number(lvl), text, id });
return `<h${lvl} id="${id}"${attrs}>${inner}</h${lvl}>`;
});
return { items, html: out };
}