v0.10.26: /blog/ 归档(archive_cards 皮肤/BlogArchive/WP 分页窗口)+ 文章详情 single 模板(hero/面包屑/三列正文+侧栏)+ page_css 全站导入(254篇) + 图片 WP 尺寸变体解析
This commit is contained in:
@@ -114,6 +114,18 @@
|
|||||||
- 已知数据差异:smart-home-solution 原站 2026-10-16 改版新增内层版块(2faa053 等不在
|
- 已知数据差异:smart-home-solution 原站 2026-10-16 改版新增内层版块(2faa053 等不在
|
||||||
dump 快照),9dc5c01 场景区较原站矮约 1540px,属快照滞后;其余 13 个落地页全部版块
|
dump 快照),9dc5c01 场景区较原站矮约 1540px,属快照滞后;其余 13 个落地页全部版块
|
||||||
齐全,逐节高度差 >15px 的多数为原站 headless lazy/swiper 未初始化假象。
|
齐全,逐节高度差 >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-<id>.css 已导入,355 篇原站 404(无自定义 CSS)。
|
||||||
|
- 已知数据差异:/blog/ 本地 category-blog 302 篇(26 页)vs 原站当前 7 页(dump 后原站
|
||||||
|
调整了分类成员);文章内文逐节高度仍有数据快照/lazy 假象差。
|
||||||
|
|
||||||
### 3. 商城/产品/博客(WordPress 模板页)
|
### 3. 商城/产品/博客(WordPress 模板页)
|
||||||
- SH1 商城:`/shop/` 分类+商品网格
|
- SH1 商城:`/shop/` 分类+商品网格
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dusuniot-rebuild",
|
"name": "dusuniot-rebuild",
|
||||||
"version": "0.10.25",
|
"version": "0.10.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -113,30 +113,49 @@ function filterCss(css: string, ids: Set<string>, pid: number): string {
|
|||||||
|
|
||||||
const slugs = process.argv.slice(2).length ? process.argv.slice(2) : SOLUTION_SLUGS;
|
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 });
|
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) {
|
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 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];
|
const row = rows[0];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
console.log("SKIP(无内容):", slug);
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const meta = JSON.parse(row.meta);
|
const meta = JSON.parse(row.meta);
|
||||||
|
if (meta.page_css !== undefined) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const ids = collectIds(meta.elementor);
|
const ids = collectIds(meta.elementor);
|
||||||
const pid = row.legacy_id;
|
const pid = row.legacy_id;
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
console.log("SKIP(无 legacy_id):", slug);
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const res = await fetch(CSS_URL(pid), { headers: { "User-Agent": "Mozilla/5.0" } });
|
// 重试 3 次、间隔 300ms(原站偶发 ECONNRESET/限流)
|
||||||
if (!res.ok) {
|
let raw: string | null = null;
|
||||||
console.log("SKIP(css", res.status, "):", slug, CSS_URL(pid));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const raw = await res.text();
|
|
||||||
const pageCss = filterCss(raw, ids, pid);
|
const pageCss = filterCss(raw, ids, pid);
|
||||||
meta.page_css = pageCss;
|
meta.page_css = pageCss;
|
||||||
// ::text::jsonb 双重转换:Bun SQL 绑定字符串直接 ::jsonb 会把整串存成 JSON 字符串值
|
// ::text::jsonb 双重转换:Bun SQL 绑定字符串直接 ::jsonb 会把整串存成 JSON 字符串值
|
||||||
await sql`update contents set meta = ${JSON.stringify(meta)}::text::jsonb where id = ${row.id}`;
|
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();
|
await sql.end();
|
||||||
|
|||||||
@@ -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<ReturnType<typeof getLayoutData>>;
|
||||||
|
list: Awaited<ReturnType<typeof listPosts>>;
|
||||||
|
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 (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<style>{BLOG_ARCHIVE_CSS}</style>
|
||||||
|
<div className="ds-blogarch">
|
||||||
|
<div className="elementor-location-archive">
|
||||||
|
{/* hero:灰底 Blog 大标题 */}
|
||||||
|
<section className="ds-hero">
|
||||||
|
<div className="ds-hero-container">
|
||||||
|
<div className="ds-hero-col">
|
||||||
|
<h1 className="ds-heading">Blog</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/* 卡片归档 */}
|
||||||
|
<section className="ds-posts-section">
|
||||||
|
<div className="ds-container">
|
||||||
|
<div className="ds-col">
|
||||||
|
<div className="ds-widget-wrap">
|
||||||
|
<div className="ds-posts">
|
||||||
|
{list.items.map((post) => (
|
||||||
|
<article className="ds-post" key={post.id}>
|
||||||
|
<div className="ds-post-card">
|
||||||
|
<Link to="/blog/$slug/" params={{ slug: post.slug }} className="ds-thumb-link" tabIndex={-1}>
|
||||||
|
<div className="ds-thumb">
|
||||||
|
{post.imageUrl ? <img src={post.imageUrl} alt={post.title} width={600} height={400} loading="lazy" decoding="async" /> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{post.categoryName ? <div className="ds-badge">{post.categoryName}</div> : null}
|
||||||
|
<div className="ds-post-text">
|
||||||
|
<h3 className="ds-post-title">
|
||||||
|
<Link to="/blog/$slug/" params={{ slug: post.slug }}>{post.title}</Link>
|
||||||
|
</h3>
|
||||||
|
{post.excerpt ? (
|
||||||
|
<div className="ds-post-excerpt">
|
||||||
|
<p>{trimExcerpt(post.excerpt, EXCERPT_WORDS)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="ds-read-more">
|
||||||
|
<Link to="/blog/$slug/" params={{ slug: post.slug }}>{t("blog_read_more")} »</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ds-meta">
|
||||||
|
{post.authorName ? <span className="ds-author">{post.authorName}</span> : null}
|
||||||
|
<span className="ds-date">{fmtDate(post.publishedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{list.items.length === 0 ? <p className="ds-empty">{t("blog_no_posts")}</p> : null}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<nav className="ds-pagination" aria-label="Pagination">
|
||||||
|
{page > 1 ? (
|
||||||
|
<a className="ds-page-num ds-prev" href={href(page - 1)}>« Previous</a>
|
||||||
|
) : (
|
||||||
|
<span className="ds-page-num ds-prev">« Previous</span>
|
||||||
|
)}
|
||||||
|
{pageWindow(totalPages, page).map((p) =>
|
||||||
|
p === "dots" ? (
|
||||||
|
<span key="dots" className="ds-page-num ds-dots">…</span>
|
||||||
|
) : p === page ? (
|
||||||
|
<span key={p} aria-current="page" className="ds-page-num ds-current">
|
||||||
|
<span className="ds-screen-only">Page</span>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a key={p} className="ds-page-num" href={href(p)}>
|
||||||
|
<span className="ds-screen-only">Page</span>
|
||||||
|
{p}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{page < totalPages ? (
|
||||||
|
<a className="ds-page-num ds-next" href={href(page + 1)}>Next »</a>
|
||||||
|
) : (
|
||||||
|
<span className="ds-page-num ds-next">Next »</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 常量 博客归档页样式(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%; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<style>{POST_HERO_CSS}</style>
|
||||||
|
<div className="ds-posthero">
|
||||||
|
<section className="ds-hero">
|
||||||
|
<div className="ds-container">
|
||||||
|
<div className="ds-inner">
|
||||||
|
<div className="ds-col-l">
|
||||||
|
<h1 className="ds-title">{post.title}</h1>
|
||||||
|
{post.excerpt ? <div className="ds-excerpt">{post.excerpt}</div> : null}
|
||||||
|
<ul className="ds-info">
|
||||||
|
{fmtDate(post.updatedAt) ? (
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 512 512" className="ds-ico" aria-hidden><path d="M256.455 8c66.269.119 126.437 26.233 170.859 68.685l35.715-35.715C478.149 25.851 504 36.559 504 57.941V192c0 13.255-10.745 24-24 24H345.941c-21.382 0-32.09-25.851-16.971-40.971l41.75-41.75c-30.864-28.899-70.801-44.907-113.23-45.273-92.398-.798-170.283 73.977-169.484 169.442C88.764 348.009 162.184 424 256 424c41.127 0 79.127-15.6 107.819-41.236l35.715 35.715C355.149 461.24 307.499 488 256 488 119.034 488 8.001 376.967 8 240.002 7.999 103.036 119.033 7.994 256.455 8z" /></svg>
|
||||||
|
<span className="ds-info-text">{fmtDate(post.updatedAt)}</span>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{fmtDate(post.publishedAt) ? (
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 448 512" className="ds-ico" aria-hidden><path d="M0 464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V192H0v272zm64-192c0-8.8 7.2-16 16-16h96c8.8 0 16 7.2 16 16v96c0 8.8-7.2 16-16 16H80c-8.8 0-16-7.2-16-16v-96zM400 64h-48V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H160V16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v48H48C21.5 64 0 85.5 0 112v48h448v-48c0-26.5-21.5-48-48-48z" /></svg>
|
||||||
|
<span className="ds-info-text">{fmtDate(post.publishedAt)}</span>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="ds-col-r">
|
||||||
|
{post.featuredUrl ? <img src={post.featuredUrl} alt={post.featuredAlt || post.title} width={800} height={450} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="ds-strip">
|
||||||
|
<div className="ds-strip-container">
|
||||||
|
<nav className="ds-breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<Link to="/">Home</Link>
|
||||||
|
{" / "}
|
||||||
|
<Link to="/blog/">Blog</Link>
|
||||||
|
{" / "}
|
||||||
|
<span>{post.title}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 常量 文章 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%; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
import { listSolutionLandings } from "~/server/data";
|
import { listSolutionLandings } from "~/server/data";
|
||||||
|
import { pageWindow } from "~/components/website/BlogArchive";
|
||||||
import { useT } from "~/i18n";
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
/** # 类型 方案归档页 props **/
|
/** # 类型 方案归档页 props **/
|
||||||
@@ -101,8 +102,10 @@ export function SolutionArchive({ data }: { data: { layout: Awaited<ReturnType<t
|
|||||||
) : (
|
) : (
|
||||||
<span className="ds-page-num ds-prev">« Previous</span>
|
<span className="ds-page-num ds-prev">« Previous</span>
|
||||||
)}
|
)}
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) =>
|
{pageWindow(totalPages, page).map((p) =>
|
||||||
p === page ? (
|
p === "dots" ? (
|
||||||
|
<span key="dots" className="ds-page-num ds-dots">…</span>
|
||||||
|
) : p === page ? (
|
||||||
<span key={p} aria-current="page" className="ds-page-num ds-current">
|
<span key={p} aria-current="page" className="ds-page-num ds-current">
|
||||||
<span className="ds-screen-only">Page</span>
|
<span className="ds-screen-only">Page</span>
|
||||||
{p}
|
{p}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const en = {
|
|||||||
|
|
||||||
/** 博客 */
|
/** 博客 */
|
||||||
blog_badge: "Blog",
|
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_no_posts: "No blog posts yet — content is being imported. Please check back soon.",
|
||||||
blog_toc: "Table of Contents",
|
blog_toc: "Table of Contents",
|
||||||
blog_related_posts: "Related Posts",
|
blog_related_posts: "Related Posts",
|
||||||
|
|||||||
@@ -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 AdminOrdersIdRouteImport } from './routes/admin.orders.$id'
|
||||||
import { Route as AdminProductsIndexRouteImport } from './routes/admin.products.index'
|
import { Route as AdminProductsIndexRouteImport } from './routes/admin.products.index'
|
||||||
import { Route as AdminProductsIdRouteImport } from './routes/admin.products.$id'
|
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'
|
import { Route as SolutionPagePageRouteImport } from './routes/solution.page.$page'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -203,6 +204,11 @@ const AdminProductsIdRoute = AdminProductsIdRouteImport.update({
|
|||||||
path: '/products/$id',
|
path: '/products/$id',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const BlogPagePageRoute = BlogPagePageRouteImport.update({
|
||||||
|
id: '/blog/page/$page',
|
||||||
|
path: '/blog/page/$page',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const SolutionPagePageRoute = SolutionPagePageRouteImport.update({
|
const SolutionPagePageRoute = SolutionPagePageRouteImport.update({
|
||||||
id: '/page/$page',
|
id: '/page/$page',
|
||||||
path: '/page/$page',
|
path: '/page/$page',
|
||||||
@@ -239,6 +245,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/contents/$id': typeof AdminContentsIdRoute
|
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||||
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||||
'/admin/products/$id': typeof AdminProductsIdRoute
|
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||||
|
'/blog/page/$page': typeof BlogPagePageRoute
|
||||||
'/solution/page/$page': typeof SolutionPagePageRoute
|
'/solution/page/$page': typeof SolutionPagePageRoute
|
||||||
'/admin/contents/': typeof AdminContentsIndexRoute
|
'/admin/contents/': typeof AdminContentsIndexRoute
|
||||||
'/admin/orders/': typeof AdminOrdersIndexRoute
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
@@ -272,6 +279,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin/contents/$id': typeof AdminContentsIdRoute
|
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||||
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||||
'/admin/products/$id': typeof AdminProductsIdRoute
|
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||||
|
'/blog/page/$page': typeof BlogPagePageRoute
|
||||||
'/solution/page/$page': typeof SolutionPagePageRoute
|
'/solution/page/$page': typeof SolutionPagePageRoute
|
||||||
'/admin/contents': typeof AdminContentsIndexRoute
|
'/admin/contents': typeof AdminContentsIndexRoute
|
||||||
'/admin/orders': typeof AdminOrdersIndexRoute
|
'/admin/orders': typeof AdminOrdersIndexRoute
|
||||||
@@ -308,6 +316,7 @@ export interface FileRoutesById {
|
|||||||
'/admin/contents/$id': typeof AdminContentsIdRoute
|
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||||
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||||
'/admin/products/$id': typeof AdminProductsIdRoute
|
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||||
|
'/blog/page/$page': typeof BlogPagePageRoute
|
||||||
'/solution/page/$page': typeof SolutionPagePageRoute
|
'/solution/page/$page': typeof SolutionPagePageRoute
|
||||||
'/admin/contents/': typeof AdminContentsIndexRoute
|
'/admin/contents/': typeof AdminContentsIndexRoute
|
||||||
'/admin/orders/': typeof AdminOrdersIndexRoute
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
@@ -345,6 +354,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/contents/$id'
|
| '/admin/contents/$id'
|
||||||
| '/admin/orders/$id'
|
| '/admin/orders/$id'
|
||||||
| '/admin/products/$id'
|
| '/admin/products/$id'
|
||||||
|
| '/blog/page/$page'
|
||||||
| '/solution/page/$page'
|
| '/solution/page/$page'
|
||||||
| '/admin/contents/'
|
| '/admin/contents/'
|
||||||
| '/admin/orders/'
|
| '/admin/orders/'
|
||||||
@@ -378,6 +388,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/contents/$id'
|
| '/admin/contents/$id'
|
||||||
| '/admin/orders/$id'
|
| '/admin/orders/$id'
|
||||||
| '/admin/products/$id'
|
| '/admin/products/$id'
|
||||||
|
| '/blog/page/$page'
|
||||||
| '/solution/page/$page'
|
| '/solution/page/$page'
|
||||||
| '/admin/contents'
|
| '/admin/contents'
|
||||||
| '/admin/orders'
|
| '/admin/orders'
|
||||||
@@ -413,6 +424,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/contents/$id'
|
| '/admin/contents/$id'
|
||||||
| '/admin/orders/$id'
|
| '/admin/orders/$id'
|
||||||
| '/admin/products/$id'
|
| '/admin/products/$id'
|
||||||
|
| '/blog/page/$page'
|
||||||
| '/solution/page/$page'
|
| '/solution/page/$page'
|
||||||
| '/admin/contents/'
|
| '/admin/contents/'
|
||||||
| '/admin/orders/'
|
| '/admin/orders/'
|
||||||
@@ -437,6 +449,7 @@ export interface RootRouteChildren {
|
|||||||
ProductSlugRoute: typeof ProductSlugRoute
|
ProductSlugRoute: typeof ProductSlugRoute
|
||||||
BlogIndexRoute: typeof BlogIndexRoute
|
BlogIndexRoute: typeof BlogIndexRoute
|
||||||
CollectionIndexRoute: typeof CollectionIndexRoute
|
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||||
|
BlogPagePageRoute: typeof BlogPagePageRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -665,6 +678,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminProductsIdRouteImport
|
preLoaderRoute: typeof AdminProductsIdRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
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': {
|
'/solution/page/$page': {
|
||||||
id: '/solution/page/$page'
|
id: '/solution/page/$page'
|
||||||
path: '/page/$page'
|
path: '/page/$page'
|
||||||
@@ -743,6 +763,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ProductSlugRoute: ProductSlugRoute,
|
ProductSlugRoute: ProductSlugRoute,
|
||||||
BlogIndexRoute: BlogIndexRoute,
|
BlogIndexRoute: BlogIndexRoute,
|
||||||
CollectionIndexRoute: CollectionIndexRoute,
|
CollectionIndexRoute: CollectionIndexRoute,
|
||||||
|
BlogPagePageRoute: BlogPagePageRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -11,8 +11,80 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
import { getPublishedContent, getMenuTree, listPosts, type MenuNode, type PostListItem } from "~/server/data";
|
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";
|
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) */
|
/** # 组件 社交品牌图标(lucide 新版已移除品牌图标,内联 SVG) */
|
||||||
function BrandIcon(props: { label: string; bg: string; path: string }) {
|
function BrandIcon(props: { label: string; bg: string; path: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -35,8 +107,9 @@ export const Route = createFileRoute("/blog/$slug")({
|
|||||||
// 侧栏 IoT Gateways 折叠组 = 主菜单 Products 分支的子分组(By Protocols / Applications ...)
|
// 侧栏 IoT Gateways 折叠组 = 主菜单 Products 分支的子分组(By Protocols / Applications ...)
|
||||||
const gwGroups = productMenu.find((n) => n.title.trim().toLowerCase() === "products")?.children ?? [];
|
const gwGroups = productMenu.find((n) => n.title.trim().toLowerCase() === "products")?.children ?? [];
|
||||||
if (!post) return { layout, post: null, related: [] as PostListItem[], gwGroups };
|
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;
|
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 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 !== params.slug).slice(0, 4);
|
||||||
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
||||||
@@ -70,11 +143,26 @@ function BlogPostPage() {
|
|||||||
const post = data.post;
|
const post = data.post;
|
||||||
const cats = post.terms.filter((t) => t.taxonomy === "category");
|
const cats = post.terms.filter((t) => t.taxonomy === "category");
|
||||||
const elementor = extractElementor(post.meta);
|
const elementor = extractElementor(post.meta);
|
||||||
// Elementor 文章(原站 full-canvas 模板):正文通栏直出,不加面包屑/h1/日期
|
// Elementor 文章(原站 single 模板):hero + 面包屑条 + 三列正文
|
||||||
|
// (cd804a1 取证 2026-11-03:分享栏 60px / 正文 776px / 侧栏 360px,容器 1200)
|
||||||
if (elementor) {
|
if (elementor) {
|
||||||
return (
|
return (
|
||||||
<Layout layout={data.layout}>
|
<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>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-82
@@ -1,96 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* # 路由 博客列表 /blog/
|
* # 路由 博客归档 /blog/
|
||||||
* 文章卡片分页列表(图/标题/摘要/日期)。
|
* 原站 Elementor archive-posts archive_cards 皮肤一比一复刻(BlogArchive 组件,第 1 页)。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { BlogArchive, loadBlogArchive } from "~/components/website/BlogArchive";
|
||||||
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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/blog/")({
|
export const Route = createFileRoute("/blog/")({
|
||||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
loader: () => loadBlogArchive(1),
|
||||||
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 };
|
|
||||||
},
|
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
||||||
{ name: "description", content: "IoT guides, industry insights, product news and tutorials from Dusun IoT experts." },
|
{ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** # 组件 博客列表页 */
|
/** # 组件 博客归档页(第 1 页) */
|
||||||
function BlogIndexPage() {
|
function BlogArchivePage() {
|
||||||
const t = useT();
|
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
const page = data.list.page;
|
return <BlogArchive data={data} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ export async function getPublishedContent(slug: string, type?: string) {
|
|||||||
seoDescription: contents.seoDescription,
|
seoDescription: contents.seoDescription,
|
||||||
meta: contents.meta,
|
meta: contents.meta,
|
||||||
publishedAt: contents.publishedAt,
|
publishedAt: contents.publishedAt,
|
||||||
|
updatedAt: contents.updatedAt,
|
||||||
featuredUrl: media.url,
|
featuredUrl: media.url,
|
||||||
featuredAlt: media.altText,
|
featuredAlt: media.altText,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user