v0.10.25: /solution/ 归档+落地页复刻(archive 组件/分页/摘录24词)、Header 全断点对齐、page_css 导入管线、reviews/populated 宽度/_css_classes/tabs HTML/图片变体修复
This commit is contained in:
@@ -93,8 +93,27 @@
|
||||
srcset/medium 变体,个别卡片高度 ±5px。
|
||||
|
||||
### 2. 方案页
|
||||
- S1 `/solution/` 列表入口页
|
||||
- S2 `/solution/$slug` 落地页:与首页同款 Elementor 直出,版块逐页不同
|
||||
- S1 `/solution/` 列表入口页 v0.10.25 ✅ 一比一复刻原站 Elementor archive(widget 2da75291):
|
||||
hero #eee 120px(h1 40px/900 uppercase 居中)+ 卡片 grid 3/2/1 列 gap 28 + 缩略图 66%/50% 占位、
|
||||
::after 黑 35%→75% 渐变(hover 1→0.5)+ 卡片阴影 0 0 10px(hover 30px)+ 标题 21px #ED6C00 +
|
||||
摘录 14px/1.5 #777(24 词截断, 相连算一词)+ 分页(当前页 #FFA11B);新增
|
||||
/solution/page/N/ 分页路由与摘录/卡片数据(8 篇 excerpt 按原站回填);桌面/平板/移动三断点
|
||||
hero/卡片/分页几何差 ≤2px。
|
||||
- S2 `/solution/$slug` 落地页 v0.10.25 ✅ 渲染器补齐:
|
||||
- Renderer:populated 宽度改标准块级 auto(margin 收缩,6c765c4 卡列 272/272 精确)、
|
||||
tabs 标题按 HTML 渲染、_css_classes 落到 section/column/class 上;
|
||||
- page_css 导入管线(scripts/import-page-css.ts):抓原站 post-<legacy_id>.css,把
|
||||
.elementor-element-<id> 与 .ele-* 自定类规则翻译为 [data-el] 选择器存 meta.page_css,
|
||||
ElementorRenderer 注入(补齐 dump 缺失的 widget 级 custom CSS,如 infobox 图 80px);
|
||||
- reviews 评价区重写为 Elementor 原生 reviews DOM(2 列 slide、79px 圆形头像、
|
||||
星 14px #CCD6DF、职位 16px #697882、评价图 50% 居中);
|
||||
- Header 全断点对齐原站(topbar 36px/容器 1350/文字 16px、图标 36×36 gap10 顺序
|
||||
Linkedin-Youtube-Facebook、无 Login;主导航容器 40/25、logo 后 80px、菜单序
|
||||
Resources→Success Stories 修正、菜单项 pad 8/6 距 6、caret 16px、搜索 40×68、
|
||||
CONTACT US/邮件/语言 48px;平板汉堡形态、移动 48px 导航+气泡);菜单序为 DB 数据修正。
|
||||
- 已知数据差异:smart-home-solution 原站 2026-10-16 改版新增内层版块(2faa053 等不在
|
||||
dump 快照),9dc5c01 场景区较原站矮约 1540px,属快照滞后;其余 13 个落地页全部版块
|
||||
齐全,逐节高度差 >15px 的多数为原站 headless lazy/swiper 未初始化假象。
|
||||
|
||||
### 3. 商城/产品/博客(WordPress 模板页)
|
||||
- SH1 商城:`/shop/` 分类+商品网格
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dusuniot-rebuild",
|
||||
"version": "0.10.24",
|
||||
"version": "0.10.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* # 脚本 导入原站页面级生成 CSS(post-<legacy_id>.css)到 contents.meta.page_css
|
||||
*
|
||||
* 背景:DB dump 的 meta.elementor 缺少 widget 级 custom_css(如 EAEL infobox 的
|
||||
* `.elementor-element-7f1dc73 .eael-infobox .infobox-icon img{width:80px}`),这些规则
|
||||
* 只存在于原站 Elementor 生成的页面样式表 post-<post_id>.css 中。
|
||||
*
|
||||
* 做法:
|
||||
* 1. 抓 https://www.dusuniot.com/wp-content/uploads/elementor/css/post-<legacy_id>.css
|
||||
* 2. 只保留选择器含 .elementor-element-<id>(id 属于本文档)的规则(含 @media 块内),
|
||||
* 把 `.elementor-element.elementor-element-<id>` / `.elementor-element-<id>` 改写为
|
||||
* `[data-el="<id>"]`(与渲染器 DOM 对应),剥掉 `.elementor-<pid>` 页面作用域前缀
|
||||
* 3. 拼接后写回 meta.page_css,渲染器(ElementorRenderer pageCss prop)注入 <style>
|
||||
*
|
||||
* 用法:bun scripts/import-page-css.ts <slug> [<slug> ...] (缺省 = 全部方案落地页)
|
||||
*/
|
||||
import { SQL } from "bun";
|
||||
|
||||
/** # 常量 方案落地页 slug 全集(缺省导入目标) **/
|
||||
const SOLUTION_SLUGS = [
|
||||
"smart-apartment-gateway",
|
||||
"smart-apartment-gateway-oem",
|
||||
"oil-gas-management-solution",
|
||||
"energy-storage-ems-solution",
|
||||
"smart-robot-controller-solution",
|
||||
"vehicle-iot-solution",
|
||||
"smart-building-solution",
|
||||
"smart-retail-solution",
|
||||
"smart-home-solution",
|
||||
"smart-apartment-solution",
|
||||
"smart-elderly-care-solution",
|
||||
"ble-positioning-solution",
|
||||
"ble-mesh-lighting-control-solution",
|
||||
"home-assistant-grow-lighting-solution",
|
||||
];
|
||||
|
||||
/** # 常量 原站页面 CSS 地址模板 **/
|
||||
const CSS_URL = (pid: number) => `https://www.dusuniot.com/wp-content/uploads/elementor/css/post-${pid}.css`;
|
||||
|
||||
/** # 函数 提取文档全部元素 id 集合(含嵌套;顶层为数组或 {elements} 包装均可) **/
|
||||
function collectIds(el: unknown, out: Set<string> = new Set()): Set<string> {
|
||||
if (Array.isArray(el)) {
|
||||
for (const x of el) collectIds(x, out);
|
||||
return out;
|
||||
}
|
||||
if (!el || typeof el !== "object") return out;
|
||||
const e = el as { id?: unknown; elements?: unknown[] };
|
||||
if (typeof e.id === "string") out.add(e.id);
|
||||
for (const c of Array.isArray(e.elements) ? e.elements : []) collectIds(c, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** # 函数 把选择器中的 elementor-element-<id> 改写为 data-el 属性选择器
|
||||
* 规则保留条件:含文档 id,或引用 .ele-<自定类>(页面自定义类,渲染器把 _css_classes
|
||||
* 落到节点 class 上,如 .ele-sol-review-box / .ele-secondDep-dif);其余丢弃
|
||||
*/
|
||||
function translateSelector(sel: string, ids: Set<string>, pid: number): string | null {
|
||||
const idIn = [...sel.matchAll(/elementor-element-(\w+)/g)].map((m) => m[1]);
|
||||
const hasDocId = idIn.some((id) => ids.has(id));
|
||||
const hasCustomCls = /\.ele-[a-z][\w-]*/i.test(sel) && !/elementor-element-/.test(sel);
|
||||
if (!hasDocId && !hasCustomCls) return null;
|
||||
let out = sel;
|
||||
for (const id of new Set(idIn)) {
|
||||
if (!ids.has(id)) continue;
|
||||
out = out.split(`.elementor-element.elementor-element-${id}`).join(`[data-el="${id}"]`);
|
||||
out = out.split(`.elementor-element-${id}`).join(`[data-el="${id}"]`);
|
||||
}
|
||||
// 剥页面作用域前缀(.elementor-<pid> 空格相连)与多余空白
|
||||
out = out.replace(new RegExp(`\\.elementor-${pid}\\s*`, "g"), "");
|
||||
return out.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** # 函数 解析 CSS 顶层规则(含 @media 嵌套块),返回过滤改写后的规则串
|
||||
* 用花括号配对扫描,避免依赖完整 CSS 解析器
|
||||
*/
|
||||
function filterCss(css: string, ids: Set<string>, pid: number): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
const n = css.length;
|
||||
while (i < n) {
|
||||
// 跳过注释
|
||||
if (css.startsWith("/*", i)) {
|
||||
const end = css.indexOf("*/", i + 2);
|
||||
i = end === -1 ? n : end + 2;
|
||||
continue;
|
||||
}
|
||||
const brace = css.indexOf("{", i);
|
||||
if (brace === -1) break;
|
||||
const sel = css.slice(i, brace).trim();
|
||||
// 找配对右括号
|
||||
let depth = 1;
|
||||
let j = brace + 1;
|
||||
while (j < n && depth > 0) {
|
||||
if (css[j] === "{") depth++;
|
||||
else if (css[j] === "}") depth--;
|
||||
j++;
|
||||
}
|
||||
const body = css.slice(brace + 1, j - 1);
|
||||
if (sel.startsWith("@media") || sel.startsWith("@supports")) {
|
||||
const inner = filterCss(body, ids, pid);
|
||||
if (inner) out += `${sel}{${inner}}\n`;
|
||||
} else if (sel.startsWith("@keyframes") || sel.startsWith("@font-face")) {
|
||||
// 全局块整体保留(字体/动画与节点无关)
|
||||
out += `${sel}{${body}}\n`;
|
||||
} else {
|
||||
const parts = sel.split(",").map((p) => translateSelector(p, ids, pid)).filter((p): p is string => !!p);
|
||||
if (parts.length) out += `${parts.join(",")}{${body}}\n`;
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 });
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
const meta = JSON.parse(row.meta);
|
||||
const ids = collectIds(meta.elementor);
|
||||
const pid = row.legacy_id;
|
||||
if (!pid) {
|
||||
console.log("SKIP(无 legacy_id):", slug);
|
||||
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));
|
||||
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);
|
||||
}
|
||||
await sql.end();
|
||||
@@ -291,15 +291,18 @@ export function ElementorRenderer({
|
||||
postsData,
|
||||
shortcodes,
|
||||
vars,
|
||||
pageCss,
|
||||
}: {
|
||||
doc: ElDocument;
|
||||
postsData?: Record<string, PostCardItem[]>;
|
||||
shortcodes?: Record<string, ReactNode>;
|
||||
vars?: RendererVars;
|
||||
/** # 成员变量 页面级生成 CSS(原站 post-<id>.css 翻译成 data-el 选择器,scripts/import-page-css.ts 导入) **/
|
||||
pageCss?: string;
|
||||
}) {
|
||||
const elements = normalizeDoc(doc);
|
||||
if (!elements.length) return null;
|
||||
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
|
||||
const css = (pageCss || "") + collectCustomCss(elements) + collectResponsiveCss(elements);
|
||||
return (
|
||||
<VarsContext.Provider value={vars || {}}>
|
||||
<PostsDataContext.Provider value={postsData || {}}>
|
||||
@@ -527,8 +530,10 @@ function SectionNode({ el }: { el: ElElement }) {
|
||||
if (overlay) style.position = style.position || "relative";
|
||||
const Tag = sectionTag(settings);
|
||||
const vis = visibilityClass(settings);
|
||||
// _css_classes(页面级 custom_css 的选择器挂在节点的 class 上,如 ele-secondDep-dif)
|
||||
const secCls = [vis, s(settings._css_classes)].filter(Boolean).join(" ") || undefined;
|
||||
return (
|
||||
<Tag className={vis || undefined} style={style} data-el={el.id}>
|
||||
<Tag className={secCls} style={style} data-el={el.id}>
|
||||
{overlay}
|
||||
{/* 行加 relative:与绝对定位遮罩同属定位层,DOM 靠后绘制在上,内容不被蒙灰 */}
|
||||
<div className="mx-auto flex flex-wrap" style={{ ...inner, position: "relative" }}>
|
||||
@@ -605,13 +610,13 @@ function ColumnNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string
|
||||
} else inner.borderWidth = "1px";
|
||||
}
|
||||
return (
|
||||
// 内层(populated)宽 100%:与列同宽、水平 margin 向右溢出(H4 卡实测 populated=272 含
|
||||
// margin 溢出);H11/H14/H15 等"扣除 margin"语义的列在 app.css 用 !important 覆写为 auto。
|
||||
// 空列(无子元素)裸渲染:原站空列无 populated 盒(H15 占位格 margin/边框均为 0),
|
||||
// 仅保留外层弹性宽度与拉伸高度
|
||||
<div className={"el-col min-w-0 " + visibilityClass(settings)} style={outer} data-el={el.id}>
|
||||
// 内层(populated)块级宽 auto:与原站 .elementor-element-populated 一致——水平 margin
|
||||
// 存在时块盒收缩进列宽(6c765c4 卡列 margin 8/8 实测 populated=294=310-16、图片 272);
|
||||
// margin 为 0 时与 100% 等价;负 margin 自然外扩(H4 图)。空列(无子元素)裸渲染:
|
||||
// 原站空列无 populated 盒(H15 占位格 margin/边框均为 0),仅保留外层弹性宽度与拉伸高度
|
||||
<div className={("el-col min-w-0 " + [visibilityClass(settings), s(settings._css_classes)].filter(Boolean).join(" ")).trimEnd()} style={outer} data-el={el.id}>
|
||||
{(el.elements || []).length ? (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignContent: "flex-start", width: "100%", ...inner }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignContent: "flex-start", ...inner }}>
|
||||
{(el.elements || []).map((c, i) => (
|
||||
<ElementNode key={c.id || i} el={c} gapPad={gapPad} />
|
||||
))}
|
||||
@@ -1610,29 +1615,63 @@ function TestimonialWidget({ settings }: { settings: Record<string, unknown> })
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 reviews(评价轮播):静态卡片网格(slides 复用:name/title/rating/content/image) */
|
||||
/** # 组件 reviews(Elementor 原生 reviews widget,swiper 轮播静态化)
|
||||
* 取证(smart-building 07ecb19,1440 实测 2026-11-03):
|
||||
* 外层左右 28px 留白(箭头槽);slide 宽 (100%-10)/2(slides_per_view=2、gap 10),
|
||||
* 白底 radius 5;header flex pad 20 20 18(图 79×79 圆形 + cite 左 20:姓名 24px/700 #1C2022、
|
||||
* 星 14px #CCD6DF 右距 5、职位 16px #697882);content pad 18 20 20,内容 HTML
|
||||
* (.ele-sol-review-box 等)由页面级 CSS(pageCss / custom_css)提供。
|
||||
*/
|
||||
function ReviewsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const slides = Array.isArray(settings.slides)
|
||||
? (settings.slides as { name?: string; title?: string; rating?: string; content?: string; image?: { url?: string; alt?: string } }[])
|
||||
? (settings.slides as { _id?: string; name?: string; title?: string; rating?: string; content?: string; image?: { url?: string; alt?: string } }[])
|
||||
: [];
|
||||
if (!slides.length) return null;
|
||||
const stars = (n: string) => "★★★★★".slice(0, Math.max(1, Math.min(5, Math.round(Number(n) || 5))));
|
||||
const perView = Math.max(1, Math.round(Number(s(settings.slides_per_view)) || 2));
|
||||
return (
|
||||
<div className="mb-[18px] grid gap-4 min-[760px]:grid-cols-2">
|
||||
<div className="el-reviews">
|
||||
<style>{`
|
||||
.el-reviews .el-swiper { padding: 0 28px; }
|
||||
.el-reviews .el-swiper-wrapper { display: grid; grid-template-columns: repeat(${perView}, 1fr); gap: 10px; }
|
||||
.el-reviews .swiper-slide { background: ${s(settings.slide_background_color) || "#fff"}; border-radius: 5px; }
|
||||
.el-reviews .elementor-testimonial__header { display: flex; align-items: center; padding: 20px 20px 18px; }
|
||||
.el-reviews .elementor-testimonial__image { flex: 0 0 auto; display: flex; }
|
||||
.el-reviews .elementor-testimonial__image img { display: block; width: 79px; height: 79px; border-radius: 999px; object-fit: cover; }
|
||||
.el-reviews .elementor-testimonial__cite { display: flex; flex-direction: column; margin: 0 0 0 20px; font-style: normal; }
|
||||
.el-reviews .elementor-testimonial__name { font-size: ${sizeCss(settings.name_typography_font_size) || "24px"}; line-height: 1.5; font-weight: 700; color: #1C2022; }
|
||||
.el-reviews .elementor-star-rating { font-size: 14px; line-height: 21px; color: #CCD6DF; white-space: nowrap; }
|
||||
.el-reviews .elementor-star-rating i { display: inline-block; font-style: normal; margin-right: 5px; }
|
||||
.el-reviews .elementor-testimonial__title { font-size: 16px; color: #697882; }
|
||||
.el-reviews .elementor-testimonial__content { padding: 18px 20px 20px; font-size: ${sizeCss(settings.content_typography_font_size) || "16px"}; line-height: ${sizeCss(settings.content_typography_line_height) || "24px"}; }
|
||||
`}</style>
|
||||
<div className="el-swiper">
|
||||
<div className="el-swiper-wrapper">
|
||||
{slides.map((it, i) => (
|
||||
<div key={i} className="rounded-md border p-4">
|
||||
<div className="text-[var(--brand)]">{stars(s(it.rating))}</div>
|
||||
{s(it.content) ? <div className="content-html mt-2" dangerouslySetInnerHTML={{ __html: s(it.content) }} /> : null}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
{it.image?.url ? <img src={it.image.url} alt={it.image.alt || it.name || ""} className="h-10 w-10 rounded-full object-cover" /> : null}
|
||||
<div>
|
||||
<div className="font-medium">{s(it.name)}</div>
|
||||
<div className="text-sm text-muted-foreground">{s(it.title)}</div>
|
||||
<div key={it._id || i} className="swiper-slide">
|
||||
<div className="elementor-testimonial">
|
||||
<div className="elementor-testimonial__header">
|
||||
<div className="elementor-testimonial__image">
|
||||
{it.image?.url ? <img src={it.image.url} alt={it.image.alt || it.name || ""} loading="lazy" /> : null}
|
||||
</div>
|
||||
<cite className="elementor-testimonial__cite">
|
||||
<span className="elementor-testimonial__name">{s(it.name)}</span>
|
||||
<div className="elementor-star-rating" aria-label={it.rating}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<i key={n} className={"elementor-star-" + (n <= Math.round(Number(it.rating) || 5) ? "full" : "empty")}>★</i>
|
||||
))}
|
||||
</div>
|
||||
<span className="elementor-testimonial__title">{s(it.title)}</span>
|
||||
</cite>
|
||||
</div>
|
||||
<div className="elementor-testimonial__content">
|
||||
<div className="elementor-testimonial__text" dangerouslySetInnerHTML={{ __html: localSiteUrl(s(it.content)) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1944,9 +1983,8 @@ function TabsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
tabIndex={i === active ? 0 : -1}
|
||||
aria-selected={i === active}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
{s(t.tab_title)}
|
||||
</div>
|
||||
dangerouslySetInnerHTML={{ __html: s(t.tab_title) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="elementor-tabs-content-wrapper" role="tablist">
|
||||
@@ -1959,9 +1997,8 @@ function TabsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
tabIndex={i === active ? 0 : -1}
|
||||
aria-selected={i === active}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
{s(t.tab_title)}
|
||||
</div>
|
||||
dangerouslySetInnerHTML={{ __html: s(t.tab_title) }}
|
||||
/>
|
||||
<div
|
||||
id={`elementor-tab-content-${base + i}`}
|
||||
className="elementor-tab-content elementor-clearfix"
|
||||
|
||||
@@ -126,6 +126,12 @@ const SETTING_KEYS = new Set([
|
||||
"social_icon_list",
|
||||
"slides",
|
||||
"slides_height",
|
||||
// reviews widget(评价轮播静态化;smart-building 07ecb19)
|
||||
"slides_per_view",
|
||||
"slide_background_color",
|
||||
"name_typography_font_size",
|
||||
"content_typography_font_size",
|
||||
"content_typography_line_height",
|
||||
"tabs",
|
||||
"tabs_align_horizontal",
|
||||
"tab_active_color",
|
||||
|
||||
@@ -77,8 +77,8 @@ function NavigationMenuHeading({ className, ...props }: React.ComponentProps<"di
|
||||
/** # 组件 实心下三角指示器(等价原站 dashicons arrow-down,展开不旋转,颜色随文字) */
|
||||
function CaretDown() {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="shrink-0">
|
||||
<path d="M4.5 7h11L10 13.5 4.5 7Z" />
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="shrink-0">
|
||||
<path d="M4.5 7.5h11L10 13.5 4.5 7.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Search, Menu, X, ChevronRight, Mail, User, Star } from "lucide-react";
|
||||
import { Search, Menu, X, ChevronRight, Mail, MessageCircleMore, Star } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
@@ -118,60 +118,68 @@ export function Header(props: { menu: MenuNode[] }) {
|
||||
const tops = items.filter((it) => it.title !== "Search");
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-[100] border-b border-line bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
|
||||
{/* topbar:口号 + 社交图标 + 登录 */}
|
||||
<div className="bg-footer text-[13px] text-white">
|
||||
<div className="site-container flex h-10 items-center justify-between">
|
||||
<a href="/landing-pages/oem-odm/" className="text-white hover:text-[#f5b278]">
|
||||
<header className="sticky top-0 z-[100] bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
|
||||
{/* topbar:口号 + 社交图标(原站实测 2026-11-03:高 36px、容器 1350、文字 16px/16、
|
||||
图标 36×36 盒 gap 10、顺序 Linkedin/Youtube/Facebook、无登录项;<768 隐藏) */}
|
||||
<div className="hidden bg-footer text-white min-[768px]:block">
|
||||
<div className="mx-auto flex h-9 w-full max-w-[1350px] items-center justify-between">
|
||||
<a href="/landing-pages/oem-odm/" className="text-[16px] leading-4 text-white hover:text-[#f5b278]">
|
||||
{t("topbar_oem")}
|
||||
</a>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
|
||||
<YoutubeGlyph />
|
||||
</a>
|
||||
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
|
||||
<FacebookGlyph />
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="flex h-9 w-9 items-center justify-center text-white hover:text-[#f5b278]">
|
||||
<LinkedinGlyph />
|
||||
</a>
|
||||
<span className="h-3.5 w-px bg-white/35" aria-hidden />
|
||||
<a href="/my-account/" className="inline-flex items-center gap-1 text-white hover:text-[#f5b278]">
|
||||
<User size={15} aria-hidden /> {t("login")}
|
||||
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="flex h-9 w-9 items-center justify-center text-white hover:text-[#f5b278]">
|
||||
<YoutubeGlyph />
|
||||
</a>
|
||||
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="flex h-9 w-9 items-center justify-center text-white hover:text-[#f5b278]">
|
||||
<FacebookGlyph />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主导航(原站实测:白底条高 68px,topbar 40px,合计 108px) */}
|
||||
<div className="site-container flex h-[68px] items-center gap-6">
|
||||
{/* 主导航(原站实测 2026-11-03:桌面 68px 容器 40/25、logo 后 80px 起菜单、项 pad 8/6 项距 6;
|
||||
平板 768-959 为汉堡导航(顶栏 36 + 导航 48,logo 缩放,右侧 CONTACT US+邮件);
|
||||
手机 <768 无顶栏、导航 48、汉堡在 logo 右 96px、右侧橙色气泡+邮件) */}
|
||||
<div className="flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:pl-10 min-[960px]:pr-[25px]">
|
||||
{/* 直接复用原站 header logo(Elementor site-logo 同一图片,实测渲染 171×42.75px) */}
|
||||
<Link to="/" className="shrink-0">
|
||||
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="h-auto w-[171px]" />
|
||||
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="block h-auto w-[109px] min-[768px]:w-[103px] min-[880px]:w-[124px] min-[960px]:w-[171px]" />
|
||||
</Link>
|
||||
<div className="ml-[80px] hidden min-w-0 min-[960px]:flex min-[960px]:flex-1 min-[960px]:items-center">
|
||||
<DesktopNav tops={tops} />
|
||||
{searchOpen ? (
|
||||
<form action="/search" method="get" className="flex items-center overflow-hidden rounded-full border border-line bg-soft">
|
||||
<form action="/search" method="get" className="ml-1.5 flex items-center overflow-hidden rounded-full border border-line bg-soft">
|
||||
<Input type="search" name="q" placeholder={t("search_placeholder")} aria-label="Search" autoFocus className="w-40 rounded-none border-0 bg-transparent focus-visible:border-0" />
|
||||
<button type="submit" aria-label="Submit search" className="flex cursor-pointer items-center border-0 bg-transparent px-2.5 py-1.5 text-muted-foreground">
|
||||
<Search size={16} aria-hidden />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button aria-label="Open search" onClick={() => setSearchOpen(true)} className="flex cursor-pointer items-center border-0 bg-transparent p-1.5 text-ink">
|
||||
<button aria-label="Open search" onClick={() => setSearchOpen(true)} className="ml-1.5 flex h-[68px] w-10 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-ink">
|
||||
<Search size={19} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<Button asChild className="hidden whitespace-nowrap px-[18px] py-[9px] min-[960px]:inline-flex">
|
||||
</div>
|
||||
{/* 汉堡:原站为 mega-toggle 块(手机位于 logo 右 96px、平板右聚拢),图标浅灰 */}
|
||||
<div className="ml-[96px] flex h-12 items-center justify-end min-[768px]:ml-auto min-[960px]:hidden">
|
||||
<button aria-label="Toggle menu" onClick={() => setOpen(!open)} className="flex h-12 w-10 cursor-pointer items-center justify-center rounded-[4px] border-0 bg-transparent p-0 text-[#c9c9c9]">
|
||||
{open ? <X size={32} aria-hidden /> : <Menu size={32} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 min-[960px]:hidden" aria-hidden />
|
||||
<a href="/contact-us/" aria-label="Contact us" className="flex h-[34px] w-[38px] items-center justify-center text-brand min-[768px]:hidden">
|
||||
<MessageCircleMore size={28} aria-hidden />
|
||||
</a>
|
||||
<Button asChild className="hidden whitespace-nowrap px-[18px] py-[9px] min-[768px]:inline-flex">
|
||||
<a href="/contact-us/">{t("contact_us")}</a>
|
||||
</Button>
|
||||
<a href="/contact-us/" aria-label="Email us" className="hidden items-center p-1 text-brand min-[960px]:flex">
|
||||
<Mail size={19} aria-hidden />
|
||||
<a href="/contact-us/" aria-label="Email us" className="flex h-10 w-[52px] items-center justify-center p-0 text-brand">
|
||||
<Mail size={28} aria-hidden />
|
||||
</a>
|
||||
<LangSwitcher />
|
||||
<button aria-label="Toggle menu" onClick={() => setOpen(!open)} className="ml-auto cursor-pointer rounded-[4px] border-0 bg-transparent p-1.5 text-ink min-[960px]:hidden">
|
||||
{open ? <X size={24} aria-hidden /> : <Menu size={24} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
{/* 移动端菜单 */}
|
||||
{open ? (
|
||||
@@ -201,7 +209,7 @@ export function Header(props: { menu: MenuNode[] }) {
|
||||
*/
|
||||
function DesktopNav({ tops }: { tops: MenuNode[] }) {
|
||||
return (
|
||||
<div className="hidden min-[960px]:flex min-[960px]:flex-1 min-[960px]:items-center gap-0.5">
|
||||
<div className="hidden min-[960px]:flex min-[960px]:items-center gap-1.5">
|
||||
{tops.map((node) => {
|
||||
const linkHref = TOP_URL_FIX[node.title] ?? normalizeUrl(node.url) ?? undefined;
|
||||
// 巨幕面板项(全宽视口)
|
||||
@@ -298,7 +306,7 @@ function TopTrigger({ title, href }: { title: string; href?: string }) {
|
||||
if (href) {
|
||||
return (
|
||||
<NavigationMenuTrigger asChild chevron={false}>
|
||||
<a href={href}>
|
||||
<a href={href} className="pl-2 pr-1.5">
|
||||
{title}
|
||||
<CaretDown />
|
||||
</a>
|
||||
|
||||
@@ -30,9 +30,9 @@ export function LangSwitcher() {
|
||||
aria-expanded={open}
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex cursor-pointer items-center border-0 bg-transparent p-1 text-brand"
|
||||
className="flex h-12 w-12 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-brand"
|
||||
>
|
||||
<Globe size={19} aria-hidden />
|
||||
<Globe size={28} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-50 max-h-[340px] w-[200px] overflow-auto rounded-md border border-line bg-white py-1 shadow-[0_8px_24px_rgba(16,42,76,0.14)]">
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* # 组件 方案归档页(/solution/ 与 /solution/page/N/ 共用)
|
||||
* 一比一复刻原站 Elementor archive-posts 归档(widget 2da75291,skin=cards):
|
||||
* hero 灰底 h1 "Solution" + 卡片网格(3/2/1 列,gap 28)+ 分页。
|
||||
* 全部几何/配色取自 2026-11-03 playwright 取证(桌面 1440/1280/1920、平板 1024、移动 390):
|
||||
* - hero:#eee 背景,上下 40px;h1 40px/40px 900 #000 居中;容器 1140+10 内边距
|
||||
* - 卡片区:白底;桌面左右 192px、平板 100px 25px、移动 0;外层 margin 64px 0(移动 0)
|
||||
* - 卡片:radius 3、阴影 0 0 10px rgba(0,0,0,.15)(hover 30px)、无边框
|
||||
* - 缩略图:图高按原比例置于顶部 + padding-bottom 66%(移动 50%)占位,::after 黑 35%→75% 透明渐变
|
||||
* (base opacity 1,hover 0.5,transition .3s ease-out)
|
||||
* - 标题 21px/1.2 600,链接 #ED6C00 400;摘录 14px/1.5 #777,底部 33px;文字区左右 30px
|
||||
* - 分页:居中,页码 margin 0 10px,当前页 #FFA11B,当前页/端点为 span 其余为 a
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { listSolutionLandings } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
/** # 类型 方案归档页 props **/
|
||||
export type SolutionArchiveProps = { page: number };
|
||||
|
||||
/** # 函数 加载方案归档数据(layout + 分页列表) **/
|
||||
export async function loadSolutionArchive(page: number) {
|
||||
const [layout, list] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listSolutionLandings(page, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
]);
|
||||
return { layout, list, page };
|
||||
}
|
||||
|
||||
/** # 常量 摘录显示词数上限(原站 archive-posts excerpt_length,取证:12 张卡片全部 ≤24 词、DB 全文更长) **/
|
||||
const EXCERPT_WORDS = 24;
|
||||
|
||||
/** # 函数 按词数截断摘录(WP wp_trim_words 语义:空白分词但 相连算一个词,无省略号后缀)
|
||||
* 参数 text DB 摘录全文;limit 词数上限
|
||||
* 返回值 截断后的显示文本
|
||||
*/
|
||||
function trimExcerpt(text: string, limit: number) {
|
||||
// \s 除 \u00a0 外的分隔符(PHP \s 不含 nbsp,"Smart Building" 是一个词)
|
||||
const words = text.split(/[^\S\u00a0]+/).filter(Boolean);
|
||||
return words.length > limit ? words.slice(0, limit).join(" ") : text;
|
||||
}
|
||||
|
||||
/** # 组件 方案归档页(原站 Elementor archive 一比一) */
|
||||
export function SolutionArchive({ data }: { data: { layout: Awaited<ReturnType<typeof getLayoutData>>; list: Awaited<ReturnType<typeof listSolutionLandings>>; page: number } }) {
|
||||
const t = useT();
|
||||
const { list, page } = data;
|
||||
const totalPages = Math.max(1, Math.ceil(list.total / list.pageSize));
|
||||
/** 分页链接(原站路径 /solution/ 与 /solution/page/N/) */
|
||||
const href = (p: number) => (p <= 1 ? "/solution/" : `/solution/page/${p}/`);
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<style>{SOLUTION_ARCHIVE_CSS}</style>
|
||||
<div className="ds-solarch">
|
||||
<div className="elementor-location-archive">
|
||||
{/* hero:灰底 Solution 大标题 */}
|
||||
<section className="ds-hero">
|
||||
<div className="ds-hero-container">
|
||||
<div className="ds-hero-col">
|
||||
<div className="ds-widget">
|
||||
<h1 className="ds-heading">{t("solution_title")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* 卡片归档 */}
|
||||
<section className="ds-posts-section">
|
||||
<div className="ds-container">
|
||||
<div className="ds-col">
|
||||
<div className="ds-widget-wrap">
|
||||
<div className="ds-widget ds-archive-posts">
|
||||
<div className="ds-posts">
|
||||
{list.items.map((post) => (
|
||||
<article className="ds-post" key={post.id}>
|
||||
<div className="ds-post-card">
|
||||
<Link to="/solution/$slug/" params={{ slug: post.slug }} className="ds-thumb-link" tabIndex={-1}>
|
||||
<div className="ds-thumb">
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt={post.imageAlt || post.title} width={600} height={400} loading="lazy" decoding="async" />
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="ds-post-text">
|
||||
<h3 className="ds-post-title">
|
||||
<Link to="/solution/$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>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) =>
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 常量 方案归档页样式(取证值;断点 1024 平板 / 767 移动) **/
|
||||
export const SOLUTION_ARCHIVE_CSS = `
|
||||
.ds-solarch .ds-hero { background: #eee; padding: 40px 0; }
|
||||
.ds-solarch .ds-hero-container { max-width: 1140px; margin: 0 auto; }
|
||||
.ds-solarch .ds-hero-col { padding: 0 10px; }
|
||||
.ds-solarch .ds-heading { margin: 0; font-size: 40px; font-weight: 900; line-height: 40px; color: #000; text-align: center; text-transform: uppercase; }
|
||||
.ds-solarch .ds-posts-section { background: #fff; margin: 64px 0; padding: 0 192px; }
|
||||
.ds-solarch .ds-container { display: flex; }
|
||||
.ds-solarch .ds-col { width: 100%; }
|
||||
.ds-solarch .ds-widget-wrap { padding: 10px; width: 100%; }
|
||||
.ds-solarch .ds-posts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; }
|
||||
.ds-solarch .ds-post { display: flex; }
|
||||
.ds-solarch .ds-post-card { 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-solarch .ds-post:hover .ds-post-card { box-shadow: 0 0 30px 0 rgba(0,0,0,0.15); }
|
||||
.ds-solarch .ds-thumb-link { position: relative; display: block; margin: 0 0 20px; color: #ED6C00; }
|
||||
.ds-solarch .ds-thumb { position: relative; overflow: hidden; padding-bottom: 66%; }
|
||||
.ds-solarch .ds-thumb img { display: block; width: calc(100% + 1px); height: auto; margin: 0; }
|
||||
.ds-solarch .ds-thumb-link::after { content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(0deg, rgba(0,0,0,0.35), rgba(0,0,0,0) 75%); opacity: 1; transition: opacity .3s ease-out; pointer-events: none; }
|
||||
.ds-solarch .ds-post:hover .ds-thumb-link::after { opacity: 0.5; }
|
||||
.ds-solarch .ds-post-text { margin: 10px 0 0; padding: 0 30px; }
|
||||
.ds-solarch .ds-post-title { margin: 0 0 12px; font-size: 21px; font-weight: 600; line-height: 1.2; color: #333; }
|
||||
.ds-solarch .ds-post-title a { color: #ED6C00; font-weight: 400; }
|
||||
.ds-solarch .ds-post-excerpt { margin: 0 0 33px; }
|
||||
.ds-solarch .ds-post-excerpt p { margin: 0; font-size: 14px; line-height: 1.5; color: #777; }
|
||||
.ds-solarch .ds-pagination { margin: 40px 0 0; font-size: 16px; line-height: 24px; text-align: center; }
|
||||
.ds-solarch .ds-page-num { display: inline; margin: 0 10px; color: #000; text-decoration: none; }
|
||||
.ds-solarch .ds-page-num.ds-prev { margin: 0 10px 0 0; }
|
||||
.ds-solarch .ds-page-num.ds-next { margin: 0 0 0 10px; }
|
||||
.ds-solarch .ds-page-num.ds-current { color: #FFA11B; }
|
||||
.ds-solarch .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-solarch .ds-posts-section { margin: 64px 0; padding: 100px 25px; }
|
||||
.ds-solarch .ds-posts { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.ds-solarch .ds-posts-section { margin: 0; padding: 0; }
|
||||
.ds-solarch .ds-posts { grid-template-columns: 1fr; }
|
||||
.ds-solarch .ds-thumb { padding-bottom: 50%; }
|
||||
}
|
||||
`;
|
||||
@@ -113,7 +113,7 @@ export const en = {
|
||||
nf_contact: "Contact Us",
|
||||
|
||||
/** 方案页 */
|
||||
solution_title: "SOLUTION",
|
||||
solution_title: "Solution",
|
||||
};
|
||||
|
||||
/** # 类型 词典形状(其它语言按 Partial<Dict> 提供即可) **/
|
||||
|
||||
+42
-2
@@ -33,6 +33,7 @@ import { Route as BlogSlugRouteImport } from './routes/blog.$slug'
|
||||
import { Route as CollectionIndexRouteImport } from './routes/collection.index'
|
||||
import { Route as CollectionSlugRouteImport } from './routes/collection.$slug'
|
||||
import { Route as ProductSlugRouteImport } from './routes/product.$slug'
|
||||
import { Route as SolutionIndexRouteImport } from './routes/solution.index'
|
||||
import { Route as SolutionSlugRouteImport } from './routes/solution.$slug'
|
||||
import { Route as AdminContentsIndexRouteImport } from './routes/admin.contents.index'
|
||||
import { Route as AdminContentsIdRouteImport } from './routes/admin.contents.$id'
|
||||
@@ -40,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 SolutionPagePageRouteImport } from './routes/solution.page.$page'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -161,6 +163,11 @@ const ProductSlugRoute = ProductSlugRouteImport.update({
|
||||
path: '/product/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SolutionIndexRoute = SolutionIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => SolutionRoute,
|
||||
} as any)
|
||||
const SolutionSlugRoute = SolutionSlugRouteImport.update({
|
||||
id: '/$slug',
|
||||
path: '/$slug',
|
||||
@@ -196,6 +203,11 @@ const AdminProductsIdRoute = AdminProductsIdRouteImport.update({
|
||||
path: '/products/$id',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const SolutionPagePageRoute = SolutionPagePageRouteImport.update({
|
||||
id: '/page/$page',
|
||||
path: '/page/$page',
|
||||
getParentRoute: () => SolutionRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -223,9 +235,11 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/blog/': typeof BlogIndexRoute
|
||||
'/collection/': typeof CollectionIndexRoute
|
||||
'/solution/': typeof SolutionIndexRoute
|
||||
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||
'/solution/page/$page': typeof SolutionPagePageRoute
|
||||
'/admin/contents/': typeof AdminContentsIndexRoute
|
||||
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||
'/admin/products/': typeof AdminProductsIndexRoute
|
||||
@@ -241,7 +255,6 @@ export interface FileRoutesByTo {
|
||||
'/search': typeof SearchRoute
|
||||
'/shop': typeof ShopRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/solution': typeof SolutionRouteWithChildren
|
||||
'/admin/filters': typeof AdminFiltersRoute
|
||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||
'/admin/languages': typeof AdminLanguagesRoute
|
||||
@@ -255,9 +268,11 @@ export interface FileRoutesByTo {
|
||||
'/admin': typeof AdminIndexRoute
|
||||
'/blog': typeof BlogIndexRoute
|
||||
'/collection': typeof CollectionIndexRoute
|
||||
'/solution': typeof SolutionIndexRoute
|
||||
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||
'/solution/page/$page': typeof SolutionPagePageRoute
|
||||
'/admin/contents': typeof AdminContentsIndexRoute
|
||||
'/admin/orders': typeof AdminOrdersIndexRoute
|
||||
'/admin/products': typeof AdminProductsIndexRoute
|
||||
@@ -289,9 +304,11 @@ export interface FileRoutesById {
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/blog/': typeof BlogIndexRoute
|
||||
'/collection/': typeof CollectionIndexRoute
|
||||
'/solution/': typeof SolutionIndexRoute
|
||||
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||
'/solution/page/$page': typeof SolutionPagePageRoute
|
||||
'/admin/contents/': typeof AdminContentsIndexRoute
|
||||
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||
'/admin/products/': typeof AdminProductsIndexRoute
|
||||
@@ -324,9 +341,11 @@ export interface FileRouteTypes {
|
||||
| '/admin/'
|
||||
| '/blog/'
|
||||
| '/collection/'
|
||||
| '/solution/'
|
||||
| '/admin/contents/$id'
|
||||
| '/admin/orders/$id'
|
||||
| '/admin/products/$id'
|
||||
| '/solution/page/$page'
|
||||
| '/admin/contents/'
|
||||
| '/admin/orders/'
|
||||
| '/admin/products/'
|
||||
@@ -342,7 +361,6 @@ export interface FileRouteTypes {
|
||||
| '/search'
|
||||
| '/shop'
|
||||
| '/sitemap.xml'
|
||||
| '/solution'
|
||||
| '/admin/filters'
|
||||
| '/admin/inquiries'
|
||||
| '/admin/languages'
|
||||
@@ -356,9 +374,11 @@ export interface FileRouteTypes {
|
||||
| '/admin'
|
||||
| '/blog'
|
||||
| '/collection'
|
||||
| '/solution'
|
||||
| '/admin/contents/$id'
|
||||
| '/admin/orders/$id'
|
||||
| '/admin/products/$id'
|
||||
| '/solution/page/$page'
|
||||
| '/admin/contents'
|
||||
| '/admin/orders'
|
||||
| '/admin/products'
|
||||
@@ -389,9 +409,11 @@ export interface FileRouteTypes {
|
||||
| '/admin/'
|
||||
| '/blog/'
|
||||
| '/collection/'
|
||||
| '/solution/'
|
||||
| '/admin/contents/$id'
|
||||
| '/admin/orders/$id'
|
||||
| '/admin/products/$id'
|
||||
| '/solution/page/$page'
|
||||
| '/admin/contents/'
|
||||
| '/admin/orders/'
|
||||
| '/admin/products/'
|
||||
@@ -587,6 +609,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProductSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/solution/': {
|
||||
id: '/solution/'
|
||||
path: '/'
|
||||
fullPath: '/solution/'
|
||||
preLoaderRoute: typeof SolutionIndexRouteImport
|
||||
parentRoute: typeof SolutionRoute
|
||||
}
|
||||
'/solution/$slug': {
|
||||
id: '/solution/$slug'
|
||||
path: '/$slug'
|
||||
@@ -636,6 +665,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminProductsIdRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/solution/page/$page': {
|
||||
id: '/solution/page/$page'
|
||||
path: '/page/$page'
|
||||
fullPath: '/solution/page/$page'
|
||||
preLoaderRoute: typeof SolutionPagePageRouteImport
|
||||
parentRoute: typeof SolutionRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,10 +711,14 @@ const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||
|
||||
interface SolutionRouteChildren {
|
||||
SolutionSlugRoute: typeof SolutionSlugRoute
|
||||
SolutionIndexRoute: typeof SolutionIndexRoute
|
||||
SolutionPagePageRoute: typeof SolutionPagePageRoute
|
||||
}
|
||||
|
||||
const SolutionRouteChildren: SolutionRouteChildren = {
|
||||
SolutionSlugRoute: SolutionSlugRoute,
|
||||
SolutionIndexRoute: SolutionIndexRoute,
|
||||
SolutionPagePageRoute: SolutionPagePageRoute,
|
||||
}
|
||||
|
||||
const SolutionRouteWithChildren = SolutionRoute._addFileChildren(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { getPublishedContent } from "~/server/data";
|
||||
import { resolveImageVariants } from "~/utils/image-variant";
|
||||
|
||||
export const Route = createFileRoute("/solution/$slug")({
|
||||
loader: async ({ params }) => {
|
||||
@@ -15,6 +16,8 @@ export const Route = createFileRoute("/solution/$slug")({
|
||||
]);
|
||||
if (!page || !extractElementor(page.meta)) return { layout, page: null };
|
||||
const meta = (page.meta ?? null) as Record<string, unknown> | null;
|
||||
// image widget 的 WP 尺寸变体(medium/thumbnail)解析为本地 -300x272 类文件
|
||||
resolveImageVariants(meta?.elementor);
|
||||
return { layout, page: { ...page, meta } };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
@@ -42,7 +45,9 @@ function SolutionPage() {
|
||||
const elementor = extractElementor(data.page.meta);
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
{elementor ? <ElementorRenderer doc={elementor} /> : null}
|
||||
{elementor ? (
|
||||
<ElementorRenderer doc={elementor} pageCss={(data.page.meta as Record<string, unknown> | null)?.page_css as string | undefined} />
|
||||
) : null}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* # 路由 方案归档 /solution/
|
||||
* 原站 Elementor archive-posts 归档一比一复刻(SolutionArchive 组件,第 1 页)。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SolutionArchive, loadSolutionArchive } from "~/components/website/SolutionArchive";
|
||||
|
||||
export const Route = createFileRoute("/solution/")({
|
||||
loader: () => loadSolutionArchive(1),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "IoT Gateways & Devices Solution - DusunIoT" },
|
||||
{ name: "description", content: "Dusun IoT hardware solutions for smart home, smart apartment, energy storage, vehicle IoT, retail, building and more." },
|
||||
],
|
||||
links: [{ rel: "canonical", href: "https://www.dusuniot.com/solution/" }],
|
||||
}),
|
||||
component: SolutionArchivePage,
|
||||
});
|
||||
|
||||
/** # 组件 方案归档页(第 1 页) */
|
||||
function SolutionArchivePage() {
|
||||
const data = Route.useLoaderData();
|
||||
return <SolutionArchive data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* # 路由 方案归档分页 /solution/page/$page/
|
||||
* 与原站一致的第 2+ 页归档(每页 12 篇,日期倒序);越界页返回空网格(原站 200)。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SolutionArchive, loadSolutionArchive } from "~/components/website/SolutionArchive";
|
||||
|
||||
export const Route = createFileRoute("/solution/page/$page")({
|
||||
loader: async ({ params }) => {
|
||||
const n = Number(params.page);
|
||||
return loadSolutionArchive(Number.isFinite(n) && n > 1 ? Math.floor(n) : 1);
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{
|
||||
title: `IoT Gateways & Devices Solution - Page ${loaderData?.page ?? 2} of ${loaderData ? Math.max(1, Math.ceil(loaderData.list.total / loaderData.list.pageSize)) : 2} - DusunIoT`,
|
||||
},
|
||||
{ name: "description", content: "Dusun IoT hardware solutions for smart home, smart apartment, energy storage, vehicle IoT, retail, building and more." },
|
||||
],
|
||||
}),
|
||||
component: SolutionArchivePagedPage,
|
||||
});
|
||||
|
||||
/** # 组件 方案归档分页页 */
|
||||
function SolutionArchivePagedPage() {
|
||||
const data = Route.useLoaderData();
|
||||
return <SolutionArchive data={data} />;
|
||||
}
|
||||
+5
-56
@@ -1,59 +1,8 @@
|
||||
/**
|
||||
* # 路由 方案索引 /solution/
|
||||
* 原站形态:SOLUTION 大标题 + 3 列方案卡片网格(图 / 标题 / 摘要)。
|
||||
* 卡片数据 = listSolutionLandings()(同 slug 方案落地页)。
|
||||
* # 路由 /solution 布局
|
||||
* 原站 /solution/(归档)、/solution/page/N/(归档分页)、/solution/$slug/(方案落地页)
|
||||
* 共享该前缀。本路由只做布局挂载点(无 component 时 TanStack 直接渲染子路由 Outlet)。
|
||||
*/
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { listSolutionLandings } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/solution")({
|
||||
loader: async () => {
|
||||
const [layout, solutions] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listSolutionLandings().catch(() => []),
|
||||
]);
|
||||
return { layout, solutions };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "IoT Solutions | Dusun IoT" },
|
||||
{ name: "description", content: "Dusun IoT hardware solutions for smart home, smart apartment, energy storage, vehicle IoT, retail, building and more." },
|
||||
],
|
||||
}),
|
||||
component: SolutionIndexPage,
|
||||
});
|
||||
|
||||
/** # 组件 方案索引页 */
|
||||
function SolutionIndexPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
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">{t("solution_title")}</h1>
|
||||
</div>
|
||||
<div className="site-container my-10">
|
||||
<div className="grid grid-cols-1 gap-6 min-[760px]:grid-cols-2 min-[1080px]:grid-cols-3">
|
||||
{data.solutions.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
to="/solution/$slug/"
|
||||
params={{ slug: s.slug }}
|
||||
className="block overflow-hidden rounded-lg border border-line bg-white transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]"
|
||||
>
|
||||
<div className="aspect-[4/3] overflow-hidden bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
|
||||
{s.imageUrl ? <img src={s.imageUrl} alt={s.imageAlt || s.title} loading="lazy" className="h-full w-full object-cover" /> : null}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h2 className="mt-0 mb-2 text-[18px] font-semibold text-brand">{s.title}</h2>
|
||||
{s.excerpt ? <p className="m-0 text-sm leading-relaxed text-muted-foreground">{s.excerpt.slice(0, 160)}</p> : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
export const Route = createFileRoute("/solution")({});
|
||||
|
||||
+26
-11
@@ -373,9 +373,9 @@ function firstUploadedImage(meta: unknown): string | null {
|
||||
/** # 类型 博客列表项(列表卡片 + 文章页侧栏/相关文章复用) */
|
||||
export type PostListItem = Awaited<ReturnType<typeof listPosts>>["items"][number];
|
||||
|
||||
/** # 成员变量 /solution/ 索引页展示的方案落地页 slug(与原站 SOLUTION 索引卡片一一对应) **/
|
||||
/** # 成员变量 /solution/ 归档展示的方案落地页 slug(原站 category-solution 全部 14 篇) **/
|
||||
export const SOLUTION_SLUGS = [
|
||||
"smart-apartment-solution",
|
||||
"smart-apartment-gateway",
|
||||
"smart-apartment-gateway-oem",
|
||||
"oil-gas-management-solution",
|
||||
"energy-storage-ems-solution",
|
||||
@@ -384,32 +384,47 @@ export const SOLUTION_SLUGS = [
|
||||
"smart-building-solution",
|
||||
"smart-retail-solution",
|
||||
"smart-home-solution",
|
||||
"smart-apartment-solution",
|
||||
"smart-elderly-care-solution",
|
||||
"ble-positioning-solution",
|
||||
"ble-mesh-lighting-control-solution",
|
||||
"home-assistant-grow-lighting-solution",
|
||||
] as const;
|
||||
|
||||
/** # 函数 方案落地页列表(/solution/ 索引卡片:标题 + 摘要 + 图)
|
||||
* 返回值 按 SOLUTION_SLUGS 顺序的已发布 elementor 方案页(缺省跳过)
|
||||
/** # 函数 方案归档列表(/solution/ 索引卡片:标题 + 摘要 + 图)
|
||||
* 与原站 Elementor archive-posts 一致:按发布时间倒序、每页 12 篇(第 1 页 12 篇 + 分页)。
|
||||
* 参数 page 页码(1 起);pageSize 每页条数(默认 12)
|
||||
* 返回值 { items, total, page, pageSize }
|
||||
*/
|
||||
export async function listSolutionLandings() {
|
||||
export async function listSolutionLandings(page = 1, pageSize = 12) {
|
||||
const conds = [eq(contents.type, "post"), eq(contents.status, "published"), inArray(contents.slug, [...SOLUTION_SLUGS])];
|
||||
const [{ n: total }] = await db
|
||||
.select({ n: count() })
|
||||
.from(contents)
|
||||
.where(and(...conds));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: contents.id,
|
||||
slug: contents.slug,
|
||||
title: contents.title,
|
||||
excerpt: contents.excerpt,
|
||||
publishedAt: contents.publishedAt,
|
||||
imageUrl: media.url,
|
||||
imageAlt: media.altText,
|
||||
hasElementor: sql<boolean>`(meta -> 'elementor') is not null`,
|
||||
})
|
||||
.from(contents)
|
||||
.leftJoin(media, eq(media.id, contents.featuredMediaId))
|
||||
.where(and(eq(contents.type, "post"), eq(contents.status, "published"), inArray(contents.slug, [...SOLUTION_SLUGS])));
|
||||
const bySlug = new Map(rows.map((r) => [r.slug, r]));
|
||||
return [...SOLUTION_SLUGS]
|
||||
.map((s) => bySlug.get(s))
|
||||
.filter((r) => !!r)
|
||||
.map((r) => ({ ...r, title: decodeHtmlEntities(r.title) }));
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(contents.publishedAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
return {
|
||||
items: rows.map((r) => ({ ...r, title: decodeHtmlEntities(r.title), excerpt: r.excerpt ?? "" })),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPosts(page = 1, pageSize = 12, categorySlug?: string) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* # 模块 Elementor 图片 WP 尺寸变体解析(仅服务端)
|
||||
* 原站 image widget 的 image_size=medium/thumbnail/... 时 WP 渲染 -300x272 这类裁剪/缩放
|
||||
* 变体文件;dump 的 image.url 只存原图。这里在服务端按 uploads 目录里实际存在的
|
||||
* `<base>-<w>x<h>.<ext>` 变体择优改写 image.url(选宽度 ≤ 目标里最大的;无变体保持原图)。
|
||||
*/
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
/** # 常量 WP 尺寸名 → 目标宽度(Elementor 常用档位) **/
|
||||
const SIZE_TARGET: Record<string, number> = {
|
||||
thumbnail: 150,
|
||||
medium: 300,
|
||||
medium_large: 768,
|
||||
large: 1024,
|
||||
};
|
||||
|
||||
/** # 成员变量 目录列表缓存(同一目录只扫一次盘) **/
|
||||
const dirCache = new Map<string, string[]>();
|
||||
|
||||
/** # 成员变量 变体解析结果缓存(url+size → 最终 url) **/
|
||||
const resultCache = new Map<string, string>();
|
||||
|
||||
/** # 函数 查找某原图在给定目标宽度下的最佳变体 URL(无则返回 null)
|
||||
* 参数 url 原图 URL(本地 /uploads/... 或原站绝对地址);targetWidth WP 档位目标宽
|
||||
*/
|
||||
function findVariant(url: string, targetWidth: number): string | null {
|
||||
// 归一化到本地 /uploads/ 路径(dump 里可能存原站绝对地址)
|
||||
const up = url.indexOf("/uploads/");
|
||||
if (up === -1) return null;
|
||||
const localPath = url.slice(up); // /uploads/2022/06/Group-201.png
|
||||
const m = localPath.match(/^(.*\/)([^/]+)\.(\w+)$/);
|
||||
if (!m) return null;
|
||||
const [, dir, base, ext] = m;
|
||||
const fsDir = join("public", dir);
|
||||
let files = dirCache.get(fsDir);
|
||||
if (!files) {
|
||||
try {
|
||||
files = readdirSync(fsDir);
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
dirCache.set(fsDir, files);
|
||||
}
|
||||
// 候选:<base>-<w>x<h>.<ext>;取宽度 ≤ 目标里最大的(同宽取更矮的,WP medium 缩放优先于裁剪)
|
||||
const prefix = `${base}-`;
|
||||
let best: { w: number; h: number; file: string } | null = null;
|
||||
for (const f of files) {
|
||||
const fm = f.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)x(\\d+)\\.${ext}$`));
|
||||
if (!fm) continue;
|
||||
const w = Number(fm[1]);
|
||||
const h = Number(fm[2]);
|
||||
if (w > targetWidth) continue;
|
||||
if (!best || w > best.w || (w === best.w && h < best.h)) best = { w, h, file: f };
|
||||
}
|
||||
return best ? `${dir}${best.file}` : null;
|
||||
}
|
||||
|
||||
/** # 函数 就地解析文档内 image widget 的尺寸变体(服务端 loader 调用;浏览器端空操作)
|
||||
* 参数 doc elementor 文档(数组或 {elements} 包装),image.url 被改写为变体地址
|
||||
*/
|
||||
export function resolveImageVariants(doc: unknown): void {
|
||||
if (typeof window !== "undefined") return;
|
||||
const walk = (el: unknown) => {
|
||||
if (Array.isArray(el)) {
|
||||
for (const x of el) walk(x);
|
||||
return;
|
||||
}
|
||||
if (!el || typeof el !== "object") return;
|
||||
const e = el as { id?: unknown; widgetType?: unknown; settings?: Record<string, unknown>; elements?: unknown[] };
|
||||
if (e.widgetType === "image") {
|
||||
const st = e.settings ?? {};
|
||||
const target = SIZE_TARGET[String(st.image_size ?? "")];
|
||||
const img = st.image as { url?: string } | undefined;
|
||||
if (target && img?.url && img.url.includes("/uploads/")) {
|
||||
const key = `${img.url}|${target}`;
|
||||
let resolved = resultCache.get(key);
|
||||
if (resolved === undefined) {
|
||||
resolved = findVariant(img.url, target) ?? "";
|
||||
resultCache.set(key, resolved);
|
||||
}
|
||||
if (resolved) img.url = resolved;
|
||||
}
|
||||
}
|
||||
for (const c of Array.isArray(e.elements) ? e.elements : []) walk(c);
|
||||
};
|
||||
walk(doc);
|
||||
}
|
||||
Reference in New Issue
Block a user