/** * # 组件 Elementor 内容渲染器 * 将 WordPress Elementor 导出的 JSON(contents.meta.elementor / products.meta.elementor) * 渲染为 React 元素。支持 section/column/container 结构 + 常用 widget。 * 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比), * 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。 */ import { Component, Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { getLatestPosts, getLatestProducts, type LatestItem } from "~/server/data"; import TickerBar from "~/components/website/TickerBar"; import { InquiryForm } from "~/components/website/InquiryForm"; 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; 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 可能带 等, * 浏览器解析会提前关闭页面骨架标签,导致水合不匹配与布局重排) */ function stripStructuralTags(html: string): string { return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, ""); } /** # 函数 折叠 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, 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; 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 = { 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(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; const out: Record = {}; 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)) { 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; } /** # 函数 section 的背景样式(纯色 / 图片) */ function sectionBgStyle(settings: Record): 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; } } 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 []; } /** # 组件 Elementor 渲染器入口 */ export function ElementorRenderer({ doc }: { doc: ElDocument }) { const elements = normalizeDoc(doc); if (!elements.length) return null; const css = collectCustomCss(elements) + collectResponsiveCss(elements); return (
{css ? ) : null} {text}
); } /** # 组件 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 > title +

description

; * 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 }) { 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); // 内容垂直对齐(content_vertical_alignment 控件,仅 left/right 行布局生效): // 由 per-widget 生成规则输出 align-items(514e513 middle → center 实测;否则 flex 默认 stretch) const vaMap: Record = { top: "flex-start", middle: "center", bottom: "flex-end" }; const va = isRow ? vaMap[s(settings.content_vertical_alignment)] : undefined; // flex/边距在 app.css 的 .el-imagebox-pos-* 规则(带 768 断点),这里只带 per-widget 图宽与垂直对齐 const figStyle: CSSProperties | undefined = isRow && imgW ? { width: imgW } : undefined; return ( ); } /** # 组件 icon-box / eael-info-box:图标可跳过(emoji 不渲染),只保留标题与描述 */ function IconBoxWidget({ settings }: { settings: Record }) { const title = s((settings as Record).title_text) || s((settings as Record).title); const desc = s((settings as Record).description_text) || s((settings as Record).description); const icon = (settings as Record).selected_icon as { value?: string } | undefined; const titleColor = s((settings as Record).title_color) || s((settings as Record).button_text_color); // 图标是 emoji/unicode 字符或字体图标类:一律不渲染为图标,跳过 void icon; if (!title && !desc) return null; return (
{title ?

: null} {desc ?
: null}
); } /** # 组件 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); 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 (
{img?.url ? (
{s(img.alt)}
) : null}
{title ? (
{s(settings.eael_infobox_sub_title) && s(settings.eael_infobox_sub_title) !== "This is a sub title" ? (
) : null}
) : null} {desc ?

: null}

); } /** # 组件 flip-box(翻转框):静态渲染正面(标题/描述/按钮 + 可选背景图) */ function FlipBoxWidget({ settings }: { settings: Record }) { 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 = (
{title ?

{title}

: null} {desc ?
: null} {btn ? ( {btn} ) : null}
); return link ? (
{box} ) : (
{box}
); } /** # 组件 eael-adv-tabs(EAEL 高级标签页):结构同 tabs,键名不同 */ /** # 组件 EAEL adv-tabs widget(H12 Industries and Use Cases,6 标签横排) * DOM 对齐原站(1436 实测 2026-11-01):#eael-advance-tabs-.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#-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 }) { 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 (
    {items.map((t, i) => ( ))}
{items.map((t, i) => (
))}
); } /** # 组件 eael-data-table:表头 + 行渲染为普通表格(行可为字符串或单元格数组) */ function DataTableWidget({ settings }: { settings: Record }) { 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)?.eael_data_table_content_row_title; const pick = (x: unknown) => s(typeof x === "object" && x ? ((x as Record).title ?? (x as Record).content) : x); return Array.isArray(v) ? v.map(pick) : [pick(v)]; }; return ( {headers.length ? ( {headers.map((h, i) => ( ))} ) : null} {rows.map((r, i) => ( {cells(r).map((c, j) => ( ))}
{h}
))}
); } /** # 组件 eael-testimonial(评价引用):姓名/公司/描述 */ function TestimonialWidget({ settings }: { settings: Record }) { 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 (
{desc ?
: null} {name ?
{name}{company ? · {company} : null}
: null}
); } /** # 组件 reviews(评价轮播):静态卡片网格(slides 复用:name/title/rating/content/image) */ function ReviewsWidget({ settings }: { settings: Record }) { 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 (
{slides.map((it, i) => (
{stars(s(it.rating))}
{s(it.content) ?
: null}
{it.image?.url ? {it.image.alt : null}
{s(it.name)}
{s(it.title)}
))}
); } /** # 组件 icon-list widget:仅渲染文字列表,emoji 图标跳过 */ function IconListWidget({ settings }: { settings: Record }) { const list = Array.isArray(settings.icon_list) ? (settings.icon_list as { text?: string; selected_icon?: { value?: string } }[]) : []; const items = list.map((it) => s(it.text)).filter(Boolean); if (!items.length) return null; return (
    {items.map((t, i) => { // 文本本身以 emoji 开头时保留原样输出(内容照原样,只是不做图标渲染处理) return (
  • • {t}
  • ); })}
); } /** # 组件 toggle / accordion widget:手风琴 */ function AccordionWidget({ settings }: { settings: Record }) { const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : []; if (!tabs.length) return null; return (
{tabs.map((t, i) => ( {s(t.tab_title)}
))}
); } /** # 组件 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(含