diff --git a/docs/sections-inventory.md b/docs/sections-inventory.md index 894c276f3..6b3518d53 100644 --- a/docs/sections-inventory.md +++ b/docs/sections-inventory.md @@ -114,6 +114,18 @@ - 已知数据差异:smart-home-solution 原站 2026-10-16 改版新增内层版块(2faa053 等不在 dump 快照),9dc5c01 场景区较原站矮约 1540px,属快照滞后;其余 13 个落地页全部版块 齐全,逐节高度差 >15px 的多数为原站 headless lazy/swiper 未初始化假象。 +- B1 `/blog/` 归档 v0.10.26 ✅ 一比一复刻原站 archive_cards 皮肤:缩略图 66% cover 盒、 + 右上角分类徽章 #FFA11B 圆角 999、标题 21px(margin 25)、摘录 24 词截断、Read More »、 + meta(#EAEAEA 分隔线 + 作者/日期 12px #ADADAD);/blog/page/N/ 分页路由;分页窗口算法 + WP paginate_links(end 1/mid 2,两端组件共用);数据源为 category-blog 归档。 +- B2 `/blog/$slug` 文章详情 v0.10.26 ✅ single 模板复刻:hero(blog-tem-bg 背景图、 + rgba(158,206,255,.1) 面板、h1 28px/600 白、摘要、updated/published 白色日期条、特色图) + + 面包屑条(Home/Blog/标题)+ 三列正文(分享竖栏 60 / 正文 776 / 侧栏 360,容器 1200); + 侧栏 = 社交图标 + CONTACT US 表单 + IoT Gateways 折叠组;正文 Elementor 直出 + + pageCss + 图片 WP 尺寸变体解析(medium→-300x272 类文件,服务端目录扫描缓存)。 +- page_css 导入扩展至全部文章:254 篇有 post-.css 已导入,355 篇原站 404(无自定义 CSS)。 +- 已知数据差异:/blog/ 本地 category-blog 302 篇(26 页)vs 原站当前 7 页(dump 后原站 + 调整了分类成员);文章内文逐节高度仍有数据快照/lazy 假象差。 ### 3. 商城/产品/博客(WordPress 模板页) - SH1 商城:`/shop/` 分类+商品网格 diff --git a/package.json b/package.json index 46aa719bf..b835d3736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.10.25", + "version": "0.10.26", "private": true, "type": "module", "scripts": { diff --git a/scripts/import-page-css.ts b/scripts/import-page-css.ts index 27b3e8522..7d82674fc 100644 --- a/scripts/import-page-css.ts +++ b/scripts/import-page-css.ts @@ -113,30 +113,49 @@ function filterCss(css: string, ids: Set, pid: number): string { const slugs = process.argv.slice(2).length ? process.argv.slice(2) : SOLUTION_SLUGS; const sql = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 }); +/** 已导入过的跳过(断点续传;meta.page_css 键存在即视为完成) */ +let done = 0; +let skipped = 0; for (const slug of slugs) { const rows = await sql`select id, legacy_id, meta::text as meta from contents where slug = ${slug} and type = ${"post"} limit 1`; const row = rows[0]; if (!row) { - console.log("SKIP(无内容):", slug); + skipped++; continue; } const meta = JSON.parse(row.meta); + if (meta.page_css !== undefined) { + skipped++; + continue; + } const ids = collectIds(meta.elementor); const pid = row.legacy_id; if (!pid) { - console.log("SKIP(无 legacy_id):", slug); + skipped++; continue; } - const res = await fetch(CSS_URL(pid), { headers: { "User-Agent": "Mozilla/5.0" } }); - if (!res.ok) { - console.log("SKIP(css", res.status, "):", slug, CSS_URL(pid)); + // 重试 3 次、间隔 300ms(原站偶发 ECONNRESET/限流) + let raw: string | null = null; + for (let attempt = 0; attempt < 3 && raw === null; attempt++) { + try { + const res = await fetch(CSS_URL(pid), { headers: { "User-Agent": "Mozilla/5.0" } }); + if (res.ok) raw = await res.text(); + else if (res.status === 404) break; + } catch { + await new Promise((r) => setTimeout(r, 300)); + } + } + if (raw === null) { + console.log("SKIP(fetch失败):", slug, pid); + skipped++; continue; } - const raw = await res.text(); const pageCss = filterCss(raw, ids, pid); meta.page_css = pageCss; // ::text::jsonb 双重转换:Bun SQL 绑定字符串直接 ::jsonb 会把整串存成 JSON 字符串值 await sql`update contents set meta = ${JSON.stringify(meta)}::text::jsonb where id = ${row.id}`; - console.log("OK:", slug, "post", pid, "css", raw.length, "->", pageCss.length); + done++; + if (done % 50 === 0) console.log(`... ${done} imported`); } +console.log("imported:", done, "skipped:", skipped); await sql.end(); diff --git a/src/components/website/BlogArchive.tsx b/src/components/website/BlogArchive.tsx new file mode 100644 index 000000000..6571ceb95 --- /dev/null +++ b/src/components/website/BlogArchive.tsx @@ -0,0 +1,207 @@ +/** + * # 组件 博客归档页(/blog/ 与 /blog/page/N/ 共用) + * 一比一复刻原站 Elementor archive-posts archive_cards 皮肤(widget 2da75291, + * 取证 2026-11-03 playwright 1440/390):与 /solution/ 同款 hero + grid,卡片为 cards 皮肤: + * - 缩略图 66%(移动 50%)高度盒、img absolute inset cover(原站 category-53 stabilize CSS) + * - 卡片右上角分类徽章:#FFA11B 圆角 999、白字 12px、pad 7.2 14.4、margin 14 + * - 文字区 pad 0 30、margin-top 30:标题 21px/1.2 600(链接 #ED6C00 400,margin 0 0 25px) + * - 摘录 14px/21 #777(margin 0 0 25px,24 词截断);Read More » 16px/700 #ED6C00(下距 20) + * - meta:border-top 1px #EAEAEA、pad 15 30、12px/15.6 #ADADAD(作者 + 日期) + * - 卡片阴影/hover 与 solution 一致;分页同款(当前页 #FFA11B) + */ +import { Link } from "@tanstack/react-router"; +import { getLayoutData, Layout } from "~/components/website/Layout"; +import { listPosts } from "~/server/data"; +import { useT } from "~/i18n"; + +/** # 常量 摘录显示词数上限(与 /solution/ 同款 excerpt_length=24) **/ +const EXCERPT_WORDS = 24; + +/** # 函数 按词数截断摘录(WP wp_trim_words 语义:空白分词但   相连算一个词,无省略号) **/ +function trimExcerpt(text: string, limit: number) { + const words = text.split(/[^\S\u00a0]+/).filter(Boolean); + return words.length > limit ? words.slice(0, limit).join(" ") : text; +} + +/** # 类型 博客归档数据(loader 产出) **/ +export type BlogArchiveData = { + layout: Awaited>; + list: Awaited>; + 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 }; +} + +/** # 函数 日期长格式(原站 November 2, 2026) **/ +function fmtDate(d: Date | string | null): string { + if (!d) return ""; + const dt = typeof d === "string" ? new Date(d) : d; + return dt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" }); +} + +/** # 函数 WP paginate_links 页码窗口(end_size=1、mid_size=2;页少时全部显示) + * 返回值 页码与省略号序列(如 [1, 2, "…", 7]) + */ +export function pageWindow(total: number, cur: number, end = 1, mid = 2): (number | "dots")[] { + if (total <= (end + mid) * 2 + 1) return Array.from({ length: total }, (_, i) => i + 1); + const items: (number | "dots")[] = []; + const add = (n: number | "dots") => { + if (items[items.length - 1] !== n) items.push(n); + }; + for (let i = 1; i <= end; i++) add(i); + const lo = Math.max(cur - mid, end + 1); + const hi = Math.min(cur + mid, total - end); + if (lo > end + 1) add("dots"); + for (let i = lo; i <= hi; i++) add(i); + if (hi < total - end) add("dots"); + for (let i = total - end + 1; i <= total; i++) add(i); + return items; +} + +/** # 组件 博客归档页(原站 archive_cards 一比一) */ +export function BlogArchive({ data }: { data: BlogArchiveData }) { + const t = useT(); + const { list, page } = data; + const totalPages = Math.max(1, Math.ceil(list.total / list.pageSize)); + /** 分页链接(原站路径 /blog/ 与 /blog/page/N/) */ + const href = (p: number) => (p <= 1 ? "/blog/" : `/blog/page/${p}/`); + return ( + + +
+
+ {/* hero:灰底 Blog 大标题 */} +
+
+
+

