v0.10.26: /blog/ 归档(archive_cards 皮肤/BlogArchive/WP 分页窗口)+ 文章详情 single 模板(hero/面包屑/三列正文+侧栏)+ page_css 全站导入(254篇) + 图片 WP 尺寸变体解析
This commit is contained in:
@@ -11,8 +11,80 @@ 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 { PostHero } from "~/components/website/PostHero";
|
||||
import { resolveImageVariants } from "~/utils/image-variant";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
/** # 常量 文章页三列布局样式(single 模板取证:60/776/360,容器 1200,移动端堆叠) */
|
||||
const POST_LAYOUT_CSS = `
|
||||
.ds-postlayout { background: #fff; }
|
||||
.ds-postlayout .ds-wrap { display: flex; align-items: flex-start; max-width: 1200px; margin: 0 auto; padding: 32px 0 40px; }
|
||||
.ds-postlayout .ds-rail { width: 60px; flex: 0 0 60px; }
|
||||
.ds-postlayout .ds-main { width: 776px; flex: 0 0 776px; min-width: 0; padding: 0 32px; }
|
||||
.ds-postlayout .ds-side { width: 360px; flex: 0 0 360px; padding: 0 10px; display: flex; flex-direction: column; gap: 20px; }
|
||||
@media (max-width: 1024px) {
|
||||
.ds-postlayout .ds-wrap { flex-direction: column; }
|
||||
.ds-postlayout .ds-rail { display: none; }
|
||||
.ds-postlayout .ds-main, .ds-postlayout .ds-side { width: 100%; flex: 1 1 auto; }
|
||||
}
|
||||
`;
|
||||
|
||||
/** # 组件 分享竖栏(原站 elementor-share-buttons:facebook/twitter/linkedin 40px 方块) */
|
||||
function ShareRail() {
|
||||
const btns = [
|
||||
{ label: "Share on facebook", bg: "#1868d5", path: "M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z" },
|
||||
{ label: "Share on twitter", bg: "#1d9bf0", path: "M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z" },
|
||||
{ label: "Share on linkedin", bg: "#0a66c2", path: "M504 256c0 137-111 248-248 248S8 393 8 256 119 8 256 8s248 111 248 248zm-379-56.5v193h64.7V199.5H125zm32.4-84.2c-11.3 0-20.4 9.2-20.4 20.4 0 11.3 9.2 20.4 20.4 20.4 11.3 0 20.4-9.2 20.4-20.4 0-11.3-9.1-20.4-20.4-20.4zM399 265.9c0-50.7-27-74.2-63-74.2-29 0-42 16-49.2 27.2v-23.4H222c.9 17.6 0 193 0 193h64.7v-107.8c0-5.8.4-11.6 2.1-15.7 4.7-11.6 15.3-23.7 33.1-23.7 23.4 0 32.7 17.8 32.7 43.9v103.3H419l-.1-122.6z" },
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 sticky top-[130px]">
|
||||
{btns.map((b) => (
|
||||
<a key={b.label} href="#" aria-label={b.label} onClick={(e) => e.preventDefault()} className="flex h-10 w-10 items-center justify-center rounded text-white" style={{ background: b.bg }}>
|
||||
<svg viewBox="0 0 512 512" width="17" height="17" fill="currentColor" aria-hidden><path d={b.path} /></svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 文章页侧栏(社交图标 + CONTACT US 表单 + IoT Gateways 折叠组) */
|
||||
function PostSidebarWidget(props: { groups: MenuNode[]; productInterest: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center gap-3 rounded-lg bg-white p-4 shadow-[0_1px_6px_rgba(16,42,76,0.08)]">
|
||||
<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 shadow-[0_1px_6px_rgba(16,42,76,0.08)]">
|
||||
<InquiryForm formKey="contact-us-form-sidebar" productInterest={props.productInterest} title={t("blog_contact_title")} />
|
||||
</div>
|
||||
{props.groups.length > 0 ? (
|
||||
<div className="rounded-lg bg-white p-5 shadow-[0_1px_6px_rgba(16,42,76,0.08)]">
|
||||
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
|
||||
<Accordion type="multiple" className="border-t">
|
||||
{props.groups.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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 社交品牌图标(lucide 新版已移除品牌图标,内联 SVG) */
|
||||
function BrandIcon(props: { label: string; bg: string; path: string }) {
|
||||
return (
|
||||
@@ -35,8 +107,9 @@ export const Route = createFileRoute("/blog/$slug")({
|
||||
// 侧栏 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 类型
|
||||
// meta 收窄为具体 JSON 类型;image widget WP 尺寸变体解析
|
||||
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);
|
||||
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
||||
@@ -70,11 +143,26 @@ function BlogPostPage() {
|
||||
const post = data.post;
|
||||
const cats = post.terms.filter((t) => t.taxonomy === "category");
|
||||
const elementor = extractElementor(post.meta);
|
||||
// Elementor 文章(原站 full-canvas 模板):正文通栏直出,不加面包屑/h1/日期
|
||||
// Elementor 文章(原站 single 模板):hero + 面包屑条 + 三列正文
|
||||
// (cd804a1 取证 2026-11-03:分享栏 60px / 正文 776px / 侧栏 360px,容器 1200)
|
||||
if (elementor) {
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<ElementorRenderer doc={elementor} />
|
||||
<PostHero post={post} />
|
||||
<style>{POST_LAYOUT_CSS}</style>
|
||||
<div className="ds-postlayout">
|
||||
<div className="ds-wrap">
|
||||
<div className="ds-rail">
|
||||
<ShareRail />
|
||||
</div>
|
||||
<div className="ds-main">
|
||||
<ElementorRenderer doc={elementor} pageCss={(post.meta as Record<string, unknown> | null)?.page_css as string | undefined} />
|
||||
</div>
|
||||
<aside className="ds-side">
|
||||
<PostSidebarWidget groups={data.gwGroups} productInterest={post.title} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-82
@@ -1,96 +1,24 @@
|
||||
/**
|
||||
* # 路由 博客列表 /blog/
|
||||
* 文章卡片分页列表(图/标题/摘要/日期)。
|
||||
* # 路由 博客归档 /blog/
|
||||
* 原站 Elementor archive-posts archive_cards 皮肤一比一复刻(BlogArchive 组件,第 1 页)。
|
||||
*/
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { Pagination } from "~/components/website/Pagination";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { listPosts, getPostCategories } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
|
||||
|
||||
export const Route = createFileRoute("/blog/")({
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||
const page = Number(search.page);
|
||||
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||
loader: async ({ deps }) => {
|
||||
const [layout, list, cats] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listPosts(deps.page || 1, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
getPostCategories().catch(() => []),
|
||||
]);
|
||||
return { layout, list, cats };
|
||||
},
|
||||
loader: () => loadBlogArchive(1),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
||||
{ name: "description", content: "IoT guides, industry insights, product news and tutorials from Dusun IoT experts." },
|
||||
],
|
||||
links: [{ rel: "canonical", href: "https://www.dusuniot.com/blog/" }],
|
||||
}),
|
||||
component: BlogIndexPage,
|
||||
component: BlogArchivePage,
|
||||
});
|
||||
|
||||
/** # 组件 博客列表页 */
|
||||
function BlogIndexPage() {
|
||||
const t = useT();
|
||||
/** # 组件 博客归档页(第 1 页) */
|
||||
function BlogArchivePage() {
|
||||
const data = Route.useLoaderData();
|
||||
const page = data.list.page;
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="bg-[#f5f5f6] px-4 pt-[34px] pb-[30px] text-center">
|
||||
<h1 className="m-0 text-[34px] tracking-wide">BLOG</h1>
|
||||
</div>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{t("nav_blog")}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{data.cats.length > 0 ? (
|
||||
<div className="mt-3 mb-6 flex flex-wrap gap-2.5">
|
||||
{data.cats
|
||||
.filter((c) => Number(c.count) > 0)
|
||||
.slice(0, 12)
|
||||
.map((c) => (
|
||||
<span key={c.slug} className="inline-block rounded-[4px] border border-brand bg-white px-3 py-[5px] text-[13px] font-semibold text-brand">
|
||||
{c.name} ({Number(c.count)})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-5 min-[960px]:grid-cols-3">
|
||||
{data.list.items.map((post) => (
|
||||
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||
{/* 原站卡片:无特色图时显示灰色渐变占位 + 右上角分类徽章 */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt={post.title} loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]" />
|
||||
)}
|
||||
<span className="absolute top-3 right-3 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-white">
|
||||
{post.categoryName || "Blog"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||
{post.excerpt ? <p className="my-1.5 text-sm text-muted-foreground">{post.excerpt.slice(0, 140)}…</p> : null}
|
||||
<div className="text-[13px] text-muted-foreground">
|
||||
{post.authorName ? `${post.authorName} · ` : ""}
|
||||
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||
</div>
|
||||
<div className="mt-2.5 text-[13px] font-bold">{t("blog_read_more")}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("blog_no_posts")}</p> : null}
|
||||
</div>
|
||||
<Pagination page={page} total={data.list.total} pageSize={data.list.pageSize} makeHref={(p) => `/blog/?page=${p}`} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
return <BlogArchive data={data} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* # 路由 博客归档分页 /blog/page/$page/
|
||||
* 与原站一致的第 2+ 页归档(每页 12 篇,日期倒序)。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
|
||||
|
||||
export const Route = createFileRoute("/blog/page/$page")({
|
||||
loader: async ({ params }) => {
|
||||
const n = Number(params.page);
|
||||
return loadBlogArchive(Number.isFinite(n) && n > 1 ? Math.floor(n) : 1);
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{
|
||||
title: `Blog - Page ${loaderData?.page ?? 2} of ${loaderData ? Math.max(1, Math.ceil(loaderData.list.total / loaderData.list.pageSize)) : 2} - DusunIoT`,
|
||||
},
|
||||
{ name: "description", content: "IoT guides, industry insights, product news and tutorials from Dusun IoT experts." },
|
||||
],
|
||||
}),
|
||||
component: BlogArchivePagedPage,
|
||||
});
|
||||
|
||||
/** # 组件 博客归档分页页 */
|
||||
function BlogArchivePagedPage() {
|
||||
const data = Route.useLoaderData();
|
||||
return <BlogArchive data={data} />;
|
||||
}
|
||||
Reference in New Issue
Block a user