v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,981 @@
|
||||
/**
|
||||
* # 组件 Elementor 内容渲染器
|
||||
* 将 WordPress Elementor 导出的 JSON(contents.meta.elementor / products.meta.elementor)
|
||||
* 渲染为 React 元素。支持 section/column/container 结构 + 常用 widget。
|
||||
* 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比),
|
||||
* 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。
|
||||
*/
|
||||
import { Component, 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;
|
||||
}
|
||||
|
||||
/** # 函数 解析 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;
|
||||
}
|
||||
|
||||
/** # 函数 单值全局色引用解析 */
|
||||
function resolveGlobalsStr(v: string): string {
|
||||
const m = /^globals\/colors\?id=([0-9a-f]+)$/.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}")`;
|
||||
style.backgroundSize = "cover";
|
||||
// "center" 会被浏览器归一为 "center center",直接写全值保证水合一致
|
||||
style.backgroundPosition = "center center";
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
const pad = paddingCss(settings.padding);
|
||||
if (pad) style.padding = pad;
|
||||
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) */
|
||||
function ElementNode({ el }: { el: ElElement }) {
|
||||
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} />;
|
||||
// 未知结构:有子元素则递归,否则忽略
|
||||
if (el.elements?.length) {
|
||||
return (
|
||||
<>
|
||||
{el.elements.map((c, i) => (
|
||||
<ElementNode key={c.id || i} el={c} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** # 组件 section / container:背景 + 内部布局
|
||||
* 取证(原站实测/post CSS):boxed 内容容器 = margin auto + flex wrap 的定宽行;
|
||||
* 内容宽度 content_width 优先,否则沿用 Elementor kit 默认 1140px;
|
||||
* 自带 padding 只取 settings.padding(不再叠加通用 px/py)。
|
||||
*/
|
||||
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 inner: CSSProperties = {};
|
||||
if (s(settings.layout) === "full_width") {
|
||||
// 通栏 section:内容不设上限、无内边距(如首页 hero 轮播)
|
||||
inner.maxWidth = "none";
|
||||
inner.padding = "0";
|
||||
} else {
|
||||
inner.maxWidth = width;
|
||||
const innerPad = paddingCss(settings.padding);
|
||||
if (innerPad) inner.padding = innerPad;
|
||||
}
|
||||
// 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;
|
||||
return (
|
||||
<section style={style} data-el={el.id}>
|
||||
<div className="mx-auto flex flex-wrap" style={inner}>
|
||||
{(el.elements || []).map((c, i) => (
|
||||
<ElementNode key={c.id || i} el={c} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 column:按 _inline_size 百分比设置列宽 */
|
||||
function ColumnNode({ el }: { el: ElElement }) {
|
||||
const settings = resolveGlobals(el.settings || {});
|
||||
// 未声明列宽时按 Elementor 语义均分(flex-basis 0),避免固定 240px 底宽把多列挤成换行
|
||||
const style: CSSProperties = { flex: "1 1 0", minWidth: 0 };
|
||||
const inline = settings._inline_size;
|
||||
if (typeof inline === "number" && inline > 0) {
|
||||
style.flex = `0 0 ${inline}%`;
|
||||
style.maxWidth = `${inline}%`;
|
||||
}
|
||||
const pad = paddingCss(settings.padding);
|
||||
if (pad) style.padding = pad;
|
||||
const bg = sectionBgStyle(settings);
|
||||
Object.assign(style, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
|
||||
// 列圆角/边框(卡片样式)
|
||||
const radius = paddingCss(settings.border_radius);
|
||||
if (radius) style.borderRadius = radius;
|
||||
if (s(settings.border_border) === "solid") {
|
||||
const bw = paddingCss(settings.border_width);
|
||||
style.border = `1px solid ${s(settings.border_color) || "#e3e9f0"}`;
|
||||
if (bw && bw.split(" ").length === 4) {
|
||||
const [t, r2, b, l] = bw.split(" ");
|
||||
style.borderWidth = `${t} ${r2} ${b} ${l}`;
|
||||
} else style.borderWidth = "1px";
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 px-2.5" style={style} data-el={el.id}>
|
||||
{(el.elements || []).map((c, i) => (
|
||||
<ElementNode key={c.id || i} el={c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 widget 分发:按 widgetType 渲染,未知类型优雅降级
|
||||
* 外层 div 与原站 widget 包裹层一致(.elementor-widget …),挂 data-el=<id>,
|
||||
* 页面级规则(post-18.css 对应块按 [data-el] 范围书写)与 _css_classes 因此都能命中。
|
||||
*/
|
||||
function WidgetNode({ el }: { el: ElElement }) {
|
||||
const inner = (
|
||||
<WidgetGuard widgetType={s(el.widgetType)}>
|
||||
<WidgetNodeInner el={el} />
|
||||
</WidgetGuard>
|
||||
);
|
||||
const cls = s(el.settings?._css_classes) || undefined;
|
||||
return <div className={cls} 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 settings={settings} />;
|
||||
case "html":
|
||||
return s(settings.html) ? (
|
||||
<div className="mb-[18px]" dangerouslySetInnerHTML={{ __html: 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 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:标题级别/颜色/对齐 */
|
||||
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const size = s(settings.header_size) || "h2";
|
||||
const Tag = (["h1", "h2", "h3", "h4", "h5", "h6"].includes(size) ? size : "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
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"];
|
||||
return <Tag className="mb-[18px]" style={style} dangerouslySetInnerHTML={{ __html: s(settings.title) }} />;
|
||||
}
|
||||
|
||||
/** # 组件 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);
|
||||
return <div className="mb-[18px]" style={color ? { color } : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
/** # 组件 image widget */
|
||||
function ImageWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const url = (settings.image as { url?: string } | undefined)?.url;
|
||||
if (!url) return null;
|
||||
const align = s(settings.align);
|
||||
return (
|
||||
<div className="mb-[18px]" style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
|
||||
<img src={url} alt={s((settings.image as { alt?: string }).alt) || ""} loading="lazy" />
|
||||
</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 */
|
||||
function ButtonWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||
const text = s(settings.text);
|
||||
const link = (settings.link as { url?: string; target?: string } | undefined)?.url;
|
||||
const style: CSSProperties = {
|
||||
display: "inline-block",
|
||||
padding: "10px 24px",
|
||||
borderRadius: 4,
|
||||
background: s(settings.background_color) || "var(--brand)",
|
||||
color: s(settings.button_text_color) || s(settings.text_color) || "#fff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
};
|
||||
if (s(settings.size) === "lg") style.padding = "14px 32px";
|
||||
if (s(settings.size) === "sm") style.padding = "7px 16px";
|
||||
if (!link && !text) return null;
|
||||
return (
|
||||
<div className="mb-[18px]" style={{ textAlign: (s(settings.align) as CSSProperties["textAlign"]) || "left" }}>
|
||||
<a 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";
|
||||
return (
|
||||
<div className={"el-imagebox" + posCls}>
|
||||
<div className="el-imagebox-wrapper">
|
||||
{url ? (
|
||||
<figure className="el-imagebox-img">
|
||||
{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">
|
||||
{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。
|
||||
*/
|
||||
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);
|
||||
if (!title && !desc) 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";
|
||||
return (
|
||||
<div className="eael-infobox mb-[18px]">
|
||||
{title ? <Tag className="title" dangerouslySetInnerHTML={{ __html: title }} /> : null}
|
||||
{desc ? <div className="infobox-content" dangerouslySetInnerHTML={{ __html: desc }} /> : null}
|
||||
</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,键名不同 */
|
||||
function AdvTabsWidget({ settings }: { 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);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<Tabs defaultValue="adv-0" className="mb-[18px]">
|
||||
<TabsList>
|
||||
{items.map((t, i) => (
|
||||
<TabsTrigger key={i} value={`adv-${i}`}>
|
||||
{t.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{items.map((t, i) => (
|
||||
<TabsContent key={i} value={`adv-${i}`}>
|
||||
<div className="content-html" dangerouslySetInnerHTML={{ __html: t.content }} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 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:标签页 */
|
||||
function TabsWidget({ 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 (
|
||||
<Tabs defaultValue="tab-0" className="mb-[18px]">
|
||||
<TabsList>
|
||||
{tabs.map((t, i) => (
|
||||
<TabsTrigger key={i} value={`tab-${i}`}>
|
||||
{s(t.tab_title)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((t, i) => (
|
||||
<TabsContent key={i} value={`tab-${i}`}>
|
||||
<div className="content-html" dangerouslySetInnerHTML={{ __html: s(t.tab_content) }} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 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}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user