Blog

+
+
+
+ {/* 卡片归档 */} +
+
+
+
+
+ {list.items.map((post) => ( +
+
+ +
+ {post.imageUrl ? {post.title} : null} +
+ + {post.categoryName ?
{post.categoryName}
: null} +
+

+ {post.title} +

+ {post.excerpt ? ( +
+

{trimExcerpt(post.excerpt, EXCERPT_WORDS)}

+
+ ) : null} +
+ {t("blog_read_more")} » +
+
+
+ {post.authorName ? {post.authorName} : null} + {fmtDate(post.publishedAt)} +
+
+
+ ))} + {list.items.length === 0 ?

{t("blog_no_posts")}

: null} +
+ {totalPages > 1 ? ( + + ) : null} +
+
+
+
+
+
+
+ ); +} + +/** # 常量 博客归档页样式(archive_cards 皮肤取证值;断点 1024/767 与 hero/分页同 solution) **/ +export const BLOG_ARCHIVE_CSS = ` +.ds-blogarch .ds-hero { background: #eee; padding: 40px 0; } +.ds-blogarch .ds-hero-container { max-width: 1140px; margin: 0 auto; } +.ds-blogarch .ds-hero-col { padding: 0 10px; } +.ds-blogarch .ds-heading { margin: 0; font-size: 40px; font-weight: 900; line-height: 40px; color: #000; text-align: center; text-transform: uppercase; } +.ds-blogarch .ds-posts-section { background: #fff; margin: 64px 0; padding: 0 192px; } +.ds-blogarch .ds-container { display: flex; } +.ds-blogarch .ds-col { width: 100%; } +.ds-blogarch .ds-widget-wrap { padding: 10px; width: 100%; } +.ds-blogarch .ds-posts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; } +.ds-blogarch .ds-post { display: flex; } +.ds-blogarch .ds-post-card { position: relative; display: flex; flex-direction: column; flex: 1; background: #fff; border-radius: 3px; box-shadow: 0 0 10px 0 rgba(0,0,0,0.15); transition: background .25s, box-shadow .25s; } +.ds-blogarch .ds-post:hover .ds-post-card { box-shadow: 0 0 30px 0 rgba(0,0,0,0.15); } +.ds-blogarch .ds-thumb-link { position: relative; display: block; margin: 0 0 20px; } +.ds-blogarch .ds-thumb { position: relative; height: 0; padding-bottom: 66%; overflow: hidden; } +.ds-blogarch .ds-thumb img { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: cover; object-position: center; } +.ds-blogarch .ds-badge { position: absolute; top: 14px; right: 14px; padding: 7.2px 14.4px; border-radius: 999px; background: #FFA11B; color: #fff; font-size: 12px; line-height: 12px; font-weight: 400; } +.ds-blogarch .ds-post-text { margin: 10px 0 0; padding: 0 30px; flex: 1; } +.ds-blogarch .ds-post-title { margin: 0 0 25px; font-size: 21px; font-weight: 600; line-height: 1.2; color: #333; } +.ds-blogarch .ds-post-title a { color: #ED6C00; font-weight: 400; } +.ds-blogarch .ds-post-excerpt { margin: 0 0 25px; } +.ds-blogarch .ds-post-excerpt p { margin: 0; font-size: 14px; line-height: 21px; color: #777; } +.ds-blogarch .ds-read-more { margin: 0 0 20px; font-size: 16px; line-height: 24px; font-weight: 700; } +.ds-blogarch .ds-read-more a { display: inline-block; color: #ED6C00; text-decoration: none; } +.ds-blogarch .ds-dots { color: #000; } +.ds-blogarch .ds-meta { border-top: 1px solid #EAEAEA; padding: 15px 30px; font-size: 12px; line-height: 15.6px; color: #ADADAD; } +.ds-blogarch .ds-empty { grid-column: 1 / -1; padding: 60px 0; text-align: center; color: #777; } +.ds-blogarch .ds-pagination { margin: 40px 0 0; font-size: 16px; line-height: 24px; text-align: center; } +.ds-blogarch .ds-page-num { display: inline; margin: 0 10px; color: #000; text-decoration: none; } +.ds-blogarch .ds-page-num.ds-prev { margin: 0 10px 0 0; } +.ds-blogarch .ds-page-num.ds-next { margin: 0 0 0 10px; } +.ds-blogarch .ds-page-num.ds-current { color: #FFA11B; } +.ds-blogarch .ds-screen-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; } +@media (max-width: 1024px) { + .ds-blogarch .ds-posts-section { margin: 64px 0; padding: 100px 25px; } + .ds-blogarch .ds-posts { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 767px) { + .ds-blogarch .ds-posts-section { margin: 0; padding: 0; } + .ds-blogarch .ds-posts { grid-template-columns: 1fr; } + .ds-blogarch .ds-thumb { padding-bottom: 50%; } +} +`; diff --git a/src/components/website/PostHero.tsx b/src/components/website/PostHero.tsx new file mode 100644 index 000000000..8e3e0dd58 --- /dev/null +++ b/src/components/website/PostHero.tsx @@ -0,0 +1,102 @@ +/** + * # 组件 文章详情 hero + 面包屑条(原站 single 模板,取证 2026-11-03 playwright 1440) + * hero:背景图 /uploads/2023/01/blog-tem-bg.jpg cover 居中、pad 32 0;内容容器 1200; + * 内层面板 rgba(158,206,255,0.1)、pad 16 0,两列 50/50: + * 左 = h1 28px/600/35.2 白 + 摘要 16px/24 #333(下距 20)+ post-info(updated/published + * 两个白色 16px 条目、图标 16px、li margin 0 10px);右 = 特色图(宽随列)。 + * 条:白底 44px,woocommerce 面包屑(Home / Blog / 标题)16px #333。 + */ +import { Link } from "@tanstack/react-router"; + +/** # 类型 文章 hero 数据 **/ +export type PostHeroData = { + title: string; + excerpt?: string | null; + featuredUrl?: string | null; + featuredAlt?: string | null; + publishedAt?: Date | string | null; + updatedAt?: Date | string | null; +}; + +/** # 函数 日期长格式(原站 October 21, 2026) **/ +function fmtDate(d: Date | string | null | undefined): string { + if (!d) return ""; + const dt = typeof d === "string" ? new Date(d) : d; + return dt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" }); +} + +/** # 组件 文章 hero(原站 685e2a4 + 8ba9cbc) */ +export function PostHero(props: { post: PostHeroData }) { + const { post } = props; + return ( + <> + +
+
+
+
+
+

{post.title}

+ {post.excerpt ?
{post.excerpt}
: null} +
    + {fmtDate(post.updatedAt) ? ( +
  • + + {fmtDate(post.updatedAt)} +
  • + ) : null} + {fmtDate(post.publishedAt) ? ( +
  • + + {fmtDate(post.publishedAt)} +
  • + ) : null} +
+
+
+ {post.featuredUrl ? {post.featuredAlt : null} +
+
+
+
+
+
+ +
+
+
+ + ); +} + +/** # 常量 文章 hero 样式(取证值) **/ +export const POST_HERO_CSS = ` +.ds-posthero .ds-hero { background: url(/uploads/2023/01/blog-tem-bg.jpg) center center / cover no-repeat; padding: 32px 0; } +.ds-posthero .ds-container { max-width: 1200px; margin: 0 auto; } +.ds-posthero .ds-inner { display: flex; background: rgba(158, 206, 255, 0.1); padding: 16px 0; max-width: 1180px; margin: 0 auto; } +.ds-posthero .ds-col-l { width: 50%; padding: 0 10px 0 30px; display: flex; flex-direction: column; justify-content: center; } +.ds-posthero .ds-col-r { width: 50%; padding: 0 30px 0 10px; display: flex; } +.ds-posthero .ds-title { margin: 0; font-size: 28px; font-weight: 600; line-height: 1.25; color: #fff; } +.ds-posthero .ds-excerpt { margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #333; } +.ds-posthero .ds-info { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; } +.ds-posthero .ds-info li { display: flex; align-items: center; margin: 0 10px; font-size: 16px; line-height: 24px; } +.ds-posthero .ds-ico { width: 16px; height: 16px; fill: #fff; margin-right: 5px; } +.ds-posthero .ds-info-text { color: #fff; } +.ds-posthero .ds-col-r img { display: block; width: 100%; height: auto; align-self: center; } +.ds-posthero .ds-strip { background: #fff; } +.ds-posthero .ds-strip-container { max-width: 1200px; margin: 0 auto; padding: 10px; } +.ds-posthero .ds-breadcrumb { font-size: 16px; line-height: 24px; color: #333; } +.ds-posthero .ds-breadcrumb a { color: #333; text-decoration: none; } +.ds-posthero .ds-breadcrumb a:hover { color: #ED6C00; } +.ds-posthero .ds-breadcrumb span:last-child { color: #777; } +@media (max-width: 767px) { + .ds-posthero .ds-inner { flex-direction: column; } + .ds-posthero .ds-col-l, .ds-posthero .ds-col-r { width: 100%; } +} +`; diff --git a/src/components/website/SolutionArchive.tsx b/src/components/website/SolutionArchive.tsx index 914f42d38..fd50ea2a7 100644 --- a/src/components/website/SolutionArchive.tsx +++ b/src/components/website/SolutionArchive.tsx @@ -14,6 +14,7 @@ import { Link } from "@tanstack/react-router"; import { getLayoutData, Layout } from "~/components/website/Layout"; import { listSolutionLandings } from "~/server/data"; +import { pageWindow } from "~/components/website/BlogArchive"; import { useT } from "~/i18n"; /** # 类型 方案归档页 props **/ @@ -101,8 +102,10 @@ export function SolutionArchive({ data }: { data: { layout: Awaited« Previous )} - {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => - p === page ? ( + {pageWindow(totalPages, page).map((p) => + p === "dots" ? ( + … + ) : p === page ? ( Page {p} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e6fbaf125..36d984a9b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -70,7 +70,7 @@ export const en = { /** 博客 */ blog_badge: "Blog", - blog_read_more: "READ MORE »", + blog_read_more: "Read More »", blog_no_posts: "No blog posts yet — content is being imported. Please check back soon.", blog_toc: "Table of Contents", blog_related_posts: "Related Posts", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 988f84859..920721ddf 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -41,6 +41,7 @@ import { Route as AdminOrdersIndexRouteImport } from './routes/admin.orders.inde import { Route as AdminOrdersIdRouteImport } from './routes/admin.orders.$id' import { Route as AdminProductsIndexRouteImport } from './routes/admin.products.index' import { Route as AdminProductsIdRouteImport } from './routes/admin.products.$id' +import { Route as BlogPagePageRouteImport } from './routes/blog.page.$page' import { Route as SolutionPagePageRouteImport } from './routes/solution.page.$page' const IndexRoute = IndexRouteImport.update({ @@ -203,6 +204,11 @@ const AdminProductsIdRoute = AdminProductsIdRouteImport.update({ path: '/products/$id', getParentRoute: () => AdminRoute, } as any) +const BlogPagePageRoute = BlogPagePageRouteImport.update({ + id: '/blog/page/$page', + path: '/blog/page/$page', + getParentRoute: () => rootRouteImport, +} as any) const SolutionPagePageRoute = SolutionPagePageRouteImport.update({ id: '/page/$page', path: '/page/$page', @@ -239,6 +245,7 @@ export interface FileRoutesByFullPath { '/admin/contents/$id': typeof AdminContentsIdRoute '/admin/orders/$id': typeof AdminOrdersIdRoute '/admin/products/$id': typeof AdminProductsIdRoute + '/blog/page/$page': typeof BlogPagePageRoute '/solution/page/$page': typeof SolutionPagePageRoute '/admin/contents/': typeof AdminContentsIndexRoute '/admin/orders/': typeof AdminOrdersIndexRoute @@ -272,6 +279,7 @@ export interface FileRoutesByTo { '/admin/contents/$id': typeof AdminContentsIdRoute '/admin/orders/$id': typeof AdminOrdersIdRoute '/admin/products/$id': typeof AdminProductsIdRoute + '/blog/page/$page': typeof BlogPagePageRoute '/solution/page/$page': typeof SolutionPagePageRoute '/admin/contents': typeof AdminContentsIndexRoute '/admin/orders': typeof AdminOrdersIndexRoute @@ -308,6 +316,7 @@ export interface FileRoutesById { '/admin/contents/$id': typeof AdminContentsIdRoute '/admin/orders/$id': typeof AdminOrdersIdRoute '/admin/products/$id': typeof AdminProductsIdRoute + '/blog/page/$page': typeof BlogPagePageRoute '/solution/page/$page': typeof SolutionPagePageRoute '/admin/contents/': typeof AdminContentsIndexRoute '/admin/orders/': typeof AdminOrdersIndexRoute @@ -345,6 +354,7 @@ export interface FileRouteTypes { | '/admin/contents/$id' | '/admin/orders/$id' | '/admin/products/$id' + | '/blog/page/$page' | '/solution/page/$page' | '/admin/contents/' | '/admin/orders/' @@ -378,6 +388,7 @@ export interface FileRouteTypes { | '/admin/contents/$id' | '/admin/orders/$id' | '/admin/products/$id' + | '/blog/page/$page' | '/solution/page/$page' | '/admin/contents' | '/admin/orders' @@ -413,6 +424,7 @@ export interface FileRouteTypes { | '/admin/contents/$id' | '/admin/orders/$id' | '/admin/products/$id' + | '/blog/page/$page' | '/solution/page/$page' | '/admin/contents/' | '/admin/orders/' @@ -437,6 +449,7 @@ export interface RootRouteChildren { ProductSlugRoute: typeof ProductSlugRoute BlogIndexRoute: typeof BlogIndexRoute CollectionIndexRoute: typeof CollectionIndexRoute + BlogPagePageRoute: typeof BlogPagePageRoute } declare module '@tanstack/react-router' { @@ -665,6 +678,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminProductsIdRouteImport parentRoute: typeof AdminRoute } + '/blog/page/$page': { + id: '/blog/page/$page' + path: '/blog/page/$page' + fullPath: '/blog/page/$page' + preLoaderRoute: typeof BlogPagePageRouteImport + parentRoute: typeof rootRouteImport + } '/solution/page/$page': { id: '/solution/page/$page' path: '/page/$page' @@ -743,6 +763,7 @@ const rootRouteChildren: RootRouteChildren = { ProductSlugRoute: ProductSlugRoute, BlogIndexRoute: BlogIndexRoute, CollectionIndexRoute: CollectionIndexRoute, + BlogPagePageRoute: BlogPagePageRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/blog.$slug.tsx b/src/routes/blog.$slug.tsx index a2d81ee92..773527d20 100644 --- a/src/routes/blog.$slug.tsx +++ b/src/routes/blog.$slug.tsx @@ -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 ( + + ); +} + +/** # 组件 文章页侧栏(社交图标 + CONTACT US 表单 + IoT Gateways 折叠组) */ +function PostSidebarWidget(props: { groups: MenuNode[]; productInterest: string }) { + const t = useT(); + return ( + <> +
+ + + +
+
+ +
+ {props.groups.length > 0 ? ( +
+

{t("blog_gateways")}

+ + {props.groups.map((g) => ( + + {g.title} + +
    + {g.children.map((c) => ( +
  • + {c.title} +
  • + ))} +
+
+
+ ))} +
+
+ ) : 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 | 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 ( - + + +
+
+
+ +
+
+ | null)?.page_css as string | undefined} /> +
+ +
+
); } diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index 1f6d2e1a2..86e2205ed 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -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): { 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 ( - -
-

BLOG

-
-
- - - Home - - {t("nav_blog")} - - - {data.cats.length > 0 ? ( -
- {data.cats - .filter((c) => Number(c.count) > 0) - .slice(0, 12) - .map((c) => ( - - {c.name} ({Number(c.count)}) - - ))} -
- ) : null} -
- {data.list.items.map((post) => ( - - {/* 原站卡片:无特色图时显示灰色渐变占位 + 右上角分类徽章 */} -
- {post.imageUrl ? ( - {post.title} - ) : ( -
- )} - - {post.categoryName || "Blog"} - -
-
-
{post.title}
- {post.excerpt ?

{post.excerpt.slice(0, 140)}…

: null} -
- {post.authorName ? `${post.authorName} · ` : ""} - {post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""} -
-
{t("blog_read_more")}
-
- - ))} - {data.list.items.length === 0 ?

{t("blog_no_posts")}

: null} -
- `/blog/?page=${p}`} /> -
- - ); + return ; } diff --git a/src/routes/blog.page.$page.tsx b/src/routes/blog.page.$page.tsx new file mode 100644 index 000000000..7d74f72f0 --- /dev/null +++ b/src/routes/blog.page.$page.tsx @@ -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 ; +} diff --git a/src/server/data.ts b/src/server/data.ts index 922284543..a00024a72 100644 --- a/src/server/data.ts +++ b/src/server/data.ts @@ -78,6 +78,7 @@ export async function getPublishedContent(slug: string, type?: string) { seoDescription: contents.seoDescription, meta: contents.meta, publishedAt: contents.publishedAt, + updatedAt: contents.updatedAt, featuredUrl: media.url, featuredAlt: media.altText, })