/** * # 组件 Elementor 内容渲染器 * 将 WordPress Elementor 导出的 JSON(contents.meta.elementor / products.meta.elementor) * 渲染为 React 元素。支持 section/column/container 结构 + 常用 widget。 * 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比), * 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。 */ import { Component, createContext, useContext, Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { getLatestPosts, getLatestProducts, type LatestItem, type PostCardItem } from "~/server/data"; import TickerBar from "~/components/website/TickerBar"; import { Cf7Form604 } from "~/components/website/Cf7Form604"; import { localSiteUrl } from "~/utils/media"; import { ProductCard, type ProductCardData } from "~/components/website/ProductCard"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; /** # 类型 Elementor 元素结构(section/column/widget 通用) */ export type ElElement = { elType?: string; widgetType?: string; id?: string; settings?: Record; elements?: ElElement[]; }; /** # 类型 Elementor 文档(数组或 {elements:[]} 包装) */ export type ElDocument = ElElement[] | { elements?: ElElement[] } | unknown; /** # 类型 尺寸值 {size, unit} */ type Size = { size?: number | string; unit?: string }; /** # 函数 判断字符串是否主要是 emoji/unicode 图标(不可作为图片图标渲染) */ function isEmojiOrPictograph(s: unknown): boolean { if (typeof s !== "string" || !s.trim()) return true; // 含有 emoji 区段字符(U+1F000+ 或 U+2600-U+27BF 等)视为图标字符 return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{E000}-\u{F8FF}]/u.test(s); } /** # 函数 读取 settings 中字符串字段 */ function s(v: unknown): string { return typeof v === "string" ? v : ""; } /** # 函数 把 Elementor 尺寸对象/数字转为 CSS 值 */ function sizeCss(v: unknown, fallbackUnit = "px"): string | undefined { if (v == null) return undefined; if (typeof v === "number") return `${v}${fallbackUnit}`; if (typeof v === "object") { const o = v as Size; if (o.size != null) return `${o.size}${o.unit || fallbackUnit}`; } if (typeof v === "string" && v.trim()) return v; return undefined; } /** # 函数 剥离内容中的文档结构级标签(WP 导入的原始 HTML 可能带 等, * 浏览器解析会提前关闭页面骨架标签,导致水合不匹配与布局重排) */ function stripStructuralTags(html: string): string { return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, ""); } /** # 函数 CSS 长度折半(Elementor icon-list 间距:space/2 落在 padding/margin) * 参数 v CSS 长度(如 "5px" / "1rem") * 返回值 折半后的长度;无法解析返回 undefined */ function halfLength(v: string | undefined): string | undefined { if (!v) return undefined; const m = /^(-?[\d.]+)(px|rem|em|%)$/.exec(v.trim()); if (!m) return undefined; const n = Number(m[1]) / 2; return `${n}${m[2]}`; } /** # 函数 折叠 CSS 盒模型四值简写为最短等价形式(1/2/4 值) * React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem" * 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。 */ function collapseBoxValues(v: string): string { const p = v.trim().split(/\s+/); if (p.length === 4 && p[0] === p[2] && p[1] === p[3]) return `${p[0]} ${p[1]}`; if (p.length === 4 && p[0] === p[1] && p[1] === p[2] && p[2] === p[3]) return p[0]!; if (p.length === 3 && p[0] === p[2]) return `${p[0]} ${p[1]}`; if (p.length === 2 && p[0] === p[1]) return p[0]!; return v; } /** # 函数 Elementor 背景位置词转 CSS 百分比(bottom center → 50% 100%) */ function mapBgPosition(v: string): string { const parts = v.trim().split(/\s+/).filter(Boolean); const to = (w: string): string | null => { switch (w) { case "top": return "0%"; case "center": return "50%"; case "bottom": return "100%"; case "left": return "0%"; case "right": return "100%"; default: return null; } }; // 顺序统一为 [x, y] const [xs, ys] = parts.length === 2 && ["top", "bottom"].includes(parts[0]!) ? [parts[1]!, parts[0]!] : [parts[0]!, parts[1]!]; const x = to(xs) ?? xs; const y = to(ys) ?? ys; return `${x} ${y}`; } /** # 函数 解析 Typography 组合样式(typography_typography=custom 时的 typography_* 字段) * 覆盖 font-family/size/weight/line-height/letter-spacing/word-spacing/text-transform/ * font-style/text-decoration(凡 settings 中有定义的值写入内联样式), * 供 heading / text-editor / image-box 等按原站 kit 逐字重渲染。 * 参数 settings 元素 settings;prefix 通常为 "typography" * 返回值 CSSProperties(无 custom 时为空对象) */ function typographyCss(settings: Record, prefix = "typography"): CSSProperties { const style: CSSProperties = {}; if (s(settings[`${prefix}_typography`]) !== "custom") return style; const fam = s(settings[`${prefix}_font_family`]); if (fam) style.fontFamily = `"${fam}", sans-serif`; const size = sizeCss(settings[`${prefix}_font_size`]); if (size) style.fontSize = size; const weight = s(settings[`${prefix}_font_weight`]); if (weight) style.fontWeight = weight as CSSProperties["fontWeight"]; const lh = sizeCss(settings[`${prefix}_line_height`]); if (lh) style.lineHeight = lh; const ls = sizeCss(settings[`${prefix}_letter_spacing`]); if (ls) style.letterSpacing = ls; const ws = sizeCss(settings[`${prefix}_word_spacing`]); if (ws) style.wordSpacing = ws; const tt = s(settings[`${prefix}_text_transform`]); if (tt) style.textTransform = tt as CSSProperties["textTransform"]; const fs = s(settings[`${prefix}_font_style`]); if (fs) style.fontStyle = fs as CSSProperties["fontStyle"]; const td = s(settings[`${prefix}_text_decoration`]); if (td) style.textDecoration = td as CSSProperties["textDecoration"]; return style; } /** # 函数 解析 padding(Elementor 可能是对象/字符串/数组) */ function paddingCss(v: unknown): string | undefined { if (!v) return undefined; if (typeof v === "string") return v; if (typeof v === "number") return `${v}px`; if (Array.isArray(v)) return collapseBoxValues(v.map((x) => (typeof x === "number" ? `${x}px` : String(x ?? "0"))).join(" ")); if (typeof v === "object") { const o = v as Record; const unit = s(o.unit) || "px"; const t = o.top, r = o.right, b = o.bottom, l = o.left; if (t == null && r == null && b == null && l == null) return undefined; const px = (x: unknown) => `${x ?? 0}${unit}`; return collapseBoxValues(`${px(t)} ${px(r)} ${px(b)} ${px(l)}`); } return undefined; } /** # 成员变量 Elementor 全局色映射(Default Kit:_id → 十六进制) **/ const ELEMENTOR_GLOBAL_COLORS: Record = { primary: "#ED6C00", secondary: "#FFA11B", text: "#000000", accent: "#474DFF", "4d5c430": "#FFFFFF", e4e3ba3: "#4A75FF", "282ef76": "#5C5E66", "6fba50b": "#444444", "78696cf": "#00C1BC", a6f03ac: "#E9E9E9", ee1917e: "#E0E5F9", "26331af": "#F4F5F8", }; /** # 函数 递归解析 settings 中的全局色引用(globals/colors?id=x → 十六进制), * 并把 __globals__ 里的引用合并到对应设置键(Elementor 全局色实际存放在 __globals__) */ function resolveGlobals(settings: T): T { if (Array.isArray(settings)) return settings.map((v) => resolveGlobals(v)) as unknown as T; if (settings && typeof settings === "object" && settings.constructor === Object) { const src = settings as Record; const out: Record = {}; for (const [k, v] of Object.entries(src)) { out[k] = typeof v === "string" ? resolveGlobalsStr(v) : resolveGlobals(v); } const globals = src.__globals__; if (globals && typeof globals === "object" && globals.constructor === Object) { for (const [key, ref] of Object.entries(globals as Record)) { if (typeof ref !== "string") continue; const resolved = resolveGlobalsStr(ref); if (resolved !== ref) { const cur = out[key]; // 仅当原设置为空(空串/未定义)时采用全局色 if (cur == null || cur === "") out[key] = resolved; } } } return out as T; } return settings; } /** # 函数 单值全局色引用解析(键名含非十六进制 id:text/primary/accent 等) */ function resolveGlobalsStr(v: string): string { const m = /^globals\/colors\?id=([\w-]+)$/.exec(v.trim()); if (m) return ELEMENTOR_GLOBAL_COLORS[m[1]!] ?? v; return v; } /** # 函数 响应式可见性类名(Elementor hide_* 设置 → elementor-hidden-*,配 app.css 媒体查询) * 参数 settings 元素设置 * 返回值 空格分隔的类名(无隐藏设置则空串) */ function visibilityClass(settings: Record): string { const cls: string[] = []; if (s(settings.hide_desktop) === "hidden-desktop") cls.push("elementor-hidden-desktop"); if (s(settings.hide_tablet) === "hidden-tablet") cls.push("elementor-hidden-tablet"); if (s(settings.hide_mobile) === "hidden-mobile") cls.push("elementor-hidden-mobile"); return cls.join(" "); } /** # 函数 section 的自定义 HTML 标签(Elementor html_tag 设置;默认 section) * 参数 settings 元素设置 * 返回值 合法标签名 */ function sectionTag(settings: Record): "section" | "div" | "footer" | "header" | "aside" | "article" { const tag = s(settings.html_tag); return (["section", "div", "footer", "header", "aside", "article"] as const).includes(tag as never) ? (tag as "section" | "div" | "footer" | "header" | "aside" | "article") : "section"; } /** # 函数 section 的背景样式(纯色 / 图片) */ function sectionBgStyle(settings: Record): CSSProperties { const style: CSSProperties = {}; const bgType = s(settings.background_background); if (bgType === "classic") { const color = s(settings.background_color); if (color) style.backgroundColor = color; const img = (settings.background_image as { url?: string } | undefined)?.url; if (img) { style.backgroundImage = `url("${img}")`; // 位置/重复取设置值(H11 底图 1920×240 条图:bottom center + no-repeat, // 只在节底部铺一条,尺寸 auto 不缩放——写死 cover/center 会平铺满节,实测对不上) const posRaw = s(settings.background_position); style.backgroundPosition = posRaw ? mapBgPosition(posRaw) : "center center"; const rep = s(settings.background_repeat); if (rep) style.backgroundRepeat = rep; // 尺寸:initial → 100% auto(H13 楼体大图 bf3d0ba 生成规则实测; // cover/contain/auto 原样输出) const sizeRaw = s(settings.background_size); if (sizeRaw) style.backgroundSize = sizeRaw === "initial" ? "100% auto" : sizeRaw; } } else if (bgType === "gradient") { const c1 = s(settings.background_color) || s(settings.color_a) || "#ffffff"; const c2 = s(settings.background_color_b) || s(settings.color_b) || s(settings.background_color_stop_b) || "#f4f5f8"; style.backgroundImage = `linear-gradient(${s(settings.gradient_angle) || "180"}deg, ${c1}, ${c2})`; } else if (s(settings.background_color)) { style.backgroundColor = s(settings.background_color); } // padding 由调用方(SectionNode 写到内容容器 / ColumnNode 写到列)负责,不在此叠加避免双份 return style; } /** # 函数 归一化 Elementor 文档为元素数组 */ function normalizeDoc(doc: ElDocument): ElElement[] { if (Array.isArray(doc)) return doc as ElElement[]; if (doc && typeof doc === "object") { const el = (doc as { elements?: ElElement[] }).elements; if (Array.isArray(el)) return el; } return []; } /** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/ const PostsDataContext = createContext>({}); /** # 类型 Woo 产品单页上下文(模板 2741 的 Woo widget 数据) */ export type WooProductContext = { /** # 成员变量 产品标题/SKU/短描述(HTML) **/ title: string; sku: string; shortDescription: string; /** # 成员变量 分类(meta Category 链接) **/ categories: { name: string; slug: string }[]; /** # 成员变量 图廊:主图 + 缩略图(已解析 WP 尺寸变体) **/ mainImage: { url: string; alt?: string }; thumbs: { url: string; alt?: string }[]; /** # 成员变量 属性表(Additional information) **/ attributes: { attr: string; value: string }[]; /** # 成员变量 产品自身 elementor 文档(Description 标签页内容)与 pageCss **/ productDoc?: ElDocument; productPageCss?: string; /** # 成员变量 相关产品(woocommerce-products 卡片数据) **/ related: ProductCardData[]; }; const WooContext = createContext(null); /** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/ const ShortcodesContext = createContext>({}); /** # 类型 渲染器动态数据(面包屑等 WP 动态 widget 的 SSR 上下文) */ export type RendererVars = { /** # 成员变量 面包屑路径(末项无 url 表示当前页) **/ breadcrumb?: { label: string; url?: string }[]; }; /** # 成员变量 动态数据上下文(woocommerce-breadcrumb 等 widget 读取) **/ const VarsContext = createContext({}); /** # 组件 Elementor 渲染器入口 * 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空) * 参数 vars 动态数据(面包屑路径等) */ export function ElementorRenderer({ doc, postsData, shortcodes, vars, pageCss, woo, }: { doc: ElDocument; postsData?: Record; shortcodes?: Record; vars?: RendererVars; /** # 成员变量 Woo 产品单页上下文(产品模板 2741 渲染时传入) **/ woo?: WooProductContext | null; /** # 成员变量 页面级生成 CSS(原站 post-.css 翻译成 data-el 选择器,scripts/import-page-css.ts 导入) **/ pageCss?: string; }) { const elements = normalizeDoc(doc); if (!elements.length) return null; const css = (pageCss || "") + collectCustomCss(elements) + collectResponsiveCss(elements); return (
{css ?
{s(settings.prefix)} {/* ending_number 为数值(about-us 计数器 3000/100 等),s() 只收字符串 */} {String(settings.ending_number ?? "") || "0"} {s(settings.suffix)}
{s(settings.title)}
); case "shortcode": // WordPress 短代码无法在 Node 端执行:由调用方按 widget id 传入预渲染节点 // (页脚 post 58:0683e73 → menu-2 移动端折叠菜单;2481635 → gtranslate 语言切换) return shortcodes[s(el.id)] ?? null; case "code-highlight": return s(settings.source_code) ? (
          {s(settings.source_code)}
        
) : null; default: return unknownFallback(); } } /** # 组件 woocommerce-breadcrumb widget:面包屑(路径由 vars.breadcrumb 注入; * 原站归档页模板 1398 的 hero 即此 widget,末项为当前页、前面项带链接) */ function BreadcrumbWidget() { const vars = useContext(VarsContext); const trail = vars.breadcrumb?.length ? vars.breadcrumb : [{ label: "Home", url: "/" }, { label: "Shop" }]; return ( ); } /** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */ function HeadingWidget({ settings }: { settings: Record }) { // 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2 const size = s(settings.header_size) || "h2"; 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 ( {linkProps ? ( ) : ( )} ); } /** # 组件 text-editor widget:富文本 HTML */ function TextEditorWidget({ settings }: { settings: Record }) { let html = s(settings.editor); // 动态标签 current-date-time(页脚 1fe90e6 版权年份):editor 为空 + __dynamic__.editor // 含 current-date-time → 按 before + Y 生成(原站实测 "Copyright ©2026") const dynamic = s((settings.__dynamic__ as Record | undefined)?.editor); if (!html && dynamic.includes("current-date-time")) { // settings 属性是 URL 编码的 JSON:settings="%7B%22date_format%22...%22before%22%3A%22Copyright%20%C2%A9%22" let before = "Copyright ©"; const m = /settings="([^"]*)"/.exec(dynamic); if (m) { try { const dj = JSON.parse(decodeURIComponent(m[1]!)) as { before?: string }; if (dj.before) before = dj.before; } catch { /* 解析失败用默认前缀 */ } } html = `${before}${new Date().getFullYear()}`; } if (!html) return null; // 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用 const color = s(settings.text_color); const style: CSSProperties = {}; if (color) style.color = color; Object.assign(style, typographyCss(settings)); const align = s(settings.align); if (align) style.textAlign = align as CSSProperties["textAlign"]; // _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复 return
; } /** # 组件 image widget:图(可带 link;hover_animation=shrink 复刻原站缩小动画) */ function ImageWidget({ settings }: { settings: Record }) { const img0 = settings.image as { url?: string; alt?: string } | undefined; const url = img0?.url; if (!url) return null; const align = s(settings.align); const anim = s(settings.hover_animation); // 原站映射 elementor-animation-* 类 const animCls = anim === "shrink" ? "el-anim-shrink" : ""; const link = (settings.link as { url?: string } | undefined)?.url; const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on"; const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined; // 图片宽度设置(Elementor 生成 `.elementor-element-X img{width:57%}`;页脚 logo 57%、 // Google 徽章 300px):作用于 img 本身,a 为 inline-block 时按固有宽度收缩(与原站一致) // 图片宽度:Elementor width 设置优先;本主题另有 space(%) 控制图片宽度(实测 63dd8a2=92%) const imgWidth = sizeCss(settings.width) || sizeCss(settings.space); const img = {s(img0.alt)}; const cls = ("el-image " + (animCls || "")).trim(); return ( ); } /** # 组件 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 }) { const slides = (settings.slides as Array>) || []; 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 (
setPause(true)} onMouseLeave={() => setPause(false)}> {slides.map((sl, i) => { const bg = (sl.background_image as { url?: string } | undefined)?.url; // 水平推移:相邻 offset,轮播任意距离;非激活 slide 不捕捉指针 return (
); })} {slides.length > 1 ? ( <>
{slides.map((_, i) => (
) : null}
); } /** # 组件 eael-content-ticker(标签 + 最新内容标题轮播,淡入切换) * 交互/样式全部在 TickerBar(复用组件)中按原站实测值还原; * 数据:product → 最新发布产品(created_at 降序,与原站一致),post → 最新文章。 */ function TickerWidget({ settings }: { settings: Record }) { 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 | undefined)?.size) || 200; const interval = Number((settings.autoplay_speed as Record | undefined)?.size) || 5000; const [titles, setTitles] = useState([]); 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 ; } /** # 组件 posts widget(Elementor Pro 卡片皮肤:文章/产品卡片网格) */ /** # 组件 posts widget(Elementor skin cards 忠实 DOM) * DOM/样式对齐 widget-posts.min.css 的 cards 皮肤(原站 H18 与模板 27143 实测 2026-11-02): * .elementor-posts-container.elementor-posts.elementor-posts--skin-cards.elementor-grid * > article.elementor-post > .elementor-post__card(纵向 flex、白底、1px 边、圆角 3) * > [a.__thumbnail__link > .__thumbnail(padding-bottom 比率) > img] * + .__badge(仅缩略图存在时,绝对右上) * + .__text(flex-grow)> .__title + .__excerpt * + .__meta-data(text 之后,border-top + margin-top:auto,日期 F j, Y) * 图片宽度 calc(100%+1px) 且为静态流(高度按原图比例,原站即如此); * 缩略图比率来自 cards_item_ratio(默认 0.66)/ cards_item_ratio_mobile。 * 文章数据经 PostsDataContext 预取(server 端 loadPostsWidgetsData)。 */ function PostsWidget({ el, settings }: { el: ElElement; settings: Record }) { const allPosts = useContext(PostsDataContext); const posts = (el.id && allPosts[el.id]) || []; if (!posts.length) return null; const numVal = (v: unknown): number | undefined => { const n = typeof v === "number" ? v : Number(s(v)); return Number.isFinite(n) && n > 0 ? n : undefined; }; const showThumb = s(settings.cards_thumbnail) !== "none"; const cols = numVal(settings.cards_columns) ?? 1; const colsT = numVal(settings.cards_columns_tablet) ?? Math.min(cols, 2); const colsM = numVal(settings.cards_columns_mobile) ?? 1; const rowGap = sizeCss(settings.cards_row_gap) || "16px"; const colGap = sizeCss(settings.cards_column_gap) || "10px"; const textPad = sizeCss(settings.cards_card_padding) || "12px"; const titleSpacing = sizeCss(settings.cards_title_spacing) || "16px"; const excSpacing = sizeCss(settings.cards_excerpt_spacing) || "44px"; const excerptColor = s(settings.cards_excerpt_color) || "#777"; const excerptSize = sizeCss(settings.cards_excerpt_typography_font_size) || "14px"; const titleSize = sizeCss(settings.cards_title_typography_font_size) || "18px"; const metaSize = sizeCss(settings.cards_meta_typography_font_size) || "12px"; const metaWeight = s(settings.cards_meta_typography_font_weight) || "400"; const cardBorderColor = s(settings.cards_card_border_color) || "#69727d"; const cardBorderWidth = sizeCss(settings.cards_card_border_width) || "1px"; // 缩略图比率:desktop 默认 0.66,mobile 默认继承(27143/ H18 均为 0.6/0.67) const ratio = numVal((settings.cards_item_ratio as { size?: unknown } | undefined)?.size) ?? 0.66; const ratioM = numVal((settings.cards_item_ratio_mobile as { size?: unknown } | undefined)?.size) ?? ratio; const titleTag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(s(settings.cards_title_tag)) ? s(settings.cards_title_tag) : "h3") as "h3"; const TitleTag = titleTag; // 徽章/日期/摘要开关:switcher 键缺省 = 开(左卡无键但有徽章/摘要的实测语义), // 空串 = 显式关(右卡 cards_show_badge/cards_show_excerpt 均为空串) const showBadge = settings.cards_show_badge === undefined ? true : s(settings.cards_show_badge) !== ""; const showMeta = Array.isArray(settings.cards_meta_data) && (settings.cards_meta_data as string[]).includes("date"); const showExcerpt = settings.cards_show_excerpt === undefined ? true : s(settings.cards_show_excerpt) !== ""; // 日期格式 F j, Y(October 21, 2026) const fmtDate = (d: Date | string | null) => { if (!d) return null; const dt = typeof d === "string" ? new Date(d) : d; if (isNaN(dt.getTime())) return null; return dt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); }; const thumbVars = { "--ep-ratio": String(ratio), "--ep-ratio-m": String(ratioM) } as CSSProperties; return (
{posts.map((p) => { // 链接前缀按主分类:News → /news/,其余 → /blog/(原站 News 文章走 /news//) const href = (p.categoryName === "News" ? "/news/" : "/blog/") + p.slug + "/"; const hasThumb = showThumb && !!p.imageUrl; return (
{hasThumb ? (
{p.title}
) : null} {hasThumb && showBadge && p.categoryName ? (
{p.categoryName}
) : null}
{p.title} {showExcerpt ? (

) : null}
{showMeta && p.publishedAt ? (
{fmtDate(p.publishedAt)}
) : null}
); })}
); } /** # 组件 social-icons widget(图标行) */ /** # 函数 文章卡摘要:excerpt 优先,空则取正文纯文本前 N 词(WP wp_trim_words 语义) */ function postExcerpt(p: PostCardItem): string { return p.excerpt && p.excerpt.trim() ? p.excerpt : postWords(p.content || "", 18); } /** # 函数 纯文本取前 N 词(去 HTML 标签后按空白分词,"…" 结尾) */ function postWords(html: string, n: number): string { const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); const words = text.split(" "); return words.length <= n ? text : words.slice(0, n).join(" ") + " …"; } /** # 组件 social-icons widget:忠实 Elementor DOM(widget-social-icons 语义,H16 实测) * .elementor-social-icons-wrapper.elementor-grid(inline-grid + column-gap=icon_spacing, * 由父级 text-align 居中)> a.elementor-icon.elementor-social-icon.elementor-social-icon- * (官方品牌底色/44×44 盒/圆角 10%/白色字形 22px/hover shrink)> span.elementor-screen-only + i.fab; * 字形色原站为白色(SVG fill #fff,icon_primary_color 未作用到字形)。 */ function SocialIconsWidget({ settings }: { settings: Record }) { const items = (settings.social_icon_list as Array>) || []; if (!items.length) return null; const spacing = sizeCss(settings.icon_spacing) || "10px"; const anim = s(settings.hover_animation) === "shrink" ? " elementor-animation-shrink" : ""; return (
{items.map((it, i) => { const value = ((it.social_icon as { value?: string })?.value || "").trim(); // value 形如 "fab fa-linkedin":取末段去 fa- 前缀作品牌名(elementor-social-icon-) const name = (value.split(" ").pop() || "").replace(/^fa-/, ""); const label = name.replace(/^-/, "").replace(/^\w/, (c) => c.toUpperCase()); return ( {label}