- AdvTabsWidget: 忠实 EAEL DOM(li id/data-tab/aria-controls/aria-expanded)+ React 点击切换, active/inactive 类、内容面板 id=<slug>-tab;标题字体 Roboto - H12 scoped 生成规则: li bg #F4F5F8 + 底边 3px(默认同背景色,active/hover #ED6C00), 内容面板 color #333 + padding 16px 0 0 + 顶边 1px #C7CBDA(li 高 59 与原站全等) - image-box 修复: content_vertical_alignment 设置驱动 wrapper align-items(middle→center, 防 fig 被 flex stretch 拉高 2px);figure 内 <a> 块化消行盒下伸;ec11f3f 图宽 30% 仅存于 post-18 生成 CSS,DB 无对应键,scoped 补规则 - 审计方法修正: 先滚动全页触发 lazy 图加载再测量(视口外 lazy 图曾致 H11 虚假 -82.2px) - 实测 H0-H12 全部 section 高度与原站全等,页级漂移 0.8px;截图目视一致;点击切换正常
1306 lines
62 KiB
TypeScript
1306 lines
62 KiB
TypeScript
/**
|
||
* # 组件 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<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 盒模型四值简写为最短等价形式(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;
|
||
}
|
||
|
||
/** # 函数 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;
|
||
}
|
||
} 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 (
|
||
<div className="content-html">
|
||
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
|
||
{elements.map((el, i) => (
|
||
<ElementNode key={el.id || i} el={el} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** # 函数 收集整份文档所有节点的 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 t = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_tablet);
|
||
const m = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_mobile);
|
||
if (t) css += `@media (max-width:1024px){${sel}{padding:${t} !important;}}\n`;
|
||
if (m) css += `@media (max-width:767px){${sel}{padding:${m} !important;}}\n`;
|
||
}
|
||
function walk(els: ElElement[]) {
|
||
for (const el of els) {
|
||
if (el.id && (s(el.elType) === "section" || s(el.elType) === "container" || s(el.elType) === "column")) emit(el);
|
||
if (el.elements?.length) walk(el.elements);
|
||
}
|
||
}
|
||
walk(elements);
|
||
return css;
|
||
}
|
||
|
||
/** # 组件 单个元素节点分发(section/column/container/widget)
|
||
* 参数 gapPad 所属 section 的默认列内边距(Elementor gap 档位,仅 column 消费)
|
||
*/
|
||
function ElementNode({ el, gapPad }: { el: ElElement; gapPad?: 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} />;
|
||
// 未知结构:有子元素则递归,否则忽略
|
||
if (el.elements?.length) {
|
||
return (
|
||
<>
|
||
{el.elements.map((c, i) => (
|
||
<ElementNode key={c.id || i} el={c} gapPad={gapPad} />
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
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;
|
||
if (overlay) style.position = style.position || "relative";
|
||
return (
|
||
<section 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} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** # 函数 盒模型值是否非全零(全零 _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 }: { el: ElElement; gapPad?: string }) {
|
||
const settings = resolveGlobals(el.settings || {});
|
||
const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" };
|
||
const inline = settings._inline_size;
|
||
if (typeof inline === "number" && inline > 0) {
|
||
outer.flex = `0 0 ${inline}%`;
|
||
outer.maxWidth = `${inline}%`;
|
||
}
|
||
const inner: CSSProperties = {};
|
||
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);
|
||
Object.assign(inner, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
|
||
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 (
|
||
// 内层 width:100% 与列同宽(H4 卡 margin 内含、向右溢出,原站实测一致);
|
||
// H11 等少数列在 app.css 用 !important 覆写为 auto(原站 populated 扣除水平外边距 443.33)
|
||
<div className="el-col min-w-0" style={outer} data-el={el.id}>
|
||
<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>
|
||
</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 baseCls = cls ? (widthAuto ? cls + " el-w-auto" : cls) : widthAuto ? "el-w-auto" : undefined;
|
||
const hasContainerStyle = Object.keys(style).length > 0;
|
||
if (hasContainerStyle) {
|
||
return (
|
||
// 外层也要带 el-w-auto:列内基础规则按 :not(.el-w-auto) 放行 width:auto 的根,
|
||
// 否则 width-auto widget(如图标)被 width:100% 强推成每行一个(H7 回归根因)
|
||
<div className={widthAuto ? "el-w-auto" : 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 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 "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 "spacer":
|
||
return <div style={{ height: sizeCss(settings.space, "px") || "20px" }} />;
|
||
case "divider":
|
||
return <hr style={{ border: 0, borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}`, margin: "18px 0" }} />;
|
||
case "toggle":
|
||
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 settings={settings} />;
|
||
case "social-icons":
|
||
return <SocialIconsWidget settings={settings} />;
|
||
case "eael-contact-form-7":
|
||
return <ContactFormWidget 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":
|
||
return null; // WordPress shortcode 无法在 Node 端执行,忽略
|
||
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();
|
||
}
|
||
}
|
||
|
||
/** # 组件 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> }) {
|
||
const html = s(settings.editor);
|
||
if (!html) return null;
|
||
// 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用
|
||
const color = s(settings.text_color);
|
||
const style: CSSProperties = {};
|
||
if (color) style.color = color;
|
||
Object.assign(style, typographyCss(settings));
|
||
// _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;
|
||
const img = <img className={animCls || undefined} src={url} alt={s(img0.alt)} loading="lazy" />;
|
||
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 卡片皮肤:文章/产品卡片网格) */
|
||
function PostsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||
const perPage = Number(s(settings.cards_posts_per_page)) || 3;
|
||
const [posts, setPosts] = useState<Array<{ title: string; slug: string; excerpt: string | null; publishedAt: Date | string | null; image: string | null }>>([]);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
getLatestPosts()
|
||
.then((rows) => { if (alive) setPosts(rows.slice(0, perPage).map((r) => ({ title: r.title, slug: r.url, excerpt: null as string | null, publishedAt: null, image: null }))); })
|
||
.catch(() => {});
|
||
return () => { alive = false; };
|
||
}, [perPage]);
|
||
if (!posts.length) return null;
|
||
return (
|
||
<div className="mx-auto grid max-w-[1140px] grid-cols-1 gap-[22px] px-6 py-2.5 min-[900px]:grid-cols-3">
|
||
{posts.map((p) => (
|
||
<a
|
||
className="block overflow-hidden rounded-lg border border-line bg-white text-inherit transition-shadow hover:shadow-[0_10px_26px_rgba(16,42,76,0.12)]"
|
||
key={p.slug}
|
||
href={"/blog/" + p.slug + "/"}
|
||
>
|
||
{p.image ? (
|
||
<div className="aspect-video overflow-hidden bg-[#f2f5f9]">
|
||
<img src={p.image} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||
</div>
|
||
) : null}
|
||
<div className="px-4 pt-3.5 pb-[18px]">
|
||
<h3 className="mt-0 mb-1.5 text-[17px] leading-[1.4]">{p.title}</h3>
|
||
{p.publishedAt ? <time className="text-[13px] text-muted-foreground">{String(p.publishedAt).slice(0, 10)}</time> : null}
|
||
{p.excerpt ? <p className="mt-2 mb-0 line-clamp-2 text-sm text-muted-foreground">{p.excerpt}</p> : null}
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** # 组件 social-icons widget(图标行) */
|
||
function SocialIconsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||
const items = (settings.social_icon_list as Array<Record<string, unknown>>) || [];
|
||
return (
|
||
<div className="mb-[18px] flex gap-2.5">
|
||
{items.map((it, i) => {
|
||
const icon = ((it.social_icon as { value?: string })?.value || "").replace(/fa-brands |fa-solid |fa- /g, "");
|
||
return (
|
||
<a key={i} className="flex size-[34px] items-center justify-center rounded-full bg-soft text-ink hover:bg-brand hover:text-white" href={s((it.link as { url?: string })?.url) || "#"} target="_blank" rel="noreferrer" aria-label={icon}>
|
||
<span className={"soc " + icon} />
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
/** # 组件 eael-contact-form-7(EAEL Contact Form 7 皮肤):渲染内置询盘表单 */
|
||
function ContactFormWidget({ settings }: { settings: Record<string, unknown> }) {
|
||
return (
|
||
<div className="mb-[18px] rounded-md bg-white px-7 py-[26px]">
|
||
<InquiryForm formKey="contact-us-form" title={undefined} />
|
||
</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);
|
||
return (
|
||
<div style={{ textAlign: (s(settings.align) as CSSProperties["textAlign"]) || "left" }}>
|
||
{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">
|
||
{text}
|
||
</a>
|
||
</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);
|
||
// 内容垂直对齐(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;
|
||
// flex/边距在 app.css 的 .el-imagebox-pos-* 规则(带 768 断点),这里只带 per-widget 图宽与垂直对齐
|
||
const figStyle: CSSProperties | undefined = isRow && imgW ? { width: imgW } : undefined;
|
||
return (
|
||
<div className={"el-imagebox" + posCls}>
|
||
<div className="el-imagebox-wrapper" style={va ? { alignItems: va } : undefined}>
|
||
{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={titleSpace ? { marginBottom: titleSpace } : undefined}>
|
||
{link ? (
|
||
<a {...linkProps} dangerouslySetInnerHTML={{ __html: title }} />
|
||
) : (
|
||
// 无链接时:原站文本直接落在标签内,这里用内联 span 承载(不改变样式)
|
||
<span dangerouslySetInnerHTML={{ __html: title }} />
|
||
)}
|
||
</Tag>
|
||
) : null}
|
||
{desc ? <p className="el-imagebox-description" 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;
|
||
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 (
|
||
<div className={cls}>
|
||
{img?.url ? (
|
||
<div className="infobox-icon">
|
||
<img src={img.url} alt={s(img.alt)} loading="lazy" />
|
||
</div>
|
||
) : null}
|
||
<div className="infobox-content">
|
||
{title ? (
|
||
<div className="infobox-title-section">
|
||
<Tag className="title" 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 ? <p 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">
|
||
<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:仅渲染文字列表,emoji 图标跳过 */
|
||
function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
|
||
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 (
|
||
<ul className="mb-[18px]" style={{ listStyle: "none", padding: 0, margin: "0 0 18px" }}>
|
||
{items.map((t, i) => {
|
||
// 文本本身以 emoji 开头时保留原样输出(内容照原样,只是不做图标渲染处理)
|
||
return (
|
||
<li key={i} style={{ marginBottom: 6, paddingLeft: 16, position: "relative" }}>
|
||
<span style={{ position: "absolute", left: 0, color: "var(--brand)" }}>•</span>
|
||
{t}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
/** # 组件 toggle / accordion widget:手风琴 */
|
||
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}</>;
|
||
}
|