diff --git a/docs/sections-inventory.md b/docs/sections-inventory.md
index 516eb3545..040986310 100644
--- a/docs/sections-inventory.md
+++ b/docs/sections-inventory.md
@@ -37,7 +37,7 @@
| H1 | 公告滚动条 | eael-content-ticker | ✅ v0.10.4(badge 18px/600、内容 #222 hover #ED6C00、fade 200ms/5000ms、1300px 容器、bg #F4F5F8,数据取最新 3 产品) |
| H2 | 产品线入口 ×6 | image-box ×6(6 条产品线图标卡) | ✅ v0.10.5(图片上方图标+标题 16px/400/#333、figure 30% 宽、hover 亮度 90%、五列右侧 v-line 竖线、列 10px 内边距、≤767 50% 两列;ImageBoxWidget 重构为 Elementor 忠实 DOM 供 H13/about 复用) |
| H3 | 「Dusun Hardware & Service」标题条 | heading | ✅ v0.10.6(Poppins®600/40px/48 行高/#000 居中、widget 底 padding 24px、section 上 padding 64px;Poppins 与原站一致走系统 fallback 不载字体文件;typographyCss/_padding/resolveGlobals 修复基建) |
-| H4 | 硬件与服务系列 ×5 组 | 每组 heading+text+image+eael-info-box ×4(横向四列) | ⬜ |
+| H4 | 硬件与服务系列 ×5 组 | 每组 heading+text+image+eael-info-box ×2(五列卡组) | ✅ v0.10.7(heading 橙线下划线/20px/#333、text-editor BFC 62.4、image widget link/shrink 动画、eael-info-box icon-on-left 完整结构、列卡渐变+边框+margin 折叠(实测 sec 529.6/列 513.6/inf2 y1429.4 与原站一致);WidgetNode 双层容器(elementor-widget + widget-container)对齐原站 margin 折叠语义) |
| H5 | 可定制/易生产 双主张条 | heading ×2(双栏大字) | ⬜ |
| H6 | 从概念到量产 宣言条 | 全大写 heading(ACCELERATE YOUR IOT DEVICE LAUNCH…) | ⬜ |
| H7 | OS/芯片平台/语言/协议 四列 | heading ×4 图标列 | ⬜ |
diff --git a/package.json b/package.json
index e7fdb0436..340942923 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dusuniot-rebuild",
- "version": "0.10.6",
+ "version": "0.10.7",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/components/elementor/Renderer.tsx b/src/components/elementor/Renderer.tsx
index 96ad98ff1..ac487e93d 100644
--- a/src/components/elementor/Renderer.tsx
+++ b/src/components/elementor/Renderer.tsx
@@ -72,6 +72,26 @@ function collapseBoxValues(v: string): string {
return v;
}
+/** # 函数 Elementor 背景位置词转 CSS 百分比(bottom center → 50% 100%) */
+function mapBgPosition(v: string): string {
+ const parts = v.trim().split(/\s+/).filter(Boolean);
+ const to = (w: string): string | null => {
+ switch (w) {
+ case "top": return "0%";
+ case "center": return "50%";
+ case "bottom": return "100%";
+ case "left": return "0%";
+ case "right": return "100%";
+ default: return null;
+ }
+ };
+ // 顺序统一为 [x, y]
+ const [xs, ys] = parts.length === 2 && ["top", "bottom"].includes(parts[0]!) ? [parts[1]!, parts[0]!] : [parts[0]!, parts[1]!];
+ const x = to(xs) ?? xs;
+ const y = to(ys) ?? ys;
+ return `${x} ${y}`;
+}
+
/** # 函数 解析 Typography 组合样式(typography_typography=custom 时的 typography_* 字段)
* 覆盖 font-family/size/weight/line-height/letter-spacing/word-spacing/text-transform/
* font-style/text-decoration(凡 settings 中有定义的值写入内联样式),
@@ -318,51 +338,98 @@ function SectionNode({ el }: { el: ElElement }) {
);
}
-/** # 组件 column:按 _inline_size 百分比设置列宽 */
+/** # 组件 column:双层对应原站 .elementor-column(外层)+ .elementor-element-populated(内层)
+ * 外层参与 flex 布局(按 _inline_size 百分比否则均分),不承载盒样式;
+ * 内层承载 populated 的盒样式:_margin(margin,移动卡片间距且不影响 flex 宽度,与原站一致)、
+ * padding、背景、边框、圆角。
+ */
function ColumnNode({ el }: { el: ElElement }) {
const settings = resolveGlobals(el.settings || {});
- // 未声明列宽时按 Elementor 语义均分(flex-basis 0),避免固定 240px 底宽把多列挤成换行
- const style: CSSProperties = { flex: "1 1 0", minWidth: 0 };
+ const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" };
const inline = settings._inline_size;
if (typeof inline === "number" && inline > 0) {
- style.flex = `0 0 ${inline}%`;
- style.maxWidth = `${inline}%`;
+ outer.flex = `0 0 ${inline}%`;
+ outer.maxWidth = `${inline}%`;
+ }
+ const inner: CSSProperties = {};
+ const mg = paddingCss(settings.margin);
+ if (mg) {
+ // margin 写在 populated(与 Elementor 相同):width 取 100% 外层宽而不随 margin 收缩
+ // (原站实测 populated rect 272px 与 1.6/6.4 margin 并存)
+ inner.width = "100%";
+ inner.margin = mg;
}
const pad = paddingCss(settings.padding);
- if (pad) style.padding = pad;
+ if (pad) inner.padding = pad;
const bg = sectionBgStyle(settings);
- Object.assign(style, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
- // 列圆角/边框(卡片样式)
+ Object.assign(inner, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
const radius = paddingCss(settings.border_radius);
- if (radius) style.borderRadius = radius;
+ if (radius) inner.borderRadius = radius;
if (s(settings.border_border) === "solid") {
+ inner.border = `1px solid ${s(settings.border_color) || "#e3e9f0"}`;
const bw = paddingCss(settings.border_width);
- style.border = `1px solid ${s(settings.border_color) || "#e3e9f0"}`;
if (bw && bw.split(" ").length === 4) {
const [t, r2, b, l] = bw.split(" ");
- style.borderWidth = `${t} ${r2} ${b} ${l}`;
- } else style.borderWidth = "1px";
+ inner.borderWidth = `${t} ${r2} ${b} ${l}`;
+ } else inner.borderWidth = "1px";
}
return (
-
- {(el.elements || []).map((c, i) => (
-
- ))}
+
+
+ {(el.elements || []).map((c, i) => (
+
+ ))}
+
);
}
/** # 组件 widget 分发:按 widgetType 渲染,未知类型优雅降级
- * 外层 div 与原站 widget 包裹层一致(.elementor-widget …),挂 data-el=
,
- * 页面级规则(post-18.css 对应块按 [data-el] 范围书写)与 _css_classes 因此都能命中。
+ * 双层对应原站结构:外层 div = .elementor-widget(挂 data-el=,页面级规则与
+ * _css_classes 命中于此);内层 div = .elementor-widget-container(承载 _padding /
+ * _margin / _border 系列 / _border_radius / _background_* 等高级盒模型)。
+ * 双层的目的:外层仅带 kit-spacing 的 20px 下外距,内层负外距(如 H4 图片 -2.5rem 0 -1rem)
+ * 能与外层外距按标准块级折叠(max 规则),与原站实测间距(折叠后 20px)一致。
*/
function WidgetNode({ el }: { el: ElElement }) {
+ const settings = (el.settings || {}) as Record;
+ const style: CSSProperties = {};
+ const pad = paddingCss(settings._padding);
+ if (pad) style.padding = pad;
+ const mrg = paddingCss(settings._margin);
+ if (mrg) style.margin = mrg;
+ if (s(settings._border_border) === "solid") {
+ style.border = `1px solid ${s(settings.border_color) || s(settings._border_color) || "#e3e9f0"}`;
+ const bw = paddingCss(settings._border_width);
+ if (bw && bw.split(" ").length === 4) {
+ const [t, r2, b, l] = bw.split(" ");
+ style.borderWidth = `${t} ${r2} ${b} ${l}`;
+ } else style.borderWidth = "1px";
+ }
+ const rad = paddingCss(settings._border_radius);
+ if (rad) style.borderRadius = rad;
+ // widget 容器背景(_background_*):仅图片(classic),位置/平铺逐字采用
+ const wbg = settings._background_image as { url?: string } | undefined;
+ if (wbg?.url) {
+ style.backgroundImage = `url("${wbg.url}")`;
+ // Elementor 位置词转 CSS:bottom center → 50% 100%(避免浏览器归一差异)
+ style.backgroundPosition = mapBgPosition(s(settings._background_position));
+ if (s(settings._background_repeat)) style.backgroundRepeat = s(settings._background_repeat);
+ }
const inner = (
);
- const cls = s(el.settings?._css_classes) || undefined;
+ const cls = s(settings._css_classes) || undefined;
+ const hasContainerStyle = Object.keys(style).length > 0;
+ if (hasContainerStyle) {
+ return (
+
+ );
+ }
return {inner}
;
}
@@ -464,7 +531,7 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
}
}
-/** # 组件 heading widget:标题级别/颜色/对齐 */
+/** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */
function HeadingWidget({ settings }: { settings: Record }) {
// 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2
const size = s(settings.header_size) || "h2";
@@ -474,13 +541,24 @@ function HeadingWidget({ settings }: { settings: Record }) {
const align = s(settings.align);
if (align) style.textAlign = align as CSSProperties["textAlign"];
Object.assign(style, typographyCss(settings));
- // widget 自带内边距(Elementor 高级项 _padding)
- const pad = paddingCss(settings._padding);
- if (pad) style.padding = pad;
- // 通用 margin 不设(实测原站 heading widget margin 0,间距全部来自原站 settings)
- return ;
+ // 基础规则(frontend.min.css):.elementor-heading-title{line-height:1;margin:0;padding:0}
+ // settings 里未定义行高时用 1(H3 有显式 1.2 覆写,H4 无设置 → 实测原站 20px/20px)
+ if (!style.lineHeight) style.lineHeight = 1;
+ // _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在标题标签内重复
+ const title = s(settings.title);
+ const link = (settings.link as { url?: string } | undefined)?.url;
+ const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
+ const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined;
+ return (
+
+ {linkProps ? (
+
+ ) : (
+
+ )}
+
+ );
}
-
/** # 组件 text-editor widget:富文本 HTML */
function TextEditorWidget({ settings }: { settings: Record }) {
const html = s(settings.editor);
@@ -490,19 +568,26 @@ function TextEditorWidget({ settings }: { settings: Record }) {
const style: CSSProperties = {};
if (color) style.color = color;
Object.assign(style, typographyCss(settings));
- const pad = paddingCss(settings._padding);
- if (pad) style.padding = pad;
- return ;
+ // _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复
+ return ;
}
-/** # 组件 image widget */
+/** # 组件 image widget:图(可带 link;hover_animation=shrink 复刻原站缩小动画) */
function ImageWidget({ settings }: { settings: Record }) {
- const url = (settings.image as { url?: string } | undefined)?.url;
+ const img0 = settings.image as { url?: string; alt?: string } | undefined;
+ const url = img0?.url;
if (!url) return null;
const align = s(settings.align);
+ const anim = s(settings.hover_animation); // 原站映射 elementor-animation-* 类
+ const animCls = anim === "shrink" ? "el-anim-shrink" : "";
+ const link = (settings.link as { url?: string } | undefined)?.url;
+ const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
+ const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined;
+ const img =
;
+ const cls = ("el-image " + (animCls || "")).trim();
return (
-
-

+
+ {linkProps ?
{img} : img}
);
}
@@ -765,16 +850,39 @@ function IconBoxWidget({ settings }: { settings: Record
}) {
/** # 组件 eael-info-box(EAEL Infobox 皮肤):标题(.title,custom_css 提供左侧图标背景) + 富文本描述(.infobox-content)
* 与原生 icon-box 的键不同:内容在 eael_infobox_title / eael_infobox_text。
*/
+/** # 组件 eael-info-box(EAEL Infobox,忠实复刻 info-box.min.css + 原站渲染 DOM):
+ * eael-infobox(icon-on-left / 默认上下) > .infobox-icon(img,eael_infobox_img_margin)
+ * + .infobox-content > .infobox-title-section > .title(标题标签 eael_infobox_title_tag)
+ * [+ .sub_title][+ p 描述];图在左时为 flex 行(content padding-left 15px)。
+ * 标题/描述样式(颜色/字号/外距)由页面级规则与 eael_infobox_title_margin 提供。
+ */
function EaelInfoBoxWidget({ settings }: { settings: Record }) {
const title = s(settings.eael_infobox_title) || s(settings.title_text) || s(settings.title);
const desc = s(settings.eael_infobox_text) || s(settings.description_text) || s(settings.description);
- if (!title && !desc) return null;
+ const img = settings.eael_infobox_image as { url?: string; alt?: string } | undefined;
+ if (!title && !desc && !img?.url) return null;
const tag = s(settings.eael_infobox_title_tag);
const Tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div"].includes(tag) ? tag : "div") as "div";
+ const left = s(settings.eael_infobox_img_type) === "img-on-left" || s(settings.eael_infobox_img_type) === "icon-on-left";
+ const cls = "eael-infobox" + (left ? " icon-on-left" : "");
return (
-
- {title ?
: null}
- {desc ?
: null}
+
+ {img?.url ? (
+
+

+
+ ) : null}
+
+ {title ? (
+
+
+ {s(settings.eael_infobox_sub_title) && s(settings.eael_infobox_sub_title) !== "This is a sub title" ? (
+
+ ) : null}
+
+ ) : null}
+ {desc ?
: null}
+
);
}
diff --git a/src/components/elementor/slim.ts b/src/components/elementor/slim.ts
index 6748b1422..96da4c5e4 100644
--- a/src/components/elementor/slim.ts
+++ b/src/components/elementor/slim.ts
@@ -92,6 +92,24 @@ const SETTING_KEYS = new Set([
"eael_infobox_title",
"eael_infobox_text",
"eael_infobox_title_tag",
+ "eael_infobox_image",
+ "eael_infobox_img_type",
+ "eael_infobox_img_margin",
+ "eael_infobox_title_margin",
+ "eael_infobox_title_margin_mobile",
+ "eael_infobox_title_typography_typography",
+ "eael_infobox_title_typography_font_size",
+ "hover_animation",
+ // widget 高级盒模型(如 eael-info-box 边框卡:_border_* + _padding/_margin)
+ "_margin",
+ // widget 容器背景(如 H4 标题下的橙线图)
+ "_background_image",
+ "_background_position",
+ "_background_repeat",
+ "_border_border",
+ "_border_color",
+ "_border_width",
+ "_border_radius",
"title_text_a",
"description_text_a",
"button_text",
@@ -111,7 +129,7 @@ const SETTING_KEYS = new Set([
function slimSettingValue(key: string, val: unknown): unknown {
if (!val || typeof val !== "object" || Array.isArray(val)) return val;
const v = val as Record
;
- if (key === "image" || key === "background_image" || key === "background_a_image") {
+ if (key === "image" || key === "background_image" || key === "background_a_image" || key === "eael_infobox_image") {
// 渲染器只读 url / alt
return { url: v.url, alt: v.alt };
}
diff --git a/src/styles/app.css b/src/styles/app.css
index 772159b25..2e424e2a3 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -261,7 +261,7 @@
background-repeat: no-repeat;
background-position: center right;
}
- [data-el="e38a898"] > div > [data-el] { padding: 10px; }
+ [data-el="e38a898"] > div > [data-el] > div { padding: 10px; }
[data-el="e38a898"] .el-imagebox-img { width: 30%; }
[data-el="e38a898"] .el-imagebox:hover .el-imagebox-img img {
filter: brightness(90%) contrast(100%) saturate(100%) blur(0px) hue-rotate(0deg);
@@ -273,6 +273,97 @@
/* post-18.css 对六个 widget 各有 margin-bottom:0,特异性高于基础 CSS 的 15px(实测原站 0px) */
[data-el="e38a898"] .el-imagebox-img { margin-bottom: 0 !important; }
}
+
+ /* ===== Elementor 通用规则(frontend.min.css 对应块)
+ * kit-widget-spacing:非末位 widget 的下间距默认 20px(实测原站 H4 text-editor margin 0 0 20px)
+ * 挂在 .el-col 直属 widget(data-el 包裹层)上。
+ * text-editor:display flow-root 建立 BFC(子元素 p 的下外距计入容器高度,与原站
+ * 实测 62.4 = 2 行 48 + p margin 14.4 一致),p 下外距 0.9em(原站 hello 环境 14.4px)。
+ */
+ .el-col > div > [data-el]:not(:last-child) { margin-bottom: 20px; }
+ .el-texteditor { display: flow-root; }
+ .el-texteditor p { margin: 0 0 0.9em; }
+
+ /* ===== Elementor widget-image 基础(widget-image.min.css 对应块)
+ * a 与 img 都是 inline-block 且 img vertical-align:middle,
+ * 消除行内盒基线空隙(否则容器多出 ~7px 行盒 descent,与原站 231.2≠238.2 对不上)。
+ */
+ .el-image { text-align: center; }
+ .el-image a { display: inline-block; }
+ .el-image img { display: inline-block; vertical-align: middle; }
+
+ /* ===== Elementor hover 动画(e-animation-shrink.min.css):hover 缩至 90%,0.3s 过渡 */
+ /* heading 标题链接颜色继承(原站不受全局品牌橙影响,实测 #333/#000) */
+ .el-heading-title a { color: inherit; }
+ .el-anim-shrink { transition-duration: 0.3s; transition-property: transform; }
+ .el-anim-shrink:hover, .el-anim-shrink:focus, .el-anim-shrink:active { transform: scale(0.9); }
+
+ /* ===== EAEL infobox 基础(info-box.min.css 对应块):
+ * 图在左为 flex 行;内容列 padding-left 15px;标题行高 1(margin 由页面级覆盖);
+ * 图标列垂直居中。
+ */
+ .eael-infobox.icon-on-left { display: flex; position: relative; }
+ .eael-infobox.icon-on-left .infobox-content { padding-left: 15px; flex: 1 1 auto; min-width: 0; }
+ .eael-infobox .infobox-icon {
+ display: flex; justify-content: flex-start; align-self: center;
+ /* flex 行内拉伸:H4 实测 icon 容器 39px(img 32px + 7px 排版余量),
+ * 与 .eael-infobox 盒 42px 对齐 */
+ }
+ .eael-infobox .infobox-icon img { transition: all 300ms ease-in-out 0s; }
+ .eael-infobox .infobox-content { display: flex; flex-direction: column; justify-content: center; }
+ .eael-infobox .infobox-content .infobox-title-section { display: flex; flex-direction: column; }
+ .eael-infobox .infobox-content .title { line-height: 1; }
+ .eael-infobox .infobox-content p { margin: 0 0 15px; }
+
+ /* ===== 首页 H4 硬件与服务系列 ×5(post-18.css 对应块;1436px 实测 2026-11-01)
+ * 卡列:渐变 #F4F5F9→透明 + 1px #E0E5F9 边框 + 4px 圆角 + 列间 margin(ColumnNode 由 settings 渲染);
+ * 标题 widget 容器:底部橙线图背景(bottom center no-repeat)+ 左右 3rem 内边距;
+ * 产品图 widget 容器:margin -2.5rem 0 -1rem 上提;
+ * info-box ×2:图标列 margin-top 3px、内容标题 14px/#4d4d4d/margin 7px 0 0 -10px;
+ * 第二个 info-box 容器上提 -10px。
+ */
+
+
+ [data-el="314b8d5"] .infobox-icon, [data-el="8417167"] .infobox-icon,
+ [data-el="9e1009f"] .infobox-icon, [data-el="2032426"] .infobox-icon,
+ [data-el="7c9ceeb"] .infobox-icon, [data-el="9ff518a"] .infobox-icon,
+ [data-el="2c9eb1c"] .infobox-icon, [data-el="1aa62bd"] .infobox-icon,
+ [data-el="64432f0"] .infobox-icon, [data-el="2ba29de"] .infobox-icon {
+ margin: 3px 0 0 0;
+ height: 39px;
+ }
+ [data-el="314b8d5"] .infobox-content .title, [data-el="8417167"] .infobox-content .title,
+ [data-el="9e1009f"] .infobox-content .title, [data-el="2032426"] .infobox-content .title,
+ [data-el="7c9ceeb"] .infobox-content .title, [data-el="9ff518a"] .infobox-content .title,
+ [data-el="2c9eb1c"] .infobox-content .title, [data-el="1aa62bd"] .infobox-content .title,
+ [data-el="64432f0"] .infobox-content .title, [data-el="2ba29de"] .infobox-content .title {
+ color: #4d4d4d;
+ font-size: 14px;
+ margin: 7px 0 0 -10px;
+ }
+ /* inner 的 -10px 仅上移容器自身(原站实测 root 不位移),flow-root 阻止它与根折叠 */
+ [data-el="9ff518a"], [data-el="2c9eb1c"], [data-el="1aa62bd"],
+ [data-el="64432f0"], [data-el="2ba29de"] {
+ display: flow-root;
+ }
+ [data-el="9ff518a"] > div, [data-el="2c9eb1c"] > div,
+ [data-el="1aa62bd"] > div, [data-el="64432f0"] > div,
+ [data-el="2ba29de"] > div {
+ margin: -10px 0 0 0;
+ }
+ @media (max-width: 1024px) {
+ [data-el="68e06cb"], [data-el="c3c8429"], [data-el="8272123"],
+ [data-el="1d97802"], [data-el="4a8ad62"] {
+ margin: -2rem 0 -1rem;
+ }
+ }
+ @media (max-width: 767px) {
+ [data-el="9ff518a"] .infobox-content .title, [data-el="2c9eb1c"] .infobox-content .title,
+ [data-el="1aa62bd"] .infobox-content .title, [data-el="64432f0"] .infobox-content .title,
+ [data-el="2ba29de"] .infobox-content .title {
+ margin: 7px 0 0 -6px;
+ }
+ }
}
@media (max-width: 900px) {