Files
dusuniot/src/components/elementor/Renderer.tsx
T

2013 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 组件 Elementor 内容渲染器
* 将 WordPress Elementor 导出的 JSON(contents.meta.elementor / products.meta.elementor)
* 渲染为 React 元素。支持 section/column/container 结构 + 常用 widget。
* 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比),
* 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。
*/
import { Component, createContext, useContext, Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { getLatestPosts, getLatestProducts, type LatestItem, type PostCardItem } from "~/server/data";
import TickerBar from "~/components/website/TickerBar";
import { Cf7Form604 } from "~/components/website/Cf7Form604";
import { localSiteUrl } from "~/utils/media";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
/** # 类型 Elementor 元素结构(section/column/widget 通用) */
export type ElElement = {
elType?: string;
widgetType?: string;
id?: string;
settings?: Record<string, unknown>;
elements?: ElElement[];
};
/** # 类型 Elementor 文档(数组或 {elements:[]} 包装) */
export type ElDocument = ElElement[] | { elements?: ElElement[] } | unknown;
/** # 类型 尺寸值 {size, unit} */
type Size = { size?: number | string; unit?: string };
/** # 函数 判断字符串是否主要是 emoji/unicode 图标(不可作为图片图标渲染) */
function isEmojiOrPictograph(s: unknown): boolean {
if (typeof s !== "string" || !s.trim()) return true;
// 含有 emoji 区段字符(U+1F000+ 或 U+2600-U+27BF 等)视为图标字符
return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{E000}-\u{F8FF}]/u.test(s);
}
/** # 函数 读取 settings 中字符串字段 */
function s(v: unknown): string {
return typeof v === "string" ? v : "";
}
/** # 函数 把 Elementor 尺寸对象/数字转为 CSS 值 */
function sizeCss(v: unknown, fallbackUnit = "px"): string | undefined {
if (v == null) return undefined;
if (typeof v === "number") return `${v}${fallbackUnit}`;
if (typeof v === "object") {
const o = v as Size;
if (o.size != null) return `${o.size}${o.unit || fallbackUnit}`;
}
if (typeof v === "string" && v.trim()) return v;
return undefined;
}
/** # 函数 剥离内容中的文档结构级标签(WP 导入的原始 HTML 可能带 </main> 等,
* 浏览器解析会提前关闭页面骨架标签,导致水合不匹配与布局重排)
*/
function stripStructuralTags(html: string): string {
return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, "");
}
/** # 函数 CSS 长度折半(Elementor icon-list 间距:space/2 落在 padding/margin)
* 参数 v CSS 长度(如 "5px" / "1rem")
* 返回值 折半后的长度;无法解析返回 undefined
*/
function halfLength(v: string | undefined): string | undefined {
if (!v) return undefined;
const m = /^(-?[\d.]+)(px|rem|em|%)$/.exec(v.trim());
if (!m) return undefined;
const n = Number(m[1]) / 2;
return `${n}${m[2]}`;
}
/** # 函数 折叠 CSS 盒模型四值简写为最短等价形式(1/2/4 值)
* React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem"
* 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。
*/
function collapseBoxValues(v: string): string {
const p = v.trim().split(/\s+/);
if (p.length === 4 && p[0] === p[2] && p[1] === p[3]) return `${p[0]} ${p[1]}`;
if (p.length === 4 && p[0] === p[1] && p[1] === p[2] && p[2] === p[3]) return p[0]!;
if (p.length === 3 && p[0] === p[2]) return `${p[0]} ${p[1]}`;
if (p.length === 2 && p[0] === p[1]) return p[0]!;
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 中有定义的值写入内联样式),
* 供 heading / text-editor / image-box 等按原站 kit 逐字重渲染。
* 参数 settings 元素 settings;prefix 通常为 "typography"
* 返回值 CSSProperties(无 custom 时为空对象)
*/
function typographyCss(settings: Record<string, unknown>, prefix = "typography"): CSSProperties {
const style: CSSProperties = {};
if (s(settings[`${prefix}_typography`]) !== "custom") return style;
const fam = s(settings[`${prefix}_font_family`]);
if (fam) style.fontFamily = `"${fam}", sans-serif`;
const size = sizeCss(settings[`${prefix}_font_size`]);
if (size) style.fontSize = size;
const weight = s(settings[`${prefix}_font_weight`]);
if (weight) style.fontWeight = weight as CSSProperties["fontWeight"];
const lh = sizeCss(settings[`${prefix}_line_height`]);
if (lh) style.lineHeight = lh;
const ls = sizeCss(settings[`${prefix}_letter_spacing`]);
if (ls) style.letterSpacing = ls;
const ws = sizeCss(settings[`${prefix}_word_spacing`]);
if (ws) style.wordSpacing = ws;
const tt = s(settings[`${prefix}_text_transform`]);
if (tt) style.textTransform = tt as CSSProperties["textTransform"];
const fs = s(settings[`${prefix}_font_style`]);
if (fs) style.fontStyle = fs as CSSProperties["fontStyle"];
const td = s(settings[`${prefix}_text_decoration`]);
if (td) style.textDecoration = td as CSSProperties["textDecoration"];
return style;
}
/** # 函数 解析 padding(Elementor 可能是对象/字符串/数组) */
function paddingCss(v: unknown): string | undefined {
if (!v) return undefined;
if (typeof v === "string") return v;
if (typeof v === "number") return `${v}px`;
if (Array.isArray(v)) return collapseBoxValues(v.map((x) => (typeof x === "number" ? `${x}px` : String(x ?? "0"))).join(" "));
if (typeof v === "object") {
const o = v as Record<string, unknown>;
const unit = s(o.unit) || "px";
const t = o.top, r = o.right, b = o.bottom, l = o.left;
if (t == null && r == null && b == null && l == null) return undefined;
const px = (x: unknown) => `${x ?? 0}${unit}`;
return collapseBoxValues(`${px(t)} ${px(r)} ${px(b)} ${px(l)}`);
}
return undefined;
}
/** # 成员变量 Elementor 全局色映射(Default Kit:_id → 十六进制) **/
const ELEMENTOR_GLOBAL_COLORS: Record<string, string> = {
primary: "#ED6C00",
secondary: "#FFA11B",
text: "#000000",
accent: "#474DFF",
"4d5c430": "#FFFFFF",
e4e3ba3: "#4A75FF",
"282ef76": "#5C5E66",
"6fba50b": "#444444",
"78696cf": "#00C1BC",
a6f03ac: "#E9E9E9",
ee1917e: "#E0E5F9",
"26331af": "#F4F5F8",
};
/** # 函数 递归解析 settings 中的全局色引用(globals/colors?id=x → 十六进制),
* 并把 __globals__ 里的引用合并到对应设置键(Elementor 全局色实际存放在 __globals__) */
function resolveGlobals<T>(settings: T): T {
if (Array.isArray(settings)) return settings.map((v) => resolveGlobals(v)) as unknown as T;
if (settings && typeof settings === "object" && settings.constructor === Object) {
const src = settings as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(src)) {
out[k] = typeof v === "string" ? resolveGlobalsStr(v) : resolveGlobals(v);
}
const globals = src.__globals__;
if (globals && typeof globals === "object" && globals.constructor === Object) {
for (const [key, ref] of Object.entries(globals as Record<string, unknown>)) {
if (typeof ref !== "string") continue;
const resolved = resolveGlobalsStr(ref);
if (resolved !== ref) {
const cur = out[key];
// 仅当原设置为空(空串/未定义)时采用全局色
if (cur == null || cur === "") out[key] = resolved;
}
}
}
return out as T;
}
return settings;
}
/** # 函数 单值全局色引用解析(键名含非十六进制 id:text/primary/accent 等) */
function resolveGlobalsStr(v: string): string {
const m = /^globals\/colors\?id=([\w-]+)$/.exec(v.trim());
if (m) return ELEMENTOR_GLOBAL_COLORS[m[1]!] ?? v;
return v;
}
/** # 函数 响应式可见性类名(Elementor hide_* 设置 → elementor-hidden-*,配 app.css 媒体查询)
* 参数 settings 元素设置
* 返回值 空格分隔的类名(无隐藏设置则空串)
*/
function visibilityClass(settings: Record<string, unknown>): string {
const cls: string[] = [];
if (s(settings.hide_desktop) === "hidden-desktop") cls.push("elementor-hidden-desktop");
if (s(settings.hide_tablet) === "hidden-tablet") cls.push("elementor-hidden-tablet");
if (s(settings.hide_mobile) === "hidden-mobile") cls.push("elementor-hidden-mobile");
return cls.join(" ");
}
/** # 函数 section 的自定义 HTML 标签(Elementor html_tag 设置;默认 section)
* 参数 settings 元素设置
* 返回值 合法标签名
*/
function sectionTag(settings: Record<string, unknown>): "section" | "div" | "footer" | "header" | "aside" | "article" {
const tag = s(settings.html_tag);
return (["section", "div", "footer", "header", "aside", "article"] as const).includes(tag as never)
? (tag as "section" | "div" | "footer" | "header" | "aside" | "article")
: "section";
}
/** # 函数 section 的背景样式(纯色 / 图片) */
function sectionBgStyle(settings: Record<string, unknown>): CSSProperties {
const style: CSSProperties = {};
const bgType = s(settings.background_background);
if (bgType === "classic") {
const color = s(settings.background_color);
if (color) style.backgroundColor = color;
const img = (settings.background_image as { url?: string } | undefined)?.url;
if (img) {
style.backgroundImage = `url("${img}")`;
// 位置/重复取设置值(H11 底图 1920×240 条图:bottom center + no-repeat,
// 只在节底部铺一条,尺寸 auto 不缩放——写死 cover/center 会平铺满节,实测对不上)
const posRaw = s(settings.background_position);
style.backgroundPosition = posRaw ? mapBgPosition(posRaw) : "center center";
const rep = s(settings.background_repeat);
if (rep) style.backgroundRepeat = rep;
// 尺寸:initial → 100% auto(H13 楼体大图 bf3d0ba 生成规则实测;
// cover/contain/auto 原样输出)
const sizeRaw = s(settings.background_size);
if (sizeRaw) style.backgroundSize = sizeRaw === "initial" ? "100% auto" : sizeRaw;
}
} else if (bgType === "gradient") {
const c1 = s(settings.background_color) || s(settings.color_a) || "#ffffff";
const c2 = s(settings.background_color_b) || s(settings.color_b) || s(settings.background_color_stop_b) || "#f4f5f8";
style.backgroundImage = `linear-gradient(${s(settings.gradient_angle) || "180"}deg, ${c1}, ${c2})`;
} else if (s(settings.background_color)) {
style.backgroundColor = s(settings.background_color);
}
// padding 由调用方(SectionNode 写到内容容器 / ColumnNode 写到列)负责,不在此叠加避免双份
return style;
}
/** # 函数 归一化 Elementor 文档为元素数组 */
function normalizeDoc(doc: ElDocument): ElElement[] {
if (Array.isArray(doc)) return doc as ElElement[];
if (doc && typeof doc === "object") {
const el = (doc as { elements?: ElElement[] }).elements;
if (Array.isArray(el)) return el;
}
return [];
}
/** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/
const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
/** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
const ShortcodesContext = createContext<Record<string, ReactNode>>({});
/** # 类型 渲染器动态数据(面包屑等 WP 动态 widget 的 SSR 上下文) */
export type RendererVars = {
/** # 成员变量 面包屑路径(末项无 url 表示当前页) **/
breadcrumb?: { label: string; url?: string }[];
};
/** # 成员变量 动态数据上下文(woocommerce-breadcrumb 等 widget 读取) **/
const VarsContext = createContext<RendererVars>({});
/** # 组件 Elementor 渲染器入口
* 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空)
* 参数 vars 动态数据(面包屑路径等)
*/
export function ElementorRenderer({
doc,
postsData,
shortcodes,
vars,
}: {
doc: ElDocument;
postsData?: Record<string, PostCardItem[]>;
shortcodes?: Record<string, ReactNode>;
vars?: RendererVars;
}) {
const elements = normalizeDoc(doc);
if (!elements.length) return null;
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
return (
<VarsContext.Provider value={vars || {}}>
<PostsDataContext.Provider value={postsData || {}}>
<ShortcodesContext.Provider value={shortcodes || {}}>
<div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
{elements.map((el, i) => (
<ElementNode key={el.id || i} el={el} />
))}
</div>
</ShortcodesContext.Provider>
</PostsDataContext.Provider>
</VarsContext.Provider>
);
}
/** # 函数 收集整份文档所有节点的 custom_css(Elementor 每节点自定义样式,选择器挂在 _css_classes 上)
* 参数 elements 归一化后的元素数组
* 返回值 拼接后的 CSS(原站媒体地址已改写为本地 /uploads/);无则空串
*/
function collectCustomCss(elements: ElElement[]): string {
let css = "";
function walk(els: ElElement[]) {
for (const el of els) {
const c = s(el.settings?.custom_css);
if (c) css += c + "\n";
if (el.elements?.length) walk(el.elements);
}
}
walk(elements);
return css ? localSiteUrl(css) : css;
}
/** # 函数 收集各元素响应式内边距(padding_tablet / padding_mobile),输出媒体查询规则
* 选择器用 data-el 属性(section/core 容器/列都带 data-el=<elementor id>),
* 断点沿用 Elementor 默认:tablet ≤1024、mobile ≤767;!important 覆盖行内样式。
* 参数 elements 归一化后的元素数组
* 返回值 拼接后的 CSS;无则空串
*/
function collectResponsiveCss(elements: ElElement[]): string {
let css = "";
function emit(el: ElElement) {
const sel = `[data-el="${el.id}"]`;
const st = (el.settings ?? {}) as Record<string, unknown>;
// 列的内边距/外边距落在内层 populated(ColumnNode),section 落根元素
const boxSel = s(el.elType) === "column" ? `${sel} > div` : sel;
const t = paddingCss(st.padding_tablet);
const m = paddingCss(st.padding_mobile);
if (t) css += `@media (max-width:1024px){${boxSel}{padding:${t} !important;}}\n`;
if (m) css += `@media (max-width:767px){${boxSel}{padding:${m} !important;}}\n`;
// 响应式外边距(页脚 a428bb4 tablet 70px / mobile 10px;Elementor 根元素 margin)
const mt = paddingCss(st.margin_tablet);
const mm = paddingCss(st.margin_mobile);
if (mt) css += `@media (max-width:1024px){${boxSel}{margin:${mt} !important;}}\n`;
if (mm) css += `@media (max-width:767px){${boxSel}{margin:${mm} !important;}}\n`;
// widget 高级盒模型响应式(_margin/_padding 的 tablet/mobile 变体挂内层容器)
if (s(el.elType) === "widget") {
const wsel = `${sel} > div`;
const wpt = paddingCss(st._padding_tablet);
const wpm = paddingCss(st._padding_mobile);
const wmt = paddingCss(st._margin_tablet);
const wmm = paddingCss(st._margin_mobile);
if (wpt) css += `@media (max-width:1024px){${wsel}{padding:${wpt} !important;}}\n`;
if (wpm) css += `@media (max-width:767px){${wsel}{padding:${wpm} !important;}}\n`;
if (wmt) css += `@media (max-width:1024px){${wsel}{margin:${wmt} !important;}}\n`;
if (wmm) css += `@media (max-width:767px){${wsel}{margin:${wmm} !important;}}\n`;
// widget 宽度模式响应式(页脚 gtranslate 移动端 width inherit)
const ewm = s(st._element_width_mobile);
if (ewm === "inherit") css += `@media (max-width:767px){${sel}{width:100% !important;max-width:100% !important;align-self:center;}}\n`;
else if (ewm === "auto") css += `@media (max-width:767px){${sel}{width:auto !important;max-width:auto !important;align-self:auto;}}\n`;
// image widget 图片移动端宽度(页脚 logo 47%)
if (s(el.widgetType) === "image") {
const iw = sizeCss(st.width_mobile);
if (iw) css += `@media (max-width:767px){${sel} img{width:${iw} !important;}}\n`;
}
// 标题响应式字号(typography_font_size_tablet/mobile;落地页 e8c891a
// 桌面 38px 来自主题,移动端设置 1.5rem=24px,原站实测 24px/24)
if (s(el.widgetType) === "heading") {
const fsT = sizeCss(st.typography_font_size_tablet);
const fsM = sizeCss(st.typography_font_size_mobile);
if (fsT) css += `@media (max-width:1024px){${sel} .el-heading-title{font-size:${fsT} !important;}}\n`;
if (fsM) css += `@media (max-width:767px){${sel} .el-heading-title{font-size:${fsM} !important;}}\n`;
}
}
// 列响应式宽度(_inline_size_tablet / _inline_size_mobile;页脚内层四列 43%/100%)。
// Elementor 语义:桌面 _inline_size 仅 ≥768 生效(原站 post-840.css 的 min-width:768 门),
// 未设 _inline_size_mobile 的列在 ≤767 回退 100%(Elementor frontend.css 的
// @media(max-width:767px){.elementor-column{width:100%}})——联系页 60/40 两列移动端堆叠。
// 值既有数字也有字符串(联系页 _inline_size="60"),统一转数值。
const num = (v: unknown) => (typeof v === "number" ? v : v != null && v !== "" ? Number(v) : NaN);
const tw = num(st._inline_size_tablet);
const mw = num(st._inline_size_mobile);
const dw = num(st._inline_size);
if (Number.isFinite(tw) && tw > 0) {
css += `@media (max-width:1024px){${sel}{flex:0 0 ${tw}% !important;max-width:${tw}% !important;}}\n`;
}
if (Number.isFinite(mw) && mw > 0) {
css += `@media (max-width:767px){${sel}{flex:0 0 ${mw}% !important;max-width:${mw}% !important;}}\n`;
} else if (Number.isFinite(dw) && dw > 0) {
css += `@media (max-width:767px){${sel}{flex:0 0 100% !important;max-width:100% !important;}}\n`;
}
// icon-list 响应式项间距(space_between_tablet/mobile:页脚社交 10 → 7px)
if (s(el.widgetType) === "icon-list") {
const inlineItems = `${sel} .elementor-icon-list-items.elementor-inline-items`;
const stHalf = halfLength(sizeCss(st.space_between_tablet));
const smHalf = halfLength(sizeCss(st.space_between_mobile)) || stHalf;
if (stHalf) {
css += `@media (max-width:1024px){${inlineItems}{margin-inline:-${stHalf} !important;}${inlineItems} > .elementor-icon-list-item{margin-inline:${stHalf} !important;}}\n`;
}
if (smHalf) {
css += `@media (max-width:767px){${inlineItems}{margin-inline:-${smHalf} !important;}${inlineItems} > .elementor-icon-list-item{margin-inline:${smHalf} !important;}}\n`;
}
// 移动端行高(icon_typography_line_height_mobile:联系页地址列 1.25rem,post-840.css 生成规则)
const lhM = sizeCss(st.icon_typography_line_height_mobile);
if (lhM) {
css += `@media (max-width:767px){${sel} .elementor-icon-list-item > .elementor-icon-list-text,${sel} .elementor-icon-list-item > a{line-height:${lhM} !important;}}\n`;
}
}
}
function walk(els: ElElement[]) {
for (const el of els) {
if (el.id) emit(el);
if (el.elements?.length) walk(el.elements);
}
}
walk(elements);
return css;
}
/** # 组件 单个元素节点分发(section/column/container/widget)
* 参数 gapPad 所属 section 的默认列内边距(Elementor gap 档位,仅 column 消费)
* 参数 contentPos 所属 section 的 content_position(middle → 列的 widget-wrap 垂直居中)
*/
function ElementNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string; contentPos?: string }) {
const type = s(el.elType);
if (type === "widget") return <WidgetNode el={el} />;
if (type === "section" || type === "container") return <SectionNode el={el} />;
if (type === "column") return <ColumnNode el={el} gapPad={gapPad} contentPos={contentPos} />;
// 未知结构:有子元素则递归,否则忽略
if (el.elements?.length) {
return (
<>
{el.elements.map((c, i) => (
<ElementNode key={c.id || i} el={c} gapPad={gapPad} contentPos={contentPos} />
))}
</>
);
}
return null;
}
/** # 组件 section / container:背景 + 内部布局
* 取证(原站实测/post CSS):boxed 内容容器 = margin auto + flex wrap 的定宽行;
* 内容宽度 content_width 优先,否则沿用 Elementor kit 默认 1140px。
* padding 挂在 section 根(原站 computed padding 在 .elementor-section 上,行容器无 padding,
* 根 padding 同时把行往内推:1436 视口 + 1.6px 侧 padding 时行仍为整宽 1360,实测一致)。
* gap 档位决定子列的默认 populated padding(frontend.min.css:no 0 / narrow 5 / default 10 /
* extended 15 / wide 20 / wider 30;settings 缺省 = default)。
*/
const GAP_PAD: Record<string, string> = {
no: "0px", narrow: "5px", default: "10px", extended: "15px", wide: "20px", wider: "30px",
};
function SectionNode({ el }: { el: ElElement }) {
const settings = resolveGlobals(el.settings || {});
const style = sectionBgStyle(settings);
const width = sizeCss(settings.content_width) || sizeCss((settings as Record<string, unknown>).width) || "1140px";
const secPad = paddingCss(settings.padding);
if (secPad) style.padding = secPad;
// section 外边距(原站 computed 在根元素,如 H8 7df0244 margin-top 4% = 57.4px @1436)
const secMargin = paddingCss(settings.margin);
if (secMargin) style.margin = secMargin;
const inner: CSSProperties = {};
if (s(settings.layout) === "full_width") {
// 通栏 section:内容不设上限(如首页 hero 轮播)
inner.maxWidth = "none";
} else {
inner.maxWidth = width;
}
const gapPad = GAP_PAD[s(settings.gap) || "default"] || "10px";
// 背景遮罩(Elementor .elementor-background-overlay div):盖在背景图上、内容之下
// (H11 Developer Center:电路板底图 bottom center + F4F5F8→透明 180deg 渐变遮罩,
// 顶部近白、底部透出电路纹理,实测 2026-11-01)
const ovType = s((settings as Record<string, unknown>).background_overlay_background);
let overlay: ReactNode = null;
if (ovType === "gradient") {
const c1 = s((settings as Record<string, unknown>).background_overlay_color) || "transparent";
const c2 = s((settings as Record<string, unknown>).background_overlay_color_b) || "transparent";
const angle = sizeCss((settings as Record<string, unknown>).background_overlay_gradient_angle) || "180deg";
overlay = (
<div
aria-hidden
style={{ position: "absolute", inset: 0, background: `linear-gradient(${angle}, ${c1} 0%, ${c2} 100%)`, pointerEvents: "none" }}
/>
);
} else if (ovType === "classic") {
const c1 = s((settings as Record<string, unknown>).background_overlay_color);
if (c1) overlay = <div aria-hidden style={{ position: "absolute", inset: 0, background: c1, pointerEvents: "none" }} />;
}
// section 高度模式(Elementor: min-height / full),配合 custom_height;
// 取证(原站 09fe4b7 实测):height 未设置时 custom_height 不生效(min-height 0px),
// 故仅在显式 height 模式下应用
const heightMode = s(settings.height);
const ch = sizeCss(settings.custom_height);
if (heightMode === "min-height" && ch) style.minHeight = ch;
else if (heightMode === "full" && ch) style.height = ch;
// section 边框(Elementor 仅在 border_border 有值时生成;页脚 a428bb4 顶部点线)
const secBorder = s(settings.border_border);
if (secBorder) {
style.borderStyle = secBorder as CSSProperties["borderStyle"];
const bw = paddingCss(settings.border_width);
if (bw) style.borderWidth = bw;
const bc = s(settings.border_color);
if (bc) style.borderColor = bc;
}
// section 阴影(Elementor box_shadow 控件;联系页 5ed2d55 = 0 10px 50px rgba(71,77,255,.2))
const sh = settings.box_shadow_box_shadow as { horizontal?: number; vertical?: number; blur?: number; spread?: number; color?: string } | undefined;
if (sh && (sh.blur || sh.spread || sh.horizontal || sh.vertical)) {
style.boxShadow = `${sh.horizontal || 0}px ${sh.vertical || 0}px ${sh.blur || 0}px ${sh.spread || 0}px ${sh.color || "rgba(0,0,0,0.5)"}`;
}
// 列内容垂直对齐(content_position=middle → align-items:center;页脚 d834160 实测)
const contentPos = s(settings.content_position);
if (contentPos === "middle") inner.alignItems = "center";
else if (contentPos === "top") inner.alignItems = "flex-start";
else if (contentPos === "bottom") inner.alignItems = "flex-end";
if (overlay) style.position = style.position || "relative";
const Tag = sectionTag(settings);
const vis = visibilityClass(settings);
return (
<Tag className={vis || undefined} style={style} data-el={el.id}>
{overlay}
{/* 行加 relative:与绝对定位遮罩同属定位层,DOM 靠后绘制在上,内容不被蒙灰 */}
<div className="mx-auto flex flex-wrap" style={{ ...inner, position: "relative" }}>
{(el.elements || []).map((c, i) => (
<ElementNode key={c.id || i} el={c} gapPad={gapPad} contentPos={contentPos} />
))}
</div>
</Tag>
);
}
/** # 函数 盒模型值是否非全零(全零 _margin 视为未设置,kit-spacing 的 20px 才能生效;
* 原站 H11 text-editor _margin 全零但实测 margin-bottom 20px)
*/
function boxNonZero(v: unknown): boolean {
if (typeof v === "number") return v !== 0;
if (typeof v === "string") return /[^0\s.]/.test(v.replace(/(px|rem|em|%)/g, ""));
if (Array.isArray(v)) return v.some((x) => boxNonZero(x));
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return (["top", "right", "bottom", "left"] as const).some((k) => boxNonZero(o[k]));
}
return false;
}
/** # 组件 column:双层对应原站 .elementor-column(外层)+ .elementor-element-populated(内层)
* 外层参与 flex 布局(按 _inline_size 百分比否则均分),不承载盒样式;
* 内层承载 populated 的盒样式:_margin(margin,移动卡片间距且不影响 flex 宽度,与原站一致)、
* padding、背景、边框、圆角。
* padding:列自带 settings.padding 优先;否则用所属 section 的 gap 默认值
* (frontend.min.css .elementor-column-gap-default > … > .elementor-element-populated{padding:10px},
* 实测 H6 列 59bc2e1 四向 10px、H7 列 66de90a 设置覆盖为 8/16/16/0,两端一致)。
*/
function ColumnNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string; contentPos?: string }) {
const settings = resolveGlobals(el.settings || {});
const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" };
// _inline_size 既有数字也有字符串(联系页 ec3c8ff = "60",原站生成 width:60%),统一转数值
const inlineRaw = settings._inline_size;
const inline = typeof inlineRaw === "number" ? inlineRaw : inlineRaw != null && inlineRaw !== "" ? Number(inlineRaw) : NaN;
if (Number.isFinite(inline) && inline > 0) {
outer.flex = `0 0 ${inline}%`;
outer.maxWidth = `${inline}%`;
}
// widget-wrap 对齐:Elementor 默认 align-items:normal(stretch,与线上 computed 一致);
// section content_position=middle 时 align-content/align-items 均为 center
// (页脚 d834160 生成规则;社交行因此与 gtranslate 下拉同高 29px)
const inner: CSSProperties = {};
if (contentPos === "middle") {
inner.alignItems = "center";
inner.alignContent = "center";
} else if (contentPos === "bottom") {
inner.alignItems = "flex-end";
inner.alignContent = "flex-end";
}
const mg = paddingCss(settings.margin);
if (mg) {
// margin 写在 populated(与 Elementor 相同):宽保持 auto——块级填充会自动扣除自身外边距
// (原站实测 H11 列 453.33 宽 + margin-right 10 → populated 443.33,与 auto 行为一致);
// 全零 margin 视为未设置(boxNonZero),避免覆盖 kit-spacing
if (boxNonZero(settings.margin)) inner.margin = mg;
}
inner.padding = paddingCss(settings.padding) || gapPad || "10px";
const bg = sectionBgStyle(settings);
// 背景对象整体并入(颜色/图片/位置/平铺/尺寸,H13 楼体图 bottom left + 100% auto 实测)
Object.assign(inner, bg);
const radius = paddingCss(settings.border_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);
if (bw && bw.split(" ").length === 4) {
const [t, r2, b, l] = bw.split(" ");
inner.borderWidth = `${t} ${r2} ${b} ${l}`;
} 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}>
{(el.elements || []).length ? (
<div style={{ display: "flex", flexWrap: "wrap", alignContent: "flex-start", width: "100%", ...inner }}>
{(el.elements || []).map((c, i) => (
<ElementNode key={c.id || i} el={c} gapPad={gapPad} />
))}
</div>
) : null}
</div>
);
}
/** # 组件 widget 分发:按 widgetType 渲染,未知类型优雅降级
* 双层对应原站结构:外层 div = .elementor-widget(挂 data-el=<id>,页面级规则与
* _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<string, unknown>;
const style: CSSProperties = {};
const pad = paddingCss(settings._padding);
if (pad) style.padding = pad;
const mrg = paddingCss(settings._margin);
if (mrg && boxNonZero(settings._margin)) 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 = (
<WidgetGuard widgetType={s(el.widgetType)}>
<WidgetNodeInner el={el} />
</WidgetGuard>
);
// 原站 .elementor-widget-wrap > .elementor-element { width:100% }:
// widget 默认满宽(flex wrap 中独占一行),_element_width=auto 时按内容宽(横排)
const widthAuto = s(settings._element_width) === "auto";
const cls = s(settings._css_classes) || undefined;
const vis = visibilityClass(settings);
const baseCls = [cls, widthAuto ? "el-w-auto" : "", vis].filter(Boolean).join(" ") || undefined;
// 有桌面盒样式,或有响应式 _margin_*/_padding_*(移动端需挂在内层容器,页脚 c015d97
// -1rem 上外距靠内层负外距把整块高度压到 15px)→ 双层结构
const hasResponsiveBox =
boxNonZero(settings._margin_mobile) || boxNonZero(settings._padding_mobile) ||
boxNonZero(settings._margin_tablet) || boxNonZero(settings._padding_tablet);
const hasContainerStyle = Object.keys(style).length > 0 || hasResponsiveBox;
if (hasContainerStyle) {
return (
// 外层也要带 el-w-auto:列内基础规则按 :not(.el-w-auto) 放行 width:auto 的根,
// 否则 width-auto widget(如图标)被 width:100% 强推成每行一个(H7 回归根因)
<div className={[widthAuto ? "el-w-auto" : "", vis].filter(Boolean).join(" ") || undefined} data-el={el.id}>
<div className={baseCls} style={{ width: widthAuto ? "auto" : "100%", ...style }}>{inner}</div>
</div>
);
}
return <div className={baseCls} style={{ width: widthAuto ? "auto" : "100%" }} data-el={el.id}>{inner}</div>;
}
/** # 组件 单个 widget 渲染错误边界:异常 widget 降级为空,不拖垮整页 */
class WidgetGuard extends Component<{ widgetType: string; children: ReactNode }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
componentDidCatch(err: unknown) { console.warn("[elementor] widget 渲染失败:", this.props.widgetType, err); }
render() { return this.state.failed ? null : this.props.children; }
}
function WidgetNodeInner({ el }: { el: ElElement }) {
const w = s(el.widgetType);
const settings = resolveGlobals(el.settings || {});
const shortcodes = useContext(ShortcodesContext);
// 任意 widget 可由调用方按 id 注入实现(WooCommerce 归档/WPC 筛选等动态插件 widget)
const injected = el.id ? shortcodes[el.id] : undefined;
if (injected !== undefined) return <>{injected}</>;
const unknownFallback = () => {
const title = s(settings.heading_title) || s(settings.title);
const text = s(settings.editor) || s(settings.html) || s(settings.text);
if (!title && !text) return null;
return (
<div className="mb-[18px]">
{title ? <h3>{title}</h3> : null}
{text ? <div dangerouslySetInnerHTML={{ __html: text }} /> : null}
</div>
);
};
switch (w) {
case "heading":
return <HeadingWidget settings={settings} />;
case "woocommerce-breadcrumb":
return <BreadcrumbWidget />;
case "text-editor":
return <TextEditorWidget settings={settings} />;
case "image":
return <ImageWidget settings={settings} />;
case "button":
return <ButtonWidget el={el} settings={settings} />;
case "html":
// 间距交由 kit-spacing 列规则(.el-col > div > [data-el]),内层不再加 mb;
// 媒体地址经 localSiteUrl 改写为本地 /uploads(原站绝对地址 → 本地)
return s(settings.html) ? (
<div dangerouslySetInnerHTML={{ __html: localSiteUrl(stripStructuralTags(s(settings.html))) }} />
) : null;
case "image-box":
return <ImageBoxWidget settings={settings} />;
case "icon-box":
return <IconBoxWidget settings={settings} />;
case "eael-info-box":
return <EaelInfoBoxWidget settings={settings} />;
case "flip-box":
return <FlipBoxWidget settings={settings} />;
case "eael-adv-tabs":
return <AdvTabsWidget el={el} settings={settings} />;
case "eael-data-table":
return <DataTableWidget settings={settings} />;
case "eael-testimonial":
return <TestimonialWidget settings={settings} />;
case "reviews":
return <ReviewsWidget settings={settings} />;
case "icon-list":
return <IconListWidget settings={settings} />;
case "icon":
return <IconWidget settings={settings} />;
case "spacer":
return <div style={{ height: sizeCss(settings.space, "px") || "20px" }} />;
case "divider":
// Elementor divider DOM(页脚 c015d97:padding-block 15px + 1px #4D4E53 分隔线,
// 高 31px,与 widget 的 20px 下外距合计 51px 间距,实测 2026-11-02)
return (
<div className="elementor-divider" style={{ paddingBlock: sizeCss(settings.gap) || "15px" }}>
<span
className="elementor-divider-separator"
style={{ display: "block", width: "100%", borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}` }}
/>
</div>
);
case "toggle":
return <ToggleWidget el={el} settings={settings} />;
case "accordion":
return <AccordionWidget settings={settings} />;
case "tabs":
return <TabsWidget settings={settings} />;
case "slides":
return <SlidesWidget settings={settings} />;
case "eael-content-ticker":
return <TickerWidget settings={settings} />;
case "posts":
return <PostsWidget el={el} settings={settings} />;
case "social-icons":
return <SocialIconsWidget settings={settings} />;
case "eael-contact-form-7":
return <ContactFormWidget el={el} settings={settings} />;
case "video":
return <VideoWidget settings={settings} />;
case "counter":
return (
<div className="mb-[18px]" style={{ textAlign: "center" }}>
<div style={{ fontSize: 34, fontWeight: 700, color: "var(--brand)" }}>
{s(settings.prefix)}
{s(settings.ending_number) || "0"}
{s(settings.suffix)}
</div>
<div style={{ color: "var(--muted)" }}>{s(settings.title)}</div>
</div>
);
case "shortcode":
// WordPress 短代码无法在 Node 端执行:由调用方按 widget id 传入预渲染节点
// (页脚 post 58:0683e73 → menu-2 移动端折叠菜单;2481635 → gtranslate 语言切换)
return shortcodes[s(el.id)] ?? null;
case "code-highlight":
return s(settings.source_code) ? (
<pre className="mb-[18px]" style={{ background: "#0b1c2c", color: "#d7e5f5", padding: 16, borderRadius: 8, overflowX: "auto" }}>
<code>{s(settings.source_code)}</code>
</pre>
) : null;
default:
return unknownFallback();
}
}
/** # 组件 woocommerce-breadcrumb widget:面包屑(路径由 vars.breadcrumb 注入;
* 原站归档页模板 1398 的 hero 即此 widget,末项为当前页、前面项带链接) */
function BreadcrumbWidget() {
const vars = useContext(VarsContext);
const trail = vars.breadcrumb?.length ? vars.breadcrumb : [{ label: "Home", url: "/" }, { label: "Shop" }];
return (
<nav className="woocommerce-breadcrumb" aria-label="Breadcrumb">
{trail.map((n, i) => (
<Fragment key={`${n.label}-${i}`}>
{i > 0 ? <span className="ds-crumb-sep" dangerouslySetInnerHTML={{ __html: "&nbsp;/&nbsp;" }} /> : null}
{n.url && i < trail.length - 1 ? <a href={n.url}>{n.label}</a> : <span>{n.label}</span>}
</Fragment>
))}
</nav>
);
}
/** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
// 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2
const size = s(settings.header_size) || "h2";
const Tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(size) ? size : "h2") as "h2";
const style: CSSProperties = {};
if (s(settings.title_color)) style.color = s(settings.title_color);
const align = s(settings.align);
if (align) style.textAlign = align as CSSProperties["textAlign"];
Object.assign(style, typographyCss(settings));
// 基础规则(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 (
<Tag className="el-heading-title" style={style}>
{linkProps ? (
<a {...linkProps} dangerouslySetInnerHTML={{ __html: title }} />
) : (
<span dangerouslySetInnerHTML={{ __html: title }} />
)}
</Tag>
);
}
/** # 组件 text-editor widget:富文本 HTML */
function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) {
let html = s(settings.editor);
// 动态标签 current-date-time(页脚 1fe90e6 版权年份):editor 为空 + __dynamic__.editor
// 含 current-date-time → 按 before + Y 生成(原站实测 "Copyright ©2026")
const dynamic = s((settings.__dynamic__ as Record<string, unknown> | undefined)?.editor);
if (!html && dynamic.includes("current-date-time")) {
// settings 属性是 URL 编码的 JSON:settings="%7B%22date_format%22...%22before%22%3A%22Copyright%20%C2%A9%22"
let before = "Copyright ©";
const m = /settings="([^"]*)"/.exec(dynamic);
if (m) {
try {
const dj = JSON.parse(decodeURIComponent(m[1]!)) as { before?: string };
if (dj.before) before = dj.before;
} catch {
/* 解析失败用默认前缀 */
}
}
html = `${before}${new Date().getFullYear()}`;
}
if (!html) return null;
// 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用
const color = s(settings.text_color);
const style: CSSProperties = {};
if (color) style.color = color;
Object.assign(style, typographyCss(settings));
const align = s(settings.align);
if (align) style.textAlign = align as CSSProperties["textAlign"];
// _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复
return <div className="el-texteditor" style={Object.keys(style).length ? style : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
}
/** # 组件 image widget:图(可带 link;hover_animation=shrink 复刻原站缩小动画) */
function ImageWidget({ settings }: { settings: Record<string, unknown> }) {
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;
// 图片宽度设置(Elementor 生成 `.elementor-element-X img{width:57%}`;页脚 logo 57%、
// Google 徽章 300px):作用于 img 本身,a 为 inline-block 时按固有宽度收缩(与原站一致)
// 图片宽度:Elementor width 设置优先;本主题另有 space(%) 控制图片宽度(实测 63dd8a2=92%)
const imgWidth = sizeCss(settings.width) || sizeCss(settings.space);
const img = <img className={animCls || undefined} src={url} alt={s(img0.alt)} loading="lazy" style={imgWidth ? { width: imgWidth } : undefined} />;
const cls = ("el-image " + (animCls || "")).trim();
return (
<div className={cls} style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
{linkProps ? <a {...linkProps} tabIndex={-1}>{img}</a> : img}
</div>
);
}
/** # 组件 slides widget(Elementor 原生全宽轮播:背景图 + description HTML)
* 对齐原站取证值(见 app.css 轮播注释块):
* - 切换为水平推移(原站 swiper transition=slide,speed 500ms),激活 slide 内容
* 播放 fadeInUp(原站 data-animation=fadeInUp,duration 1s);
* - 自动播放 5000ms、悬停暂停(原站 autoplay_speed=5000 + pause_on_hover=yes);
* - slide 高度/内边距/箭头/圆点样式在 app.css 取证常量。
* 每张 slide 的标题/按钮内嵌在 description 自定义 HTML(ele-ban-* class)。
*/
function SlidesWidget({ settings }: { settings: Record<string, unknown> }) {
const slides = (settings.slides as Array<Record<string, unknown>>) || [];
const [idx, setIdx] = useState(0);
const [pause, setPause] = useState(false);
useEffect(() => {
if (slides.length < 2 || pause) return;
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 5000);
return () => clearInterval(t);
}, [slides.length, pause]);
if (!slides.length) return null;
const height = sizeCss(settings.slides_height, "px") || "560px";
const go = (d: number) => setIdx((i) => (i + d + slides.length) % slides.length);
return (
<div className="el-slides" style={{ height }}
onMouseEnter={() => setPause(true)} onMouseLeave={() => setPause(false)}>
{slides.map((sl, i) => {
const bg = (sl.background_image as { url?: string } | undefined)?.url;
// 水平推移:相邻 offset,轮播任意距离;非激活 slide 不捕捉指针
return (
<div key={(sl._id as string) || i} className={"el-slide" + (i === idx ? " active" : "")}
style={{ backgroundImage: bg ? `url(${bg})` : undefined, transform: `translateX(${(i - idx) * 100}%)` }}>
<div className="el-slide-inner">
<div className="el-slide-contents">
<div className="el-slide-desc" dangerouslySetInnerHTML={{ __html: s(sl.description) }} />
</div>
</div>
</div>
);
})}
{slides.length > 1 ? (
<>
<button className="el-slide-arrow prev" aria-label="Previous slide" onClick={() => go(-1)}>
<svg viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M646 125C629 125 613 133 604 142L308 442C296 454 292 471 292 487 292 504 296 521 308 533L604 854C617 867 629 875 646 875 663 875 679 871 692 858 704 846 713 829 713 812 713 796 708 779 692 767L438 487 692 225C700 217 708 204 708 187 708 171 704 154 692 142 675 129 663 125 646 125Z" />
</svg>
</button>
<button className="el-slide-arrow next" aria-label="Next slide" onClick={() => go(1)}>
<svg viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M696 533C708 521 713 504 713 487 713 471 708 454 696 446L400 146C388 133 375 125 354 125 338 125 325 129 313 142 300 154 292 171 292 187 292 204 296 221 308 233L563 492 304 771C292 783 288 800 288 817 288 833 296 850 308 863 321 871 338 875 354 875 371 875 388 867 400 854L696 533Z" />
</svg>
</button>
<div className="el-slide-dots">
{slides.map((_, i) => (
<button key={i} aria-label={"Go to slide " + (i + 1)} className={i === idx ? "on" : ""} onClick={() => setIdx(i)} />
))}
</div>
</>
) : null}
</div>
);
}
/** # 组件 eael-content-ticker(标签 + 最新内容标题轮播,淡入切换)
* 交互/样式全部在 TickerBar(复用组件)中按原站实测值还原;
* 数据:product → 最新发布产品(created_at 降序,与原站一致),post → 最新文章。
*/
function TickerWidget({ settings }: { settings: Record<string, unknown> }) {
const postType = s(settings.post_type) || "post";
const perPage = Number(s(settings.posts_per_page)) || 3;
const tagText = s(settings.eael_ticker_tag_text);
const speed = Number((settings.slider_speed as Record<string, unknown> | undefined)?.size) || 200;
const interval = Number((settings.autoplay_speed as Record<string, unknown> | undefined)?.size) || 5000;
const [titles, setTitles] = useState<LatestItem[]>([]);
useEffect(() => {
let alive = true;
(async () => {
try {
const rows = postType === "product" ? await getLatestProducts({ data: { limit: perPage } }) : await getLatestPosts();
if (alive) setTitles(rows.slice(0, perPage));
} catch { /* 数据不可用时静默 */ }
})();
return () => { alive = false; };
}, [postType, perPage]);
if (!titles.length) return null;
return <TickerBar badge={tagText} items={titles} speed={speed} interval={interval} />;
}
/** # 组件 posts widget(Elementor Pro 卡片皮肤:文章/产品卡片网格) */
/** # 组件 posts widget(Elementor skin cards 忠实 DOM)
* DOM/样式对齐 widget-posts.min.css 的 cards 皮肤(原站 H18 与模板 27143 实测 2026-11-02):
* .elementor-posts-container.elementor-posts.elementor-posts--skin-cards.elementor-grid
* > article.elementor-post > .elementor-post__card(纵向 flex、白底、1px 边、圆角 3)
* > [a.__thumbnail__link > .__thumbnail(padding-bottom 比率) > img]
* + .__badge(仅缩略图存在时,绝对右上)
* + .__text(flex-grow)> .__title + .__excerpt
* + .__meta-data(text 之后,border-top + margin-top:auto,日期 F j, Y)
* 图片宽度 calc(100%+1px) 且为静态流(高度按原图比例,原站即如此);
* 缩略图比率来自 cards_item_ratio(默认 0.66)/ cards_item_ratio_mobile。
* 文章数据经 PostsDataContext 预取(server 端 loadPostsWidgetsData)。
*/
function PostsWidget({ el, settings }: { el: ElElement; settings: Record<string, unknown> }) {
const allPosts = useContext(PostsDataContext);
const posts = (el.id && allPosts[el.id]) || [];
if (!posts.length) return null;
const numVal = (v: unknown): number | undefined => {
const n = typeof v === "number" ? v : Number(s(v));
return Number.isFinite(n) && n > 0 ? n : undefined;
};
const showThumb = s(settings.cards_thumbnail) !== "none";
const cols = numVal(settings.cards_columns) ?? 1;
const colsT = numVal(settings.cards_columns_tablet) ?? Math.min(cols, 2);
const colsM = numVal(settings.cards_columns_mobile) ?? 1;
const rowGap = sizeCss(settings.cards_row_gap) || "16px";
const colGap = sizeCss(settings.cards_column_gap) || "10px";
const textPad = sizeCss(settings.cards_card_padding) || "12px";
const titleSpacing = sizeCss(settings.cards_title_spacing) || "16px";
const excSpacing = sizeCss(settings.cards_excerpt_spacing) || "44px";
const excerptColor = s(settings.cards_excerpt_color) || "#777";
const excerptSize = sizeCss(settings.cards_excerpt_typography_font_size) || "14px";
const titleSize = sizeCss(settings.cards_title_typography_font_size) || "18px";
const metaSize = sizeCss(settings.cards_meta_typography_font_size) || "12px";
const metaWeight = s(settings.cards_meta_typography_font_weight) || "400";
const cardBorderColor = s(settings.cards_card_border_color) || "#69727d";
const cardBorderWidth = sizeCss(settings.cards_card_border_width) || "1px";
// 缩略图比率:desktop 默认 0.66,mobile 默认继承(27143/ H18 均为 0.6/0.67)
const ratio = numVal((settings.cards_item_ratio as { size?: unknown } | undefined)?.size) ?? 0.66;
const ratioM = numVal((settings.cards_item_ratio_mobile as { size?: unknown } | undefined)?.size) ?? ratio;
const titleTag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(s(settings.cards_title_tag)) ? s(settings.cards_title_tag) : "h3") as "h3";
const TitleTag = titleTag;
// 徽章/日期/摘要开关:switcher 键缺省 = 开(左卡无键但有徽章/摘要的实测语义),
// 空串 = 显式关(右卡 cards_show_badge/cards_show_excerpt 均为空串)
const showBadge = settings.cards_show_badge === undefined ? true : s(settings.cards_show_badge) !== "";
const showMeta = Array.isArray(settings.cards_meta_data) && (settings.cards_meta_data as string[]).includes("date");
const showExcerpt = settings.cards_show_excerpt === undefined ? true : s(settings.cards_show_excerpt) !== "";
// 日期格式 F j, Y(October 21, 2026)
const fmtDate = (d: Date | string | null) => {
if (!d) return null;
const dt = typeof d === "string" ? new Date(d) : d;
if (isNaN(dt.getTime())) return null;
return dt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
};
const thumbVars = { "--ep-ratio": String(ratio), "--ep-ratio-m": String(ratioM) } as CSSProperties;
return (
<div
className="elementor-posts-container elementor-posts elementor-posts--skin-cards elementor-posts--thumbnail-top elementor-posts__hover-gradient elementor-grid"
style={{
display: "grid",
gap: `${rowGap} ${colGap}`,
// 列数随断点(Elementor grid-4 / tablet-2 / mobile-1;值由 app.css 媒体查询读取)
["--ep-cols" as string]: String(cols),
["--ep-cols-t" as string]: String(colsT),
["--ep-cols-m" as string]: String(colsM),
}}
>
{posts.map((p) => {
// 链接前缀按主分类:News → /news/,其余 → /blog/(原站 News 文章走 /news/<slug>/)
const href = (p.categoryName === "News" ? "/news/" : "/blog/") + p.slug + "/";
const hasThumb = showThumb && !!p.imageUrl;
return (
<article key={p.slug} className="elementor-post elementor-grid-item post type-post status-publish format-standard has-post-thumbnail">
<div className="elementor-post__card" style={{ border: `${cardBorderWidth} solid ${cardBorderColor}` }}>
{hasThumb ? (
<a className="elementor-post__thumbnail__link" href={href} tabIndex={-1}>
<div className="elementor-post__thumbnail" style={thumbVars}>
<img src={localSiteUrl(p.imageUrl!)} alt={p.title} loading="lazy" />
</div>
</a>
) : null}
{hasThumb && showBadge && p.categoryName ? (
<div className="elementor-post__badge">{p.categoryName}</div>
) : null}
<div className="elementor-post__text" style={{ padding: `0 ${textPad}` }}>
<TitleTag className="elementor-post__title" style={{ fontSize: titleSize, marginBottom: titleSpacing }}>
<a href={href}>{p.title}</a>
</TitleTag>
{showExcerpt ? (
<div className="elementor-post__excerpt" style={{ marginBottom: excSpacing }}>
<p
style={{ color: excerptColor, fontSize: excerptSize }}
dangerouslySetInnerHTML={{ __html: postExcerpt(p) }}
/>
</div>
) : null}
</div>
{showMeta && p.publishedAt ? (
<div
className="elementor-post__meta-data"
style={{ padding: `10px ${textPad}`, fontSize: metaSize, fontWeight: metaWeight }}
>
<span className="elementor-post-date">{fmtDate(p.publishedAt)}</span>
</div>
) : null}
</div>
</article>
);
})}
</div>
);
}
/** # 组件 social-icons widget(图标行) */
/** # 函数 文章卡摘要:excerpt 优先,空则取正文纯文本前 N 词(WP wp_trim_words 语义) */
function postExcerpt(p: PostCardItem): string {
return p.excerpt && p.excerpt.trim() ? p.excerpt : postWords(p.content || "", 18);
}
/** # 函数 纯文本取前 N 词(去 HTML 标签后按空白分词,"…" 结尾) */
function postWords(html: string, n: number): string {
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
const words = text.split(" ");
return words.length <= n ? text : words.slice(0, n).join(" ") + " …";
}
/** # 组件 social-icons widget:忠实 Elementor DOM(widget-social-icons 语义,H16 实测)
* .elementor-social-icons-wrapper.elementor-grid(inline-grid + column-gap=icon_spacing,
* 由父级 text-align 居中)> a.elementor-icon.elementor-social-icon.elementor-social-icon-<name>
* (官方品牌底色/44×44 盒/圆角 10%/白色字形 22px/hover shrink)> span.elementor-screen-only + i.fab;
* 字形色原站为白色(SVG fill #fff,icon_primary_color 未作用到字形)。
*/
function SocialIconsWidget({ settings }: { settings: Record<string, unknown> }) {
const items = (settings.social_icon_list as Array<Record<string, unknown>>) || [];
if (!items.length) return null;
const spacing = sizeCss(settings.icon_spacing) || "10px";
const anim = s(settings.hover_animation) === "shrink" ? " elementor-animation-shrink" : "";
return (
<div className="elementor-social-icons-wrapper elementor-grid" style={{ columnGap: spacing }}>
{items.map((it, i) => {
const value = ((it.social_icon as { value?: string })?.value || "").trim();
// value 形如 "fab fa-linkedin":取末段去 fa- 前缀作品牌名(elementor-social-icon-<name>)
const name = (value.split(" ").pop() || "").replace(/^fa-/, "");
const label = name.replace(/^-/, "").replace(/^\w/, (c) => c.toUpperCase());
return (
<span key={s(it._id) || i} className="elementor-grid-item">
<a
className={"elementor-icon elementor-social-icon elementor-social-icon-" + name + anim}
href={s((it.link as { url?: string })?.url) || "#"}
target={s((it.link as { is_external?: string })?.is_external) === "true" || s((it.link as { is_external?: string })?.is_external) === "on" ? "_blank" : undefined}
rel="noopener nofollow"
aria-label={label}
>
<span className="elementor-screen-only">{label}</span>
<i className={value} aria-hidden="true" />
</a>
</span>
);
})}
</div>
);
}
/** # 组件 eael-contact-form-7(EAEL Contact Form 7 皮肤):渲染 CF7 604 忠实表单
* DOM 层级仿 EAEL 输出:eael-contact-form-7-wrapper > eael-contact-form.eael-contact-form-7 > wpcf7 > form。
* 控件样式按原站 post-840.css 生成规则 + widget 设置生成节点级 <style>
* (字段 1px #C9CEE8 边框/圆角 3、textarea 134px、select #efefef/#757575、提交 #ED6C00→#FFA11B)。
* 基础规则用 :where() 降特异性(0,0,0),保证页面注入的 custom_css
* (.ele-new-form4-dif 的 p 两列浮动布局、submit 全宽等)与响应式规则优先生效。
*/
function ContactFormWidget({ el, settings }: { el: ElElement; settings: Record<string, unknown> }) {
const sid = el.id;
const fieldBorder = s(settings.field_border_color) || "#C9CEE8";
const taH = sizeCss(settings.textarea_height) || "134px";
const btnRadius = paddingCss(settings.button_border_radius) || "3px";
const css = `
[data-el="${sid}"] :where(.wpcf7-form p){margin:0 0 20px}
[data-el="${sid}"] :where(.wpcf7-form label){display:block;margin-bottom:0;line-height:1;font-size:16px;color:#333}
[data-el="${sid}"] :where(.wpcf7-form-control-wrap){display:block}
[data-el="${sid}"] :where(.wpcf7-text,.wpcf7-email,.wpcf7-tel,.wpcf7-select,.wpcf7-textarea){width:100%;height:42px;padding:8px 16px;margin:0;border:1px solid ${fieldBorder};border-radius:3px;background:#fff;color:#000;font-size:16px;line-height:24px;font-family:inherit}
[data-el="${sid}"] :where(.wpcf7-select){padding:10px 12px 9px;background:#efefef;color:#757575}
[data-el="${sid}"] :where(.wpcf7-textarea){height:${taH};line-height:1.5;resize:vertical}
[data-el="${sid}"] :where(input[type=submit]){width:100%;height:44px;margin:10px 0 0;padding:10px 20px;border:0;border-radius:${btnRadius};background:#ED6C00;color:#fff;font-size:16px;line-height:24px;cursor:pointer;transition:background .25s linear}
[data-el="${sid}"] :where(input[type=submit]:hover){background:#FFA11B}
[data-el="${sid}"] :where(.wpcf7-list-item){display:inline-block;margin:0 !important}
[data-el="${sid}"] :where(.wpcf7-list-item > label){display:block;font-size:14px}
[data-el="${sid}"] :where(.wpcf7-list-item input[type=checkbox]){margin:0 0 3px;vertical-align:middle}
[data-el="${sid}"] :where(.wpcf7-list-item-label){color:#9396A1;font-size:0.875rem;line-height:1}
[data-el="${sid}"] :where(.wpcf7-list-item-label a){color:#9396A1}
[data-el="${sid}"] :where(.ds-recaptcha-note){color:#9396A1;font-size:0.875rem}
[data-el="${sid}"] :where(.ds-recaptcha-note a){color:#9396A1}
[data-el="${sid}"] :where(.ds-submit-p){margin:0 !important}
[data-el="${sid}"] :where(.wpcf7-spinner){display:inline-block;position:relative;visibility:hidden;opacity:0.75;width:24px;height:24px;margin:0 24px;border:none;border-radius:100%;background-color:#23282d}
[data-el="${sid}"] :where(.wpcf7-response-output){margin:0 0.5em 2em;padding:0.2em 1em;border:2px solid transparent;font-size:16px}
[data-el="${sid}"] :where(.wpcf7-response-output.ds-ok){border-color:#46b450}
[data-el="${sid}"] :where(.wpcf7-response-output.ds-err){border-color:#dc3232}
[data-el="${sid}"] :where(.wpcf7-response-output[aria-hidden="true"]){display:none}
`;
return (
<div className="eael-contact-form-7-wrapper">
<style>{css}</style>
<div className="eael-contact-form eael-contact-form-7">
<div className="wpcf7">
<Cf7Form604 formKey="contact-form-1" spinner />
</div>
</div>
</div>
);
}
/** # 组件 button widget */
/** # 组件 Elementor button widget:样式全部来自设置(背景/文字色/边框/圆角/内边距/排版,
* globals 已由 resolveGlobals 解析为实色;text_padding/typography 等实测 H11 按钮 12px 48px/18px)。
* 基础对齐 widget-button 默认:inline-block、line-height 1、圆角 3px;字重走 kit 链接 400
* (.content-html a),不在此硬编码。悬停态(button_background_hover_color/hover_color)
* 经节点级 <style> 注入(行内样式无法表达 :hover)。
*/
function ButtonWidget({ el, settings }: { el: ElElement; settings: Record<string, unknown> }) {
const text = s(settings.text);
const link = (settings.link as { url?: string; target?: string } | undefined)?.url;
if (!link && !text) return null;
const style: CSSProperties = {
display: "inline-block",
lineHeight: 1,
padding: paddingCss(settings.text_padding) || "12px 24px",
borderRadius: paddingCss(settings.border_radius) || "3px",
background: s(settings.background_color) || "#818a91",
color: s(settings.button_text_color) || "#fff",
textDecoration: "none",
};
// border_width 是标量/盒值,collapseBoxValues 折叠四值简写("1px 1px 1px 1px" → "1px"),
// 否则 `${bw} solid` 拼出非法 border 声明被浏览器丢弃
const bw = paddingCss(settings.border_width);
if (s(settings.border_border) === "solid") {
style.border = `${bw ? collapseBoxValues(bw) : "1px"} solid ${s(settings.border_color) || "transparent"}`;
} else if (bw) style.borderWidth = collapseBoxValues(bw);
const fs = sizeCss(settings.typography_font_size);
if (fs) style.fontSize = fs;
const fwt = settings.typography_font_weight;
if (fwt !== undefined && fwt !== null && fwt !== "") style.fontWeight = Number(fwt) || fwt as number;
const hoverBg = s(settings.button_background_hover_color);
const hoverColor = s(settings.hover_color);
// 图标按钮(原站 Landing 的 New/HOT 徽章):Elementor DOM = content-wrapper(flex gap 5)
// > button-icon(1em) + button-text,图标 14px 使按钮宽 +19px
const icon = s((settings.selected_icon as { value?: string } | undefined)?.value);
const content = icon ? (
<span className="elementor-button-content-wrapper">
<span className="elementor-button-icon">
<i className={icon} aria-hidden="true" />
</span>
<span className="elementor-button-text">{text}</span>
</span>
) : (
text
);
return (
<div style={{ textAlign: (s(settings.align) as CSSProperties["textAlign"]) || "left", ...(icon ? { paddingBottom: 2 } : {}) }}>
{hoverBg || hoverColor ? (
<style>{`[data-el="${el.id}"] .el-btn:hover{${hoverBg ? `background:${hoverBg};` : ""}${hoverColor ? `color:${hoverColor};` : ""}}`}</style>
) : null}
<a className="el-btn" href={link || "#"} style={style} target={(settings.link as { target?: string } | undefined)?.target === "_blank" ? "_blank" : undefined} rel="noreferrer">
{content}
</a>
{/* 原站 wrapper 内含空白文本节点:inline-block 按钮所在行盒会叠加上正文 strut,
父级高度 28(按钮 26 + 2px descender)。JSX 需显式补一个空格复现。 */}
{" "}
</div>
);
}
/** # 组件 image-box widget:图 + 标题 + 描述(忠实复刻原站 Elementor DOM)
* 结构(image-box.php render 对应,见 legacy 镜像):
* .el-imagebox > .el-imagebox-wrapper(text-align center) >
* figure.el-imagebox-img(≥768 position-top 时 margin auto 居中;≤767 margin-bottom 15px)
* > [a(tabindex -1) > img]
* + .el-imagebox-content > <Tag>title</Tag> + <p>description</p>;
* figure 内的 a 与标题内 a 是两个独立链接(原站同一 link 设置、各自 target=_blank);
* title 标签由 title_size 决定(默认 h3);颜色继承 body(原站 .elementor-image-box-title a
* color:inherit → 实测 #333),hover 提亮等由页面级规则([data-el] 范围)定义。
* 复用:首页 H2 六卡(图上标题下)与 H13/Difference Maker 六卡同源。
*/
function ImageBoxWidget({ settings }: { settings: Record<string, unknown> }) {
const url = (settings.image as { url?: string } | undefined)?.url;
const title = s(settings.title_text);
const desc = s(settings.description_text);
if (!url && !title && !desc) return null;
const link = (settings.link as { url?: string } | undefined)?.url;
const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
const pos = s(settings.position);
// 原站未设置时渲染 elementor-position-top(控件默认 top,与原站 HTML 一致)
const posCls = " el-imagebox-pos-" + (pos || "top");
const alt = title.replace(/<[^>]*>/g, "").trim();
// link 通用属性(is_external → target=_blank,与原站 elementor link_attributes 一致)
const linkProps = link
? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) }
: undefined;
// title 标签:title_size 合法值校验(原站 Utils::validate_html_tag),默认 h3
const tagOpt = s(settings.title_size) || "h3";
const tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(tagOpt)
? tagOpt
: "h3") as "div";
const Tag = tag as "div";
// position left/right:wrapper 为 flex 行(widget-image-box.min.css .elementor-position-left
// .elementor-image-box-wrapper{display:flex},实测 H11 卡内图标行);
// 图宽 image_size%(post-18 由 image_size 控件生成 .elementor-image-box-img{width:18%})、
// 左侧布局 fig 右距 15px;标题底距 title_bottom_space(H11 实测 4px)
const isRow = pos === "left" || pos === "right";
const imgW = sizeCss(settings.image_size);
const titleSpace = sizeCss(settings.title_bottom_space);
// 图与标题间距 image_space(H13.5 counter 条实测 6px,生成规则 margin-bottom:6px)
const imgSpace = sizeCss(settings.image_space);
// 内容垂直对齐(content_vertical_alignment 控件,仅 left/right 行布局生效):
// 由 per-widget 生成规则输出 align-items(514e513 middle → center 实测;否则 flex 默认 stretch)
const vaMap: Record<string, string> = { top: "flex-start", middle: "center", bottom: "flex-end" };
const va = isRow ? vaMap[s(settings.content_vertical_alignment)] : undefined;
// 文本对齐 text_align(H13.5 counter 条 left)与标题/描述排版
// (title_typography_* / description_typography_* 组合字段,counter 条 36px/700 橙 + 18px 描述)
const textAlign = s(settings.text_align) as CSSProperties["textAlign"] | undefined;
const titleStyle: CSSProperties = {
...(titleSpace ? { marginBottom: titleSpace } : {}),
...typographyCss(settings, "title_typography"),
};
// 标题色 title_color(resolveGlobals 已解析 globals/colors?id=primary → #ED6C00)
if (s(settings.title_color)) titleStyle.color = s(settings.title_color) as string;
const descStyle = typographyCss(settings, "description_typography");
// flex/边距在 app.css 的 .el-imagebox-pos-* 规则(带 768 断点),这里只带 per-widget 图宽与垂直对齐
const figStyle: CSSProperties | undefined = isRow && imgW
? { width: imgW }
: imgSpace
? { marginBottom: imgSpace }
: undefined;
return (
<div className={"el-imagebox" + posCls}>
<div className="el-imagebox-wrapper" style={{ ...(va ? { alignItems: va } : {}), ...(textAlign ? { textAlign } : {}) }}>
{url ? (
<figure className="el-imagebox-img" style={figStyle}>
{link ? (
<a {...linkProps} tabIndex={-1}><img src={url} alt={alt} loading="lazy" /></a>
) : (
<img src={url} alt={alt} loading="lazy" />
)}
</figure>
) : null}
{title || desc ? (
<div className="el-imagebox-content">
{title ? (
<Tag className="el-imagebox-title" style={Object.keys(titleStyle).length ? titleStyle : undefined}>
{link ? (
<a {...linkProps} dangerouslySetInnerHTML={{ __html: title }} />
) : (
// 无链接时:原站文本直接落在标签内,这里用内联 span 承载(不改变样式)
<span dangerouslySetInnerHTML={{ __html: title }} />
)}
</Tag>
) : null}
{desc ? (
<p
className="el-imagebox-description"
style={Object.keys(descStyle).length ? descStyle : undefined}
dangerouslySetInnerHTML={{ __html: desc }}
/>
) : null}
</div>
) : null}
</div>
</div>
);
}
/** # 组件 icon-box / eael-info-box:图标可跳过(emoji 不渲染),只保留标题与描述 */
function IconBoxWidget({ settings }: { settings: Record<string, unknown> }) {
const title = s((settings as Record<string, unknown>).title_text) || s((settings as Record<string, unknown>).title);
const desc = s((settings as Record<string, unknown>).description_text) || s((settings as Record<string, unknown>).description);
const icon = (settings as Record<string, unknown>).selected_icon as { value?: string } | undefined;
const titleColor = s((settings as Record<string, unknown>).title_color) || s((settings as Record<string, unknown>).button_text_color);
// 图标是 emoji/unicode 字符或字体图标类:一律不渲染为图标,跳过
void icon;
if (!title && !desc) return null;
return (
<div className="mb-[18px]" style={{ border: "1px solid var(--line)", borderRadius: 8, padding: 18 }}>
{title ? <h3 style={{ margin: "0 0 8px", fontSize: 17, ...(titleColor ? { color: titleColor } : {}) }} dangerouslySetInnerHTML={{ __html: title }} /> : null}
{desc ? <div style={{ color: "var(--muted)" }} dangerouslySetInnerHTML={{ __html: desc }} /> : null}
</div>
);
}
/** # 组件 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<string, unknown> }) {
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);
const img = settings.eael_infobox_image as { url?: string; alt?: string } | undefined;
const icon = settings.eael_infobox_icon_new as { value?: string; library?: string } | undefined;
const iconSize = sizeCss(settings.eael_infobox_icon_size) || "26px";
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";
// 原站 EAEL DOM:.eael-infobox.icon-on-top / .icon-on-left(配图上方/左侧布局)
const cls = "eael-infobox " + (left ? "icon-on-left" : "icon-on-top");
// 标题排版/颜色(H14 实测 fs 1.25rem/700/#0D152E;内容排版 hover 键原站未生效,不采用)
const titleStyle: CSSProperties = {
...typographyCss(settings, "eael_infobox_title_typography"),
...(s(settings.eael_infobox_title_color) ? { color: s(settings.eael_infobox_title_color) as string } : {}),
};
// 标题外距(EAEL:设置非空时覆盖默认 8px 0 16px;570df98 显式 0 0 0 0,02956ef 走默认)
const tmRaw = settings.eael_infobox_title_margin as Record<string, unknown> | undefined;
const tmSet = !!tmRaw && ["top", "right", "bottom", "left"].some((k) => tmRaw[k] !== "" && tmRaw[k] != null);
if (tmSet) {
const tm = paddingCss(tmRaw);
if (tm) titleStyle.margin = tm;
}
// 内容段落外距/颜色(EAEL 生成规则:eael_infobox_content_margin → p margin,
// eael_infobox_content_color → p color;570df98 实测 margin 10px 0 0、#969696)
const cmRaw = settings.eael_infobox_content_margin as Record<string, unknown> | undefined;
const cmSet = !!cmRaw && ["top", "right", "bottom", "left"].some((k) => cmRaw[k] !== "" && cmRaw[k] != null);
const contentVars: CSSProperties = {};
if (cmSet) {
const cm = paddingCss(cmRaw);
if (cm) {
const [t, r2, b, l] = cm.split(" ");
Object.assign(contentVars, {
"--ib-p-top": t ?? "0",
"--ib-p-right": r2 ?? "0",
"--ib-p-bottom": b ?? "0",
"--ib-p-left": l ?? "0",
});
}
}
const contentColor = s(settings.eael_infobox_content_color);
if (contentColor) Object.assign(contentVars, { "--ib-p-color": contentColor });
return (
<div className={cls}>
{img?.url ? (
<div className="infobox-icon">
<img src={img.url} alt={s(img.alt)} loading="lazy" />
</div>
) : icon?.value ? (
// 图标型 info-box(df5b95b:fas fa-check-circle 26px,左侧图标 + 标题)
<div className="infobox-icon">
<div className="infobox-icon-wrap">
<i className={icon.value} style={{ fontSize: iconSize, lineHeight: 1 }} aria-hidden="true" />
</div>
</div>
) : null}
<div className="infobox-content">
{title ? (
<div className="infobox-title-section">
<Tag className="title" style={Object.keys(titleStyle).length ? titleStyle : undefined} dangerouslySetInnerHTML={{ __html: title }} />
{s(settings.eael_infobox_sub_title) && s(settings.eael_infobox_sub_title) !== "This is a sub title" ? (
<div className="sub_title" dangerouslySetInnerHTML={{ __html: s(settings.eael_infobox_sub_title) }} />
) : null}
</div>
) : null}
{desc ? <div className="infobox-text" style={Object.keys(contentVars).length ? contentVars : undefined} dangerouslySetInnerHTML={{ __html: desc }} /> : null}
</div>
</div>
);
}
/** # 组件 flip-box(翻转框):静态渲染正面(标题/描述/按钮 + 可选背景图) */
function FlipBoxWidget({ settings }: { settings: Record<string, unknown> }) {
const title = s(settings.title_text_a);
const desc = s(settings.description_text_a);
const btn = s(settings.button_text);
const link = (settings.link as { url?: string } | undefined)?.url;
const bg = settings.background_a_image as { url?: string } | undefined;
if (!title && !desc) return null;
const style: CSSProperties = {
minHeight: sizeCss(settings.height) || "300px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 10,
padding: 24,
borderRadius: 6,
color: "#fff",
};
if (bg?.url) {
style.backgroundImage = `url("${bg.url}")`;
style.backgroundSize = "cover";
style.backgroundPosition = "center";
}
const box = (
<div style={style}>
{title ? <h3 style={{ margin: 0, fontSize: 22 }}>{title}</h3> : null}
{desc ? <div dangerouslySetInnerHTML={{ __html: desc }} /> : null}
{btn ? (
<span className="inline-flex w-fit items-center rounded bg-[var(--brand)] px-4 py-2 text-sm text-white">{btn}</span>
) : null}
</div>
);
return link ? (
<a href={link} className="mb-[18px] block">
{box}
</a>
) : (
<div className="mb-[18px]">{box}</div>
);
}
/** # 组件 eael-adv-tabs(EAEL 高级标签页):结构同 tabs,键名不同 */
/** # 组件 EAEL adv-tabs widget(H12 Industries and Use Cases,6 标签横排)
* DOM 对齐原站(1436 实测 2026-11-01):#eael-advance-tabs-<id>.eael-advance-tabs.eael-tabs-horizontal >
* .eael-tabs-nav > ul[role=tablist] > li.eael-tab-item-trigger.eael-tab-nav-item(.active/.inactive,
* pad 16px 24px,flex 1 1 auto) > span.eael-tab-title.title-after-icon;
* .eael-tabs-content > div.eael-tab-content-item.clearfix#<slug>-tab(.active/.inactive,pad 16px 0 0)。
* 内容 tab_content 为自包含 HTML(use-fl/use-fr 图文布局),custom_css 经 collectCustomCss 注入。
* 基础样式移植 eael-18.css(app.css「EAEL adv-tabs」块);激活/文字色由 post-18 生成规则
* (li bg 26331af #F4F5F8、激活 #ED6C00、未激活 #333)落在 scoped 规则。
*/
function AdvTabsWidget({ el, settings }: { el: ElElement; settings: Record<string, unknown> }) {
const raw = Array.isArray(settings.eael_adv_tabs_tab)
? (settings.eael_adv_tabs_tab as { eael_adv_tabs_tab_title?: string; eael_adv_tabs_tab_content?: string }[])
: [];
const items = raw
.map((t) => ({ title: s(t.eael_adv_tabs_tab_title), content: s(t.eael_adv_tabs_tab_content) }))
.filter((t) => t.title || t.content);
const [active, setActive] = useState(0);
if (!items.length) return null;
// 内容面板 id:标题 slug 化(原站 building-automation / building-automation-tab 等)
const slug = (t: string) => t.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-");
return (
<div id={`eael-advance-tabs-${el.id}`} className="eael-advance-tabs eael-tabs-horizontal eael-tab-auto-active active-caret-on">
<div className="eael-tabs-nav">
<ul role="tablist">
{items.map((t, i) => (
<li
key={i}
id={slug(t.title)}
className={"eael-tab-item-trigger eael-tab-nav-item " + (i === active ? "active" : "inactive")}
role="tab"
data-tab={i + 1}
aria-selected={i === active}
aria-controls={`${slug(t.title)}-tab`}
aria-expanded="false"
tabIndex={i === active ? 0 : -1}
onClick={() => setActive(i)}
>
<span className="eael-tab-title title-after-icon">{t.title}</span>
</li>
))}
</ul>
</div>
<div className="eael-tabs-content">
{items.map((t, i) => (
<div
key={i}
id={`${slug(t.title)}-tab`}
className={"clearfix eael-tab-content-item " + (i === active ? "active" : "inactive")}
role="tabpanel"
style={{ display: i === active ? "block" : "none" }}
dangerouslySetInnerHTML={{ __html: t.content }}
/>
))}
</div>
</div>
);
}
/** # 组件 eael-data-table:表头 + 行渲染为普通表格(行可为字符串或单元格数组) */
function DataTableWidget({ settings }: { settings: Record<string, unknown> }) {
const headers = Array.isArray(settings.eael_data_table_header_cols_data)
? (settings.eael_data_table_header_cols_data as { eael_data_table_header_col?: string }[]).map((h) => s(h.eael_data_table_header_col))
: [];
const rows = Array.isArray(settings.eael_data_table_content_rows) ? (settings.eael_data_table_content_rows as unknown[]) : [];
if (!headers.length && !rows.length) return null;
// 单元格:行内 eael_data_table_content_row_title 可能是字符串或 {title/content} 数组
const cells = (r: unknown): string[] => {
const v = (r as Record<string, unknown>)?.eael_data_table_content_row_title;
const pick = (x: unknown) => s(typeof x === "object" && x ? ((x as Record<string, unknown>).title ?? (x as Record<string, unknown>).content) : x);
return Array.isArray(v) ? v.map(pick) : [pick(v)];
};
return (
<table className="mb-[18px] w-full border-collapse text-sm">
{headers.length ? (
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} className="border border-[var(--line)] bg-[#f4f5f8] px-3 py-2 text-left">
{h}
</th>
))}
</tr>
</thead>
) : null}
<tbody>
{rows.map((r, i) => (
<tr key={i}>
{cells(r).map((c, j) => (
<td key={j} className="border border-[var(--line)] px-3 py-2" dangerouslySetInnerHTML={{ __html: c }} />
))}
</tr>
))}
</tbody>
</table>
);
}
/** # 组件 eael-testimonial(评价引用):姓名/公司/描述 */
function TestimonialWidget({ settings }: { settings: Record<string, unknown> }) {
const name = s(settings.eael_testimonial_name);
const company = s(settings.eael_testimonial_company_title);
const desc = s(settings.eael_testimonial_description);
if (!name && !desc) return null;
return (
<blockquote className="mb-[18px] rounded-md border p-4">
{desc ? <div className="content-html" dangerouslySetInnerHTML={{ __html: desc }} /> : null}
{name ? <footer className="mt-2 font-medium">{name}{company ? <span className="text-muted-foreground"> · {company}</span> : null}</footer> : null}
</blockquote>
);
}
/** # 组件 reviews(评价轮播):静态卡片网格(slides 复用:name/title/rating/content/image) */
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 } }[])
: [];
if (!slides.length) return null;
const stars = (n: string) => "★★★★★".slice(0, Math.max(1, Math.min(5, Math.round(Number(n) || 5))));
return (
<div className="mb-[18px] grid gap-4 min-[760px]:grid-cols-2">
{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>
</div>
</div>
))}
</div>
);
}
/** # 组件 icon-list widget:忠实 Elementor DOM(widget-icon-list.min.css 语义)
* view=inline 时 ul.elementor-icon-list-items.elementor-inline-items(flex wrap,
* ul 负边距 -8px 与 li 8px 侧边距对冲,实测 H13 链接条);否则竖排 ul。
* li.elementor-icon-list-item(flex 居中) > [a(target=_blank) >] span.elementor-icon-list-text;
* 文字色 text_color 落 span(实测 #0D152E,a 本身继承 kit 主色不影响文字),
* 悬停色 text_color_hover(globals)由 H13 页面级生成规则注入;custom_css(.ele-supplier-dif
* 描边盒) 经 collectCustomCss 注入。
*/
function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
const list = Array.isArray(settings.icon_list)
? (settings.icon_list as { text?: string; _id?: string; link?: { url?: string; is_external?: string }; selected_icon?: { value?: string } }[])
: [];
// 允许只有图标没有文字的项(页脚社交图标行 text 为空、selected_icon=fab fa-*)
const items = list.filter((it) => s(it.text) || s(it.selected_icon?.value));
if (!items.length) return null;
const inline = s(settings.view) === "inline";
const color = s(settings.text_color) || undefined;
const iconColor = s(settings.icon_color) || undefined;
const iconSize = sizeCss(settings.icon_size);
const iconAlign = s(settings.icon_align);
// 项间距(space_between):Elementor 竖排 = padding-block-end(space/2) + margin-block-start(space/2),
// 横排 = li margin-inline(space/2) + ul 负 margin-inline(页脚 5px / 社交 10px,实测 li 高 23.5)
const half = halfLength(sizeCss(settings.space_between));
const typo = typographyCss(settings, "icon_typography");
// 颜色用 CSS 变量承载:文字/图标的内联色若写死十六进制,页面级 :hover 规则无法覆盖
// (内联样式优先级最高)。改为 span/i 引用 var(--el-il-text/icon),悬停规则改 li 上的变量值。
const vars: Record<string, string> = {};
if (color) vars["--el-il-text"] = color;
if (iconColor) vars["--el-il-icon"] = iconColor;
const ulStyle: CSSProperties = { ...typo };
Object.assign(ulStyle, vars);
if (iconAlign === "left") ulStyle.justifyContent = "flex-start";
else if (iconAlign === "center") ulStyle.justifyContent = "center";
else if (iconAlign === "right") ulStyle.justifyContent = "flex-end";
if (inline && half) ulStyle.marginInline = `-${half}`;
return (
<ul className={"elementor-icon-list-items" + (inline ? " elementor-inline-items" : "")} style={Object.keys(ulStyle).length ? ulStyle : undefined}>
{items.map((it, i) => {
const link = it.link?.url;
const external = it.link?.is_external === "on";
const icon = s(it.selected_icon?.value);
const liStyle: CSSProperties | undefined = half
? inline
? { marginInline: half }
: { paddingBottom: i < items.length - 1 ? half : undefined, marginTop: i > 0 ? half : undefined }
: undefined;
const iconStyle: CSSProperties = { color: iconColor ? "var(--el-il-icon)" : undefined, fontSize: iconSize };
const text = (
<span
className="elementor-icon-list-text"
style={color ? { color: "var(--el-il-text)" } : undefined}
dangerouslySetInnerHTML={{ __html: s(it.text) }}
/>
);
return (
<li key={it._id || i} className={"elementor-icon-list-item" + (inline ? " elementor-inline-item" : "")} style={liStyle}>
{link ? (
<a href={link} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined}>
{icon ? <span className="elementor-icon-list-icon"><i className={icon} aria-hidden="true" style={iconStyle} /></span> : null}
{text}
</a>
) : (
<>
{icon ? <span className="elementor-icon-list-icon"><i className={icon} aria-hidden="true" style={iconStyle} /></span> : null}
{text}
</>
)}
</li>
);
})}
</ul>
);
}
/** # 组件 icon widget(Elementor .elementor-widget-icon)
* 页脚 post 58 的 5ffe573:view=stacked + _position=fixed(左下角返回顶部按钮),
* 48×48 圆(图标 24px + padding 0.5em)、底色 secondary #FFA11B、悬停主色 #ED6C00(post-58.css 取证);
* 088727e(询价弹窗入口)在桌面/平板/移动全部隐藏,由可见性类名隐藏。
*/
function IconWidget({ settings }: { settings: Record<string, unknown> }) {
const icon = s((settings.selected_icon as { value?: string } | undefined)?.value);
if (!icon) return null;
const view = s(settings.view);
const size = sizeCss(settings.size) || "24px";
const primary = s(settings.primary_color);
const secondary = s(settings.secondary_color);
const link = (settings.link as { url?: string } | undefined)?.url;
const wrapper: CSSProperties = {};
if (s(settings._position) === "fixed") {
wrapper.position = "fixed";
const x = sizeCss(settings._offset_x);
const xe = sizeCss(settings._offset_x_end);
const y = sizeCss(settings._offset_y);
const ye = sizeCss(settings._offset_y_end);
if (s(settings._offset_orientation_h) === "end") {
if (xe) wrapper.right = xe;
} else if (x) wrapper.left = x;
else if (xe) wrapper.right = xe;
if (s(settings._offset_orientation_v) === "end") {
if (ye) wrapper.bottom = ye;
} else if (y) wrapper.top = y;
else if (ye) wrapper.bottom = ye;
wrapper.zIndex = 99;
wrapper.width = "auto";
}
const iconStyle: CSSProperties = { fontSize: size, lineHeight: 0 };
if (view === "stacked") {
iconStyle.display = "inline-flex";
iconStyle.alignItems = "center";
iconStyle.justifyContent = "center";
iconStyle.padding = "0.5em";
iconStyle.borderRadius = "50%";
iconStyle.backgroundColor = secondary || primary || "#FFA11B";
iconStyle.color = "#ffffff";
} else {
if (primary) iconStyle.color = primary;
if (view === "framed") {
iconStyle.display = "inline-flex";
iconStyle.padding = "0.5em";
iconStyle.borderRadius = "50%";
iconStyle.border = `3px solid ${primary || "currentColor"}`;
}
}
return (
<div className="elementor-icon-wrapper" style={{ textAlign: "center", ...wrapper }}>
{link ? (
<a className="elementor-icon" href={link} style={iconStyle}>
<i className={icon} aria-hidden="true" />
</a>
) : (
<div className="elementor-icon" style={iconStyle}>
<i className={icon} aria-hidden="true" />
</div>
)}
</div>
);
}
/** # 组件 Elementor 原生 toggle widget(FAQ 折叠问答)
* DOM 严格对齐原站(smart-gateways 846aeb5 实测 2026-11-02):
* .elementor-toggle > .elementor-toggle-item > h3.elementor-tab-title
* (> .elementor-toggle-icon > closed/opened 两个 span SVG + a.elementor-toggle-title)
* + div.elementor-tab-content。
* 交互:Elementor 默认全部收起、各条目独立开合;active 用 elementor-active 类,
* 收起/展开的图标与内容显隐由 widget-toggle.min.css 对应规则完成(app.css「Elementor toggle」)。
* 开启 faq_schema 时在原位置输出 FAQPage JSON-LD(与原站一致)。
*/
function ToggleWidget({ el, settings }: { el: ElElement; settings: Record<string, unknown> }) {
const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : [];
/** # 成员变量 已展开条目的索引集合(默认空:全部收起) **/
const [openSet, setOpenSet] = useState<Set<number>>(() => new Set());
if (!tabs.length) return null;
// 锚点 id 基数:原站为 Elementor 渲染计数器(1381…);本地用 widget id 的数值化
// 保证 SSR/水合稳定且同页多个 toggle 不冲突,仅影响锚点,无视觉作用
const base = parseInt(el.id ?? "0", 16) || 0;
const titleTag = s(settings.title_html_tag) || "h3";
const TitleTag = titleTag as "h3";
const iconValue = s((settings.selected_icon as { value?: string } | undefined)?.value) || "fas fa-caret-down";
const isCaret = iconValue.includes("caret");
/** # 函数 切换单个条目(Elementor toggle 允许同时展开多项) */
const toggle = (i: number) => {
setOpenSet((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
};
const faq = s(settings.faq_schema) === "yes";
return (
<>
<div className="elementor-toggle" style={{ paddingBottom: 4 }}>
{/* 原站 widget 元素比其容器高 4px(容器 310 + 4),桌面 314/移动 424;
该 4px 为 Elementor 布局尾巴,补在 toggle 底部以保持节高与后续元素位置一致 */}
{tabs.map((t, i) => {
const active = openSet.has(i);
const titleId = `elementor-tab-title-${base}${i + 1}`;
const contentId = `elementor-tab-content-${base}${i + 1}`;
return (
<div className="elementor-toggle-item" key={i}>
<TitleTag
id={titleId}
className={`elementor-tab-title${active ? " elementor-active" : ""}`}
data-tab={i + 1}
role="button"
aria-controls={contentId}
aria-expanded={active ? "true" : "false"}
onClick={() => toggle(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle(i);
}
}}
>
<span className="elementor-toggle-icon elementor-toggle-icon-left" aria-hidden="true">
<span className="elementor-toggle-icon-closed">
{isCaret ? (
<svg className="e-font-icon-svg e-fas-caret-down" viewBox="0 0 320 512" xmlns="http://www.w3.org/2000/svg">
<path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z" />
</svg>
) : (
<i className={iconValue} />
)}
</span>
<span className="elementor-toggle-icon-opened">
{isCaret ? (
<svg className="elementor-toggle-icon-opened e-font-icon-svg e-fas-caret-up" viewBox="0 0 320 512" xmlns="http://www.w3.org/2000/svg">
<path d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z" />
</svg>
) : (
<i className={iconValue} />
)}
</span>
</span>
<a className="elementor-toggle-title" tabIndex={0}>
{s(t.tab_title)}
</a>
</TitleTag>
<div
id={contentId}
className={`elementor-tab-content elementor-clearfix${active ? " elementor-active" : ""}`}
data-tab={i + 1}
role="region"
aria-labelledby={titleId}
dangerouslySetInnerHTML={{ __html: s(t.tab_content) }}
/>
</div>
);
})}
</div>
{faq ? (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: tabs.map((t) => ({
"@type": "Question",
name: s(t.tab_title),
acceptedAnswer: { "@type": "Answer", text: s(t.tab_content) },
})),
}),
}}
/>
) : null}
</>
);
}
/** # 组件 toggle(Pro accordion)旧实现:Radix 手风琴(待独立页面审计后替换) */
function AccordionWidget({ settings }: { settings: Record<string, unknown> }) {
const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : [];
if (!tabs.length) return null;
return (
<div className="mb-[18px]">
<Accordion type="single" collapsible defaultValue="item-0">
{tabs.map((t, i) => (
<AccordionItem key={i} value={`item-${i}`}>
<AccordionTrigger>{s(t.tab_title)}</AccordionTrigger>
<AccordionContent>
<div className="content-html" dangerouslySetInnerHTML={{ __html: s(t.tab_content) }} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}
/** # 组件 tabs widget:标签页 */
/** # 组件 Elementor 原生 tabs widget(H9 热门嵌入式硬件切换器)
* DOM 对齐原站(1436 实测 2026-11-01):.elementor-tabs > .elementor-tabs-wrapper(desktop 标题行,
* role=tablist,flex 居中) + .elementor-tabs-content-wrapper(mobile 标题 ×N + 内容面板 ×N)。
* active 用 elementor-active 类;点击切换(React state,原站为 Elementor JS 行为);
* 内容 tab_content 为自包含 HTML(含 <style> 卡片网格样式),dangerouslySetInnerHTML 原样输出。
* 基础样式移植 widget-tabs.min.css(app.css「Elementor tabs」块),本节点 custom_css
* (.ele-soms-chip-dif 边框/内容覆盖)经 collectCustomCss 自动注入——因此类名必须用
* elementor 原名,不得改 el- 前缀。id 基数 2651:原站渲染计数 elementor-tab-title-2651…,
* 本地按序复刻(仅影响锚点,无视觉作用)。
*/
function TabsWidget({ settings }: { settings: Record<string, unknown> }) {
const tabs = Array.isArray(settings.tabs)
? (settings.tabs as { _id?: string; tab_title?: string; tab_content?: string }[])
: [];
const [active, setActive] = useState(0);
if (!tabs.length) return null;
const align = s(settings.tabs_align_horizontal) || "center";
const alignCls =
align === "center" ? " elementor-tabs-alignment-center"
: align === "end" ? " elementor-tabs-alignment-end"
: align === "stretch" ? " elementor-tabs-alignment-stretch"
: "";
const base = 2651;
return (
<div className={`elementor-widget-tabs elementor-tabs elementor-tabs-view-horizontal${alignCls}`}>
<div className="elementor-tabs-wrapper" role="tablist">
{tabs.map((t, i) => (
<div
key={t._id || i}
id={`elementor-tab-title-${base + i}`}
className={"elementor-tab-title elementor-tab-desktop-title" + (i === active ? " elementor-active" : "")}
role="tab"
data-tab={i + 1}
tabIndex={i === active ? 0 : -1}
aria-selected={i === active}
onClick={() => setActive(i)}
>
{s(t.tab_title)}
</div>
))}
</div>
<div className="elementor-tabs-content-wrapper" role="tablist">
{tabs.map((t, i) => (
<Fragment key={t._id || i}>
<div
className={"elementor-tab-title elementor-tab-mobile-title" + (i === active ? " elementor-active" : "")}
role="tab"
data-tab={i + 1}
tabIndex={i === active ? 0 : -1}
aria-selected={i === active}
onClick={() => setActive(i)}
>
{s(t.tab_title)}
</div>
<div
id={`elementor-tab-content-${base + i}`}
className="elementor-tab-content elementor-clearfix"
role="tabpanel"
data-tab={i + 1}
tabIndex={0}
style={{ display: i === active ? "block" : "none" }}
dangerouslySetInnerHTML={{ __html: s(t.tab_content) }}
/>
</Fragment>
))}
</div>
</div>
);
}
/** # 组件 video widget:YouTube iframe 嵌入 */
function VideoWidget({ settings }: { settings: Record<string, unknown> }) {
let raw = s((settings.youtube_url as { url?: string } | undefined)?.url) || s(settings.youtube_url) || s(settings.video_url) || s((settings as Record<string, unknown>).url);
if (!raw) return null;
// 把 youtube watch 链接转为 embed
const m = raw.match(/[?&]v=([\w-]{6,})/) || raw.match(/youtu\.be\/([\w-]{6,})/);
const src = m ? `https://www.youtube.com/embed/${m[1]}` : raw;
return (
<div className="relative mb-[18px] pt-[56.25%]">
<iframe src={src} title="video" allowFullScreen className="absolute inset-0 h-full w-full border-0" />
</div>
);
}
/** # 函数 从任意 meta 对象中安全提取 elementor 文档(含类型收窄) */
export function extractElementor(meta: unknown): ElDocument | undefined {
if (!meta || typeof meta !== "object") return undefined;
const el = (meta as Record<string, unknown>).elementor;
if (Array.isArray(el)) return el as ElElement[];
if (el && typeof el === "object") {
const els = (el as { elements?: unknown }).elements;
if (Array.isArray(els)) return el as ElDocument;
}
return undefined;
}
/** # 组件 兜底渲染:有 elementor 用渲染器,否则输出 html,否则标题+摘要 */
export function ContentOrFallback(props: { elementor?: ElDocument; html?: string | null; fallback?: ReactNode }) {
if (props.elementor) return <ElementorRenderer doc={props.elementor} />;
if (props.html) return <div className="content-html" dangerouslySetInnerHTML={{ __html: props.html }} />;
return <>{props.fallback ?? null}</>;
}