v0.10.19: G2 页脚 Footer 一比一复刻(原站 elementor_library post 58)

- 导入脚本 scripts/import-footer-template.ts:从 legacy dump 提取 post 58 的 _elementor_data
  写入 contents(type=template, slug=footer),并按线上 post-58.css/DOM 补齐快照后改动
  (f95e22e Google Preferred Source、405d251 width:auto、2481635 桌面外距)
- Layout/getLayoutData 下发 footerDoc,Footer 改用 ElementorRenderer 直出原模板,
  短代码按 widget id 注入:menu-2 移动端折叠菜单、gtranslate 语言下拉(原生 select.gt_selector)
- Renderer 增强:section html_tag/边框/content_position、column 响应式宽度与
  populated padding/margin(tablet/mobile)、widget 响应式盒模型、icon widget(fixed 返回顶部)、
  icon-list 图标/间距/文字排版与 CSS 变量悬停、image 宽度、divider Elementor DOM、
  text-editor 对齐与 current-date-time 动态年份、shortcode 预渲染上下文
- slim 白名单补齐响应式/图标/动态标签字段;app.css 新增 FA Solid/Regular 字体、
  页脚 scoped 规则、elementor-hidden-* 响应式可见性
- 实测 1436:页脚总高 590.4 与原站全等(列宽/内层列/logo/社交/下拉逐项一致);
  390:56/15/347/337/97/46.4/61 逐项全等;语言切换写 ui_lang cookie;返回顶部回滚 0
- bun test 11 pass;版本 0.10.19
This commit is contained in:
bigcode
2026-11-02 11:20:29 +08:00
parent 686dbe18de
commit a7614e5485
12 changed files with 765 additions and 93 deletions
+12 -2
View File
@@ -26,7 +26,7 @@
| # | 版块 | 状态 | 组件 | | # | 版块 | 状态 | 组件 |
|---|------|------|------| |---|------|------|------|
| G1 | 顶部导航 Header(含 mega menu、语言切换) | ✅ v0.10.3 | `Header.tsx` | | G1 | 顶部导航 Header(含 mega menu、语言切换) | ✅ v0.10.3 | `Header.tsx` |
| G2 | 页脚 Footer | 🟡 | `Footer.tsx` | | G2 | 页脚 Footer(原站 elementor_library post 58) | ✅ v0.10.19 | `Footer.tsx`(ElementorRenderer 直出 46d7afa/d834160/28c7f0b8/a428bb4 + menu-2 移动菜单 + gtranslate 下拉) |
| G3 | 询价弹窗/表单 | 🟡 | `InquiryForm.tsx` | | G3 | 询价弹窗/表单 | 🟡 | `InquiryForm.tsx` |
## 三、首页版块清单(自上而下,20 版块) ## 三、首页版块清单(自上而下,20 版块)
@@ -53,7 +53,7 @@
| H16 | Developer Ecosystem | heading ×2 + 内嵌 section 两卡(heading+text+button/social-icons+image) | ✅ v0.10.17(审计 0 差异:节 ab35847 bg #F4F5F8 + heading 40/600/#000(_padding 10%)+副题 20px/_margin 顶 -6;两卡 680×528.4 白底圆角 6 边框 #E0E5F9、padding 48/48/16、margin 12(populated 扣减 scoped)、卡标题 24/700/#0D152E、文本 18px/_padding 3rem、Join Now 白底橙字 12px 56px、图 544×282 居中;随此版块:SocialIconsWidget 忠实重写(wrapper grid gap 10/a 44×44 官方底色 linkedin #0077B5/youtube #CD201F/facebook #3B5998/圆角 10%/白色字形)+FA Brands 字体子集(public/fonts/fa-brands-400.woff2 + 三码位 content 规则)+elementor-animation-shrink 通用类) | | H16 | Developer Ecosystem | heading ×2 + 内嵌 section 两卡(heading+text+button/social-icons+image) | ✅ v0.10.17(审计 0 差异:节 ab35847 bg #F4F5F8 + heading 40/600/#000(_padding 10%)+副题 20px/_margin 顶 -6;两卡 680×528.4 白底圆角 6 边框 #E0E5F9、padding 48/48/16、margin 12(populated 扣减 scoped)、卡标题 24/700/#0D152E、文本 18px/_padding 3rem、Join Now 白底橙字 12px 56px、图 544×282 居中;随此版块:SocialIconsWidget 忠实重写(wrapper grid gap 10/a 44×44 官方底色 linkedin #0077B5/youtube #CD201F/facebook #3B5998/圆角 10%/白色字形)+FA Brands 字体子集(public/fonts/fa-brands-400.woff2 + 三码位 content 规则)+elementor-animation-shrink 通用类) |
| H17 | News & Updates 标题 | heading | ⬜ | | H17 | News & Updates 标题 | heading | ⬜ |
| H18 | 最新博文 ×2 | 8bc6b68:左 posts 卡×2(3025217)+右 Latest blogs>>标题(1c96d68)+posts 列表×5(a22c185) | ✅ v0.10.18(双端节内几何/样式全等(sec 521):左 skin-cards grid 335×2/gap 16 10、卡 520 高(custom_css .ele-post-dif)、缩略图比率 0.66×内宽+img 铺满、NEWS 徽章(uppercase/白 70%/12px/圆角 4,绝对右上)、标题 18/400/#000+mb16、日期 F j,Y/#ADADAD/padding 10 12、摘要 18 词/#767676/底距 44(空摘要回退正文 18 词);右列卡 grid 630、卡 bg #F4F5F8/圆角 3、纯标题 44px 两行钳制+右侧箭头图标(custom_css);Latest blogs>> 标题 24px+#E0E5F9 下划线+橙线图背景;随此版块:PostsWidget 忠实重写(PostsDataContext 预取架构:loader 按 widget 收集 posts_include_term_ids→listPostsByTermLegacy)、posts cards 基础 CSS、slim 白名单 cards_* 全键、**数据修复** scripts/repair-content-terms.ts(从 legacy dump wp_term_relationships 补 1162 条分类关联,Blog 302/News 22——原导入仅 61/609 篇有关联)) | | H18 | 最新博文 ×2 | 8bc6b68:左 posts 卡×2(3025217)+右 Latest blogs>>标题(1c96d68)+posts 列表×5(a22c185) | ✅ v0.10.18(双端节内几何/样式全等(sec 521):左 skin-cards grid 335×2/gap 16 10、卡 520 高(custom_css .ele-post-dif)、缩略图比率 0.66×内宽+img 铺满、NEWS 徽章(uppercase/白 70%/12px/圆角 4,绝对右上)、标题 18/400/#000+mb16、日期 F j,Y/#ADADAD/padding 10 12、摘要 18 词/#767676/底距 44(空摘要回退正文 18 词);右列卡 grid 630、卡 bg #F4F5F8/圆角 3、纯标题 44px 两行钳制+右侧箭头图标(custom_css);Latest blogs>> 标题 24px+#E0E5F9 下划线+橙线图背景;随此版块:PostsWidget 忠实重写(PostsDataContext 预取架构:loader 按 widget 收集 posts_include_term_ids→listPostsByTermLegacy)、posts cards 基础 CSS、slim 白名单 cards_* 全键、**数据修复** scripts/repair-content-terms.ts(从 legacy dump wp_term_relationships 补 1162 条分类关联,Blog 302/News 22——原导入仅 61/609 篇有关联)) |
| H19 | Explore more DusunIoT news > | 83ecbc5:button 居中 | ✅ v0.10.18(随 H18 交付自然对齐:节 h 123 与原站全等(dy 0.8 页级漂移),按钮设置驱动(白底橙字描边,ButtonWidget 通用实现)。**首页内容树 H0-H19 全部完成**;原站 home 页另有 4 节(46d7afa/d834160/28c7f0b8/a428bb4,合计 884.4px)属页脚 Elementor 模板,不在内容树,归 G2 Footer 待办) | | H19 | Explore more DusunIoT news > | 83ecbc5:button 居中 | ✅ v0.10.18(随 H18 交付自然对齐:节 h 123 与原站全等(dy 0.8 页级漂移),按钮设置驱动(白底橙字描边,ButtonWidget 通用实现)。**首页内容树 H0-H19 全部完成**;原站 home 页另有 4 节(46d7afa/d834160/28c7f0b8/a428bb4)属页脚 Elementor 模板,已由 G2 v0.10.19 以原模板直出全覆盖) |
## 四、其他核心页型版块(自上而下,概览) ## 四、其他核心页型版块(自上而下,概览)
@@ -120,6 +120,16 @@ cookie-policy:19 段纯文本。
> Elementor 通用布局三要素已落地 Renderer:section 根 padding、列 gap 默认 populated padding > Elementor 通用布局三要素已落地 Renderer:section 根 padding、列 gap 默认 populated padding
> (no 0/narrow 5/default 10/extended 15/wide 20/wider 30)、widget-wrap flex wrap + 子元素 width:100% > (no 0/narrow 5/default 10/extended 15/wide 20/wider 30)、widget-wrap flex wrap + 子元素 width:100%
> + `_element_width:auto` 例外。 > + `_element_width:auto` 例外。
>
> **G2 页脚取证(v0.10.19)**:原站页脚为 elementor_library post 58(两节 46d7afa/d834160 +
> 内嵌 28c7f0b8/a428bb4),导入脚本 `scripts/import-footer-template.ts` 从 legacy dump 取
> `_elementor_data` 写入 contents(type=template, slug=footer);快照之后的线上改动按线上
> post-58.css/DOM 补齐(f95e22e Google 徽章、405d251 width:auto、2481635 桌面外距)。
> 1436 实测(GTranslate 下拉已渲染、懒图已加载):页脚总高 590.4 两端一致,列宽
> 228/319.2/307.8/285、内层 615.6/171/193.8/159.6、logo 144.8×42.7、社交行 150×29、
> 下拉 202×39 全部全等;390 实测逐项全等(56/15/347/337/97/46.4/61)。
> 注意:原站 headless 下 WP Rocket 延迟加载 Elementor/jQuery,JS 组件(tabs/swiper)默认不初始化,
> 比对该类版块需先 `page.mouse.wheel` 或按线上真实状态换算,勿把"未初始化"当基线。
## 七、已知遗留(非当前版块范围,后续版块收口) ## 七、已知遗留(非当前版块范围,后续版块收口)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dusuniot-rebuild", "name": "dusuniot-rebuild",
"version": "0.10.18", "version": "0.10.19",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
Binary file not shown.
Binary file not shown.
+124
View File
@@ -0,0 +1,124 @@
/**
* # 脚本 导入页脚 Elementor 模板(legacy elementor_library post 58)
* 背景:WordPress 导入管线未迁移 elementor_library 类型(仅 page/post/landing),
* 页脚模板(post 58,含 46d7afa / d834160 两节及其内嵌 28c7f0b8 / a428bb4)因此缺失。
* 数据源:legacy/dusuniot-db-20261029.sql 中 `(meta_id,58,'_elementor_data','…')` 行。
* 补丁:线上页脚另有 f95e22e(Google Preferred Source 图,dump 快照之后新增),
* 依线上 DOM(/tmp/orig-footer.html 取证:class elementor-widget__width-inherit、
* img 528×64、链接 google.com/preferences/source?q=dusuniot.com)补入 2ccb47d 列末位。
* 结果写入 contents(type=template, slug=footer, meta.elementor),供 Layout 渲染。
* 运行:bun scripts/import-footer-template.ts
*/
import { SQL } from "bun";
const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql";
const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
/** # 函数 从 dump 提取 post 58 的 _elementor_data 并反转义为 JSON
* 返回值 Elementor 元素数组
*/
async function extractTemplate(): Promise<Record<string, unknown>[]> {
const text = await Bun.file(DUMP).text();
const m = text.match(/\(\d+,58,'_elementor_data','((?:[^'\\]|\\.)*)'\)/);
if (!m) throw new Error("dump 中未找到 post 58 的 _elementor_data");
const raw = m[1]!;
// SQL dump 反斜杠转义:\" → "、\' → '、\\ → \
const json = raw.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
const data = JSON.parse(json);
if (!Array.isArray(data)) throw new Error("_elementor_data 不是数组");
return data as Record<string, unknown>[];
}
/** # 函数 深度查找指定 id 的元素节点 */
function findElement(els: Record<string, unknown>[], id: string): Record<string, unknown> | null {
for (const el of els) {
if (el.id === id) return el;
const children = el.elements as Record<string, unknown>[] | undefined;
if (children?.length) {
const hit = findElement(children, id);
if (hit) return hit;
}
}
return null;
}
/** # 函数 线上快照新增的 f95e22e(Google Preferred Source 图)
* 线上 post-58.css 取证:.elementor-element-f95e22e img{width:300px}、
* > .elementor-widget-container{margin:10px 0 0};dump 快照之后新增,故在此补全。
*/
function preferredSourceWidget(): Record<string, unknown> {
return {
id: "f95e22e",
elType: "widget",
settings: {
image: {
url: "http://www.dusuniot.com/wp-content/uploads/2022/06/google-preffered-source.png",
id: 161805,
alt: "google prefferd source",
source: "library",
size: "",
},
image_size: "full",
width: { unit: "px", size: 300, sizes: [] },
align: "left",
_element_width: "inherit",
_margin: { unit: "px", top: "10", right: "0", bottom: "0", left: "0", isLinked: false },
link: {
url: "https://www.google.com/preferences/source?q=dusuniot.com",
is_external: "on",
nofollow: "on",
custom_attributes: "",
},
},
elements: [],
widgetType: "image",
};
}
const elements = await extractTemplate();
// 补丁:2ccb47d 列末尾追加 f95e22e(幂等:已存在则跳过)
const col = findElement(elements, "2ccb47d");
if (!col) throw new Error("模板缺少 2ccb47d 列");
const colChildren = (col.elements as Record<string, unknown>[]) || [];
const psIdx = colChildren.findIndex((el) => el.id === "f95e22e");
if (psIdx >= 0) colChildren[psIdx] = preferredSourceWidget();
else colChildren.push(preferredSourceWidget());
col.elements = colChildren;
// 补丁:社交图标行 405d251 宽度模式(线上 class=elementor-widget__width-auto、
// 生成 CSS width:auto;dump 快照为 inherit,实测线上为 auto → 与 gtranslate 同排)
const social = findElement(elements, "405d251");
if (social) {
const st = (social.settings as Record<string, unknown>) || {};
st._element_width = "auto";
social.settings = st;
}
// 补丁:gtranslate 短代码控件 2481635 桌面外距(线上 post-58.css:margin -10px 0 0 20px;
// dump 只有 _margin_mobile,桌面值来自快照之后的编辑)
const gt = findElement(elements, "2481635");
if (gt) {
const st = (gt.settings as Record<string, unknown>) || {};
st._margin = { unit: "px", top: "-10", right: "0", bottom: "0", left: "20", isLinked: false };
gt.settings = st;
}
const meta = JSON.stringify({ elementor: elements });
// 写入 contents:type=template / slug=footer;已存在则更新
const existing = await db`SELECT id FROM contents WHERE slug='footer' AND type='template'`;
if (existing.length) {
await db`UPDATE contents SET meta=${meta}::jsonb, status='published', updated_at=now() WHERE id=${existing[0]!.id}`;
console.log("已更新 footer 模板,contents.id =", existing[0]!.id);
} else {
const inserted = await db`
INSERT INTO contents (type, slug, title, status, meta, legacy_id, published_at)
VALUES ('template', 'footer', 'Footer', 'published', ${meta}::jsonb, 58, now())
RETURNING id`;
console.log("已插入 footer 模板,contents.id =", inserted[0]!.id);
}
// 验证:节数量与顶层 id
const top = elements.map((el) => el.id).join(", ");
console.log("顶层节:", top);
await db.end();
+3 -3
View File
@@ -23,13 +23,13 @@ console.log("dump 关系对:", pairs.size);
// 2) 本库映射表:legacyId → id // 2) 本库映射表:legacyId → id
const contents = await db`SELECT id, legacy_id FROM contents WHERE legacy_id IS NOT NULL`; const contents = await db`SELECT id, legacy_id FROM contents WHERE legacy_id IS NOT NULL`;
const contentMap = new Map<number, number>(contents.map((r) => [Number(r.legacy_id), Number(r.id)])); const contentMap = new Map<number, number>(contents.map((r: { id: number; legacy_id: number }) => [Number(r.legacy_id), Number(r.id)]));
const cats = await db`SELECT id, legacy_id FROM categories WHERE legacy_id IS NOT NULL`; const cats = await db`SELECT id, legacy_id FROM categories WHERE legacy_id IS NOT NULL`;
const catMap = new Map<number, number>(cats.map((r) => [Number(r.legacy_id), Number(r.id)])); const catMap = new Map<number, number>(cats.map((r: { id: number; legacy_id: number }) => [Number(r.legacy_id), Number(r.id)]));
// 3) 已有关联集合(幂等) // 3) 已有关联集合(幂等)
const existing = await db`SELECT content_id, category_id FROM content_terms`; const existing = await db`SELECT content_id, category_id FROM content_terms`;
const have = new Set(existing.map((r) => `${r.content_id}:${r.category_id}`)); const have = new Set(existing.map((r: { content_id: number; category_id: number }) => `${r.content_id}:${r.category_id}`));
// 4) 补插 // 4) 补插
let inserted = 0; let inserted = 0;
+283 -28
View File
@@ -59,6 +59,18 @@ function stripStructuralTags(html: string): string {
return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, ""); 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 值) /** # 函数 折叠 CSS 盒模型四值简写为最短等价形式(1/2/4 值)
* React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem" * React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem"
* 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。 * 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。
@@ -190,6 +202,29 @@ function resolveGlobalsStr(v: string): string {
return v; return v;
} }
/** # 函数 响应式可见性类名(Elementor hide_* 设置 → elementor-hidden-*,配 app.css 媒体查询)
* 参数 settings 元素设置
* 返回值 空格分隔的类名(无隐藏设置则空串)
*/
function visibilityClass(settings: Record<string, unknown>): string {
const cls: string[] = [];
if (s(settings.hide_desktop) === "hidden-desktop") cls.push("elementor-hidden-desktop");
if (s(settings.hide_tablet) === "hidden-tablet") cls.push("elementor-hidden-tablet");
if (s(settings.hide_mobile) === "hidden-mobile") cls.push("elementor-hidden-mobile");
return cls.join(" ");
}
/** # 函数 section 的自定义 HTML 标签(Elementor html_tag 设置;默认 section)
* 参数 settings 元素设置
* 返回值 合法标签名
*/
function sectionTag(settings: Record<string, unknown>): "section" | "div" | "footer" | "header" | "aside" | "article" {
const tag = s(settings.html_tag);
return (["section", "div", "footer", "header", "aside", "article"] as const).includes(tag as never)
? (tag as "section" | "div" | "footer" | "header" | "aside" | "article")
: "section";
}
/** # 函数 section 的背景样式(纯色 / 图片) */ /** # 函数 section 的背景样式(纯色 / 图片) */
function sectionBgStyle(settings: Record<string, unknown>): CSSProperties { function sectionBgStyle(settings: Record<string, unknown>): CSSProperties {
const style: CSSProperties = {}; const style: CSSProperties = {};
@@ -235,19 +270,26 @@ function normalizeDoc(doc: ElDocument): ElElement[] {
/** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/ /** # 成员变量 posts widget 文章数据上下文(loader 预取,经 widget id 分发) **/
const PostsDataContext = createContext<Record<string, PostCardItem[]>>({}); const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
/** # 组件 Elementor 渲染器入口 */ /** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
export function ElementorRenderer({ doc, postsData }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]> }) { const ShortcodesContext = createContext<Record<string, ReactNode>>({});
/** # 组件 Elementor 渲染器入口
* 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空)
*/
export function ElementorRenderer({ doc, postsData, shortcodes }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]>; shortcodes?: Record<string, ReactNode> }) {
const elements = normalizeDoc(doc); const elements = normalizeDoc(doc);
if (!elements.length) return null; if (!elements.length) return null;
const css = collectCustomCss(elements) + collectResponsiveCss(elements); const css = collectCustomCss(elements) + collectResponsiveCss(elements);
return ( return (
<PostsDataContext.Provider value={postsData || {}}> <PostsDataContext.Provider value={postsData || {}}>
<ShortcodesContext.Provider value={shortcodes || {}}>
<div className="content-html"> <div className="content-html">
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null} {css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
{elements.map((el, i) => ( {elements.map((el, i) => (
<ElementNode key={el.id || i} el={el} /> <ElementNode key={el.id || i} el={el} />
))} ))}
</div> </div>
</ShortcodesContext.Provider>
</PostsDataContext.Provider> </PostsDataContext.Provider>
); );
} }
@@ -279,14 +321,64 @@ function collectResponsiveCss(elements: ElElement[]): string {
let css = ""; let css = "";
function emit(el: ElElement) { function emit(el: ElElement) {
const sel = `[data-el="${el.id}"]`; const sel = `[data-el="${el.id}"]`;
const t = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_tablet); const st = (el.settings ?? {}) as Record<string, unknown>;
const m = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_mobile); // 列的内边距/外边距落在内层 populated(ColumnNode),section 落根元素
if (t) css += `@media (max-width:1024px){${sel}{padding:${t} !important;}}\n`; const boxSel = s(el.elType) === "column" ? `${sel} > div` : sel;
if (m) css += `@media (max-width:767px){${sel}{padding:${m} !important;}}\n`; const t = paddingCss(st.padding_tablet);
const m = paddingCss(st.padding_mobile);
if (t) css += `@media (max-width:1024px){${boxSel}{padding:${t} !important;}}\n`;
if (m) css += `@media (max-width:767px){${boxSel}{padding:${m} !important;}}\n`;
// 响应式外边距(页脚 a428bb4 tablet 70px / mobile 10px;Elementor 根元素 margin)
const mt = paddingCss(st.margin_tablet);
const mm = paddingCss(st.margin_mobile);
if (mt) css += `@media (max-width:1024px){${boxSel}{margin:${mt} !important;}}\n`;
if (mm) css += `@media (max-width:767px){${boxSel}{margin:${mm} !important;}}\n`;
// widget 高级盒模型响应式(_margin/_padding 的 tablet/mobile 变体挂内层容器)
if (s(el.elType) === "widget") {
const wsel = `${sel} > div`;
const wpt = paddingCss(st._padding_tablet);
const wpm = paddingCss(st._padding_mobile);
const wmt = paddingCss(st._margin_tablet);
const wmm = paddingCss(st._margin_mobile);
if (wpt) css += `@media (max-width:1024px){${wsel}{padding:${wpt} !important;}}\n`;
if (wpm) css += `@media (max-width:767px){${wsel}{padding:${wpm} !important;}}\n`;
if (wmt) css += `@media (max-width:1024px){${wsel}{margin:${wmt} !important;}}\n`;
if (wmm) css += `@media (max-width:767px){${wsel}{margin:${wmm} !important;}}\n`;
// widget 宽度模式响应式(页脚 gtranslate 移动端 width inherit)
const ewm = s(st._element_width_mobile);
if (ewm === "inherit") css += `@media (max-width:767px){${sel}{width:100% !important;max-width:100% !important;align-self:center;}}\n`;
else if (ewm === "auto") css += `@media (max-width:767px){${sel}{width:auto !important;max-width:auto !important;align-self:auto;}}\n`;
// image widget 图片移动端宽度(页脚 logo 47%)
if (s(el.widgetType) === "image") {
const iw = sizeCss(st.width_mobile);
if (iw) css += `@media (max-width:767px){${sel} img{width:${iw} !important;}}\n`;
}
}
// 列响应式宽度(_inline_size_tablet / _inline_size_mobile;页脚内层四列 43%/100%)
const tw = st._inline_size_tablet;
const mw = st._inline_size_mobile;
if (typeof tw === "number" && tw > 0) {
css += `@media (max-width:1024px){${sel}{flex:0 0 ${tw}% !important;max-width:${tw}% !important;}}\n`;
}
if (typeof mw === "number" && mw > 0) {
css += `@media (max-width:767px){${sel}{flex:0 0 ${mw}% !important;max-width:${mw}% !important;}}\n`;
}
// icon-list 响应式项间距(space_between_tablet/mobile:页脚社交 10 → 7px)
if (s(el.widgetType) === "icon-list") {
const inlineItems = `${sel} .elementor-icon-list-items.elementor-inline-items`;
const stHalf = halfLength(sizeCss(st.space_between_tablet));
const smHalf = halfLength(sizeCss(st.space_between_mobile)) || stHalf;
if (stHalf) {
css += `@media (max-width:1024px){${inlineItems}{margin-inline:-${stHalf} !important;}${inlineItems} > .elementor-icon-list-item{margin-inline:${stHalf} !important;}}\n`;
}
if (smHalf) {
css += `@media (max-width:767px){${inlineItems}{margin-inline:-${smHalf} !important;}${inlineItems} > .elementor-icon-list-item{margin-inline:${smHalf} !important;}}\n`;
}
}
} }
function walk(els: ElElement[]) { function walk(els: ElElement[]) {
for (const el of els) { for (const el of els) {
if (el.id && (s(el.elType) === "section" || s(el.elType) === "container" || s(el.elType) === "column")) emit(el); if (el.id) emit(el);
if (el.elements?.length) walk(el.elements); if (el.elements?.length) walk(el.elements);
} }
} }
@@ -296,18 +388,19 @@ function collectResponsiveCss(elements: ElElement[]): string {
/** # 组件 单个元素节点分发(section/column/container/widget) /** # 组件 单个元素节点分发(section/column/container/widget)
* 参数 gapPad 所属 section 的默认列内边距(Elementor gap 档位,仅 column 消费) * 参数 gapPad 所属 section 的默认列内边距(Elementor gap 档位,仅 column 消费)
* 参数 contentPos 所属 section 的 content_position(middle → 列的 widget-wrap 垂直居中)
*/ */
function ElementNode({ el, gapPad }: { el: ElElement; gapPad?: string }) { function ElementNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string; contentPos?: string }) {
const type = s(el.elType); const type = s(el.elType);
if (type === "widget") return <WidgetNode el={el} />; if (type === "widget") return <WidgetNode el={el} />;
if (type === "section" || type === "container") return <SectionNode el={el} />; if (type === "section" || type === "container") return <SectionNode el={el} />;
if (type === "column") return <ColumnNode el={el} gapPad={gapPad} />; if (type === "column") return <ColumnNode el={el} gapPad={gapPad} contentPos={contentPos} />;
// 未知结构:有子元素则递归,否则忽略 // 未知结构:有子元素则递归,否则忽略
if (el.elements?.length) { if (el.elements?.length) {
return ( return (
<> <>
{el.elements.map((c, i) => ( {el.elements.map((c, i) => (
<ElementNode key={c.id || i} el={c} gapPad={gapPad} /> <ElementNode key={c.id || i} el={c} gapPad={gapPad} contentPos={contentPos} />
))} ))}
</> </>
); );
@@ -369,17 +462,33 @@ function SectionNode({ el }: { el: ElElement }) {
const ch = sizeCss(settings.custom_height); const ch = sizeCss(settings.custom_height);
if (heightMode === "min-height" && ch) style.minHeight = ch; if (heightMode === "min-height" && ch) style.minHeight = ch;
else if (heightMode === "full" && ch) style.height = ch; else if (heightMode === "full" && ch) style.height = ch;
// section 边框(Elementor 仅在 border_border 有值时生成;页脚 a428bb4 顶部点线)
const secBorder = s(settings.border_border);
if (secBorder) {
style.borderStyle = secBorder as CSSProperties["borderStyle"];
const bw = paddingCss(settings.border_width);
if (bw) style.borderWidth = bw;
const bc = s(settings.border_color);
if (bc) style.borderColor = bc;
}
// 列内容垂直对齐(content_position=middle → align-items:center;页脚 d834160 实测)
const contentPos = s(settings.content_position);
if (contentPos === "middle") inner.alignItems = "center";
else if (contentPos === "top") inner.alignItems = "flex-start";
else if (contentPos === "bottom") inner.alignItems = "flex-end";
if (overlay) style.position = style.position || "relative"; if (overlay) style.position = style.position || "relative";
const Tag = sectionTag(settings);
const vis = visibilityClass(settings);
return ( return (
<section style={style} data-el={el.id}> <Tag className={vis || undefined} style={style} data-el={el.id}>
{overlay} {overlay}
{/* 行加 relative:与绝对定位遮罩同属定位层,DOM 靠后绘制在上,内容不被蒙灰 */} {/* 行加 relative:与绝对定位遮罩同属定位层,DOM 靠后绘制在上,内容不被蒙灰 */}
<div className="mx-auto flex flex-wrap" style={{ ...inner, position: "relative" }}> <div className="mx-auto flex flex-wrap" style={{ ...inner, position: "relative" }}>
{(el.elements || []).map((c, i) => ( {(el.elements || []).map((c, i) => (
<ElementNode key={c.id || i} el={c} gapPad={gapPad} /> <ElementNode key={c.id || i} el={c} gapPad={gapPad} contentPos={contentPos} />
))} ))}
</div> </div>
</section> </Tag>
); );
} }
@@ -405,7 +514,7 @@ function boxNonZero(v: unknown): boolean {
* (frontend.min.css .elementor-column-gap-default > … > .elementor-element-populated{padding:10px}, * (frontend.min.css .elementor-column-gap-default > … > .elementor-element-populated{padding:10px},
* 实测 H6 列 59bc2e1 四向 10px、H7 列 66de90a 设置覆盖为 8/16/16/0,两端一致)。 * 实测 H6 列 59bc2e1 四向 10px、H7 列 66de90a 设置覆盖为 8/16/16/0,两端一致)。
*/ */
function ColumnNode({ el, gapPad }: { el: ElElement; gapPad?: string }) { function ColumnNode({ el, gapPad, contentPos }: { el: ElElement; gapPad?: string; contentPos?: string }) {
const settings = resolveGlobals(el.settings || {}); const settings = resolveGlobals(el.settings || {});
const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" }; const outer: CSSProperties = { minWidth: 0, flex: "1 1 0" };
const inline = settings._inline_size; const inline = settings._inline_size;
@@ -413,7 +522,17 @@ function ColumnNode({ el, gapPad }: { el: ElElement; gapPad?: string }) {
outer.flex = `0 0 ${inline}%`; outer.flex = `0 0 ${inline}%`;
outer.maxWidth = `${inline}%`; outer.maxWidth = `${inline}%`;
} }
// widget-wrap 对齐:Elementor 默认 align-items:normal(stretch,与线上 computed 一致);
// section content_position=middle 时 align-content/align-items 均为 center
// (页脚 d834160 生成规则;社交行因此与 gtranslate 下拉同高 29px)
const inner: CSSProperties = {}; const inner: CSSProperties = {};
if (contentPos === "middle") {
inner.alignItems = "center";
inner.alignContent = "center";
} else if (contentPos === "bottom") {
inner.alignItems = "flex-end";
inner.alignContent = "flex-end";
}
const mg = paddingCss(settings.margin); const mg = paddingCss(settings.margin);
if (mg) { if (mg) {
// margin 写在 populated(与 Elementor 相同):宽保持 auto——块级填充会自动扣除自身外边距 // margin 写在 populated(与 Elementor 相同):宽保持 auto——块级填充会自动扣除自身外边距
@@ -440,7 +559,7 @@ function ColumnNode({ el, gapPad }: { el: ElElement; gapPad?: string }) {
// margin 溢出);H11/H14/H15 等"扣除 margin"语义的列在 app.css 用 !important 覆写为 auto。 // margin 溢出);H11/H14/H15 等"扣除 margin"语义的列在 app.css 用 !important 覆写为 auto。
// 空列(无子元素)裸渲染:原站空列无 populated 盒(H15 占位格 margin/边框均为 0), // 空列(无子元素)裸渲染:原站空列无 populated 盒(H15 占位格 margin/边框均为 0),
// 仅保留外层弹性宽度与拉伸高度 // 仅保留外层弹性宽度与拉伸高度
<div className="el-col min-w-0" style={outer} data-el={el.id}> <div className={"el-col min-w-0 " + visibilityClass(settings)} style={outer} data-el={el.id}>
{(el.elements || []).length ? ( {(el.elements || []).length ? (
<div style={{ display: "flex", flexWrap: "wrap", alignContent: "flex-start", width: "100%", ...inner }}> <div style={{ display: "flex", flexWrap: "wrap", alignContent: "flex-start", width: "100%", ...inner }}>
{(el.elements || []).map((c, i) => ( {(el.elements || []).map((c, i) => (
@@ -493,13 +612,19 @@ function WidgetNode({ el }: { el: ElElement }) {
// widget 默认满宽(flex wrap 中独占一行),_element_width=auto 时按内容宽(横排) // widget 默认满宽(flex wrap 中独占一行),_element_width=auto 时按内容宽(横排)
const widthAuto = s(settings._element_width) === "auto"; const widthAuto = s(settings._element_width) === "auto";
const cls = s(settings._css_classes) || undefined; const cls = s(settings._css_classes) || undefined;
const baseCls = cls ? (widthAuto ? cls + " el-w-auto" : cls) : widthAuto ? "el-w-auto" : undefined; const vis = visibilityClass(settings);
const hasContainerStyle = Object.keys(style).length > 0; const baseCls = [cls, widthAuto ? "el-w-auto" : "", vis].filter(Boolean).join(" ") || undefined;
// 有桌面盒样式,或有响应式 _margin_*/_padding_*(移动端需挂在内层容器,页脚 c015d97
// -1rem 上外距靠内层负外距把整块高度压到 15px)→ 双层结构
const hasResponsiveBox =
boxNonZero(settings._margin_mobile) || boxNonZero(settings._padding_mobile) ||
boxNonZero(settings._margin_tablet) || boxNonZero(settings._padding_tablet);
const hasContainerStyle = Object.keys(style).length > 0 || hasResponsiveBox;
if (hasContainerStyle) { if (hasContainerStyle) {
return ( return (
// 外层也要带 el-w-auto:列内基础规则按 :not(.el-w-auto) 放行 width:auto 的根, // 外层也要带 el-w-auto:列内基础规则按 :not(.el-w-auto) 放行 width:auto 的根,
// 否则 width-auto widget(如图标)被 width:100% 强推成每行一个(H7 回归根因) // 否则 width-auto widget(如图标)被 width:100% 强推成每行一个(H7 回归根因)
<div className={widthAuto ? "el-w-auto" : undefined} data-el={el.id}> <div className={[widthAuto ? "el-w-auto" : "", vis].filter(Boolean).join(" ") || undefined} data-el={el.id}>
<div className={baseCls} style={{ width: widthAuto ? "auto" : "100%", ...style }}>{inner}</div> <div className={baseCls} style={{ width: widthAuto ? "auto" : "100%", ...style }}>{inner}</div>
</div> </div>
); );
@@ -518,6 +643,7 @@ class WidgetGuard extends Component<{ widgetType: string; children: ReactNode },
function WidgetNodeInner({ el }: { el: ElElement }) { function WidgetNodeInner({ el }: { el: ElElement }) {
const w = s(el.widgetType); const w = s(el.widgetType);
const settings = resolveGlobals(el.settings || {}); const settings = resolveGlobals(el.settings || {});
const shortcodes = useContext(ShortcodesContext);
const unknownFallback = () => { const unknownFallback = () => {
const title = s(settings.heading_title) || s(settings.title); const title = s(settings.heading_title) || s(settings.title);
const text = s(settings.editor) || s(settings.html) || s(settings.text); const text = s(settings.editor) || s(settings.html) || s(settings.text);
@@ -562,10 +688,21 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
return <ReviewsWidget settings={settings} />; return <ReviewsWidget settings={settings} />;
case "icon-list": case "icon-list":
return <IconListWidget settings={settings} />; return <IconListWidget settings={settings} />;
case "icon":
return <IconWidget settings={settings} />;
case "spacer": case "spacer":
return <div style={{ height: sizeCss(settings.space, "px") || "20px" }} />; return <div style={{ height: sizeCss(settings.space, "px") || "20px" }} />;
case "divider": case "divider":
return <hr style={{ border: 0, borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}`, margin: "18px 0" }} />; // Elementor divider DOM(页脚 c015d97:padding-block 15px + 1px #4D4E53 分隔线,
// 高 31px,与 widget 的 20px 下外距合计 51px 间距,实测 2026-11-02)
return (
<div className="elementor-divider" style={{ paddingBlock: sizeCss(settings.gap) || "15px" }}>
<span
className="elementor-divider-separator"
style={{ display: "block", width: "100%", borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}` }}
/>
</div>
);
case "toggle": case "toggle":
case "accordion": case "accordion":
return <AccordionWidget settings={settings} />; return <AccordionWidget settings={settings} />;
@@ -595,7 +732,9 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
</div> </div>
); );
case "shortcode": case "shortcode":
return null; // WordPress shortcode 无法在 Node 端执行,忽略 // WordPress 短代码无法在 Node 端执行:由调用方按 widget id 传入预渲染节点
// (页脚 post 58:0683e73 → menu-2 移动端折叠菜单;2481635 → gtranslate 语言切换)
return shortcodes[s(el.id)] ?? null;
case "code-highlight": case "code-highlight":
return s(settings.source_code) ? ( return s(settings.source_code) ? (
<pre className="mb-[18px]" style={{ background: "#0b1c2c", color: "#d7e5f5", padding: 16, borderRadius: 8, overflowX: "auto" }}> <pre className="mb-[18px]" style={{ background: "#0b1c2c", color: "#d7e5f5", padding: 16, borderRadius: 8, overflowX: "auto" }}>
@@ -637,13 +776,32 @@ function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
} }
/** # 组件 text-editor widget:富文本 HTML */ /** # 组件 text-editor widget:富文本 HTML */
function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) { function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) {
const html = s(settings.editor); let html = s(settings.editor);
// 动态标签 current-date-time(页脚 1fe90e6 版权年份):editor 为空 + __dynamic__.editor
// 含 current-date-time → 按 before + Y 生成(原站实测 "Copyright ©2026")
const dynamic = s((settings.__dynamic__ as Record<string, unknown> | undefined)?.editor);
if (!html && dynamic.includes("current-date-time")) {
// settings 属性是 URL 编码的 JSON:settings="%7B%22date_format%22...%22before%22%3A%22Copyright%20%C2%A9%22"
let before = "Copyright ©";
const m = /settings="([^"]*)"/.exec(dynamic);
if (m) {
try {
const dj = JSON.parse(decodeURIComponent(m[1]!)) as { before?: string };
if (dj.before) before = dj.before;
} catch {
/* 解析失败用默认前缀 */
}
}
html = `${before}${new Date().getFullYear()}`;
}
if (!html) return null; if (!html) return null;
// 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用 // 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用
const color = s(settings.text_color); const color = s(settings.text_color);
const style: CSSProperties = {}; const style: CSSProperties = {};
if (color) style.color = color; if (color) style.color = color;
Object.assign(style, typographyCss(settings)); Object.assign(style, typographyCss(settings));
const align = s(settings.align);
if (align) style.textAlign = align as CSSProperties["textAlign"];
// _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复 // _padding/_margin/_background_* 属 widget 包裹层(WidgetNode),不在此重复
return <div className="el-texteditor" style={Object.keys(style).length ? style : undefined} dangerouslySetInnerHTML={{ __html: html }} />; return <div className="el-texteditor" style={Object.keys(style).length ? style : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
} }
@@ -659,7 +817,10 @@ function ImageWidget({ settings }: { settings: Record<string, unknown> }) {
const link = (settings.link as { url?: string } | undefined)?.url; const link = (settings.link as { url?: string } | undefined)?.url;
const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on"; const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
const linkProps = link ? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) } : undefined; 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" />; // 图片宽度设置(Elementor 生成 `.elementor-element-X img{width:57%}`;页脚 logo 57%、
// Google 徽章 300px):作用于 img 本身,a 为 inline-block 时按固有宽度收缩(与原站一致)
const imgWidth = sizeCss(settings.width);
const img = <img className={animCls || undefined} src={url} alt={s(img0.alt)} loading="lazy" style={imgWidth ? { width: imgWidth } : undefined} />;
const cls = ("el-image " + (animCls || "")).trim(); const cls = ("el-image " + (animCls || "")).trim();
return ( return (
<div className={cls} style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}> <div className={cls} style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
@@ -1291,30 +1452,60 @@ function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
const list = Array.isArray(settings.icon_list) const list = Array.isArray(settings.icon_list)
? (settings.icon_list as { text?: string; _id?: string; link?: { url?: string; is_external?: string }; selected_icon?: { value?: string } }[]) ? (settings.icon_list as { text?: string; _id?: string; link?: { url?: string; is_external?: string }; selected_icon?: { value?: string } }[])
: []; : [];
const items = list.filter((it) => s(it.text)); // 允许只有图标没有文字的项(页脚社交图标行 text 为空、selected_icon=fab fa-*)
const items = list.filter((it) => s(it.text) || s(it.selected_icon?.value));
if (!items.length) return null; if (!items.length) return null;
const inline = s(settings.view) === "inline"; const inline = s(settings.view) === "inline";
const color = s(settings.text_color) || undefined; const color = s(settings.text_color) || undefined;
const iconColor = s(settings.icon_color) || undefined;
const iconSize = sizeCss(settings.icon_size);
const iconAlign = s(settings.icon_align);
// 项间距(space_between):Elementor 竖排 = padding-block-end(space/2) + margin-block-start(space/2),
// 横排 = li margin-inline(space/2) + ul 负 margin-inline(页脚 5px / 社交 10px,实测 li 高 23.5)
const half = halfLength(sizeCss(settings.space_between));
const typo = typographyCss(settings, "icon_typography");
// 颜色用 CSS 变量承载:文字/图标的内联色若写死十六进制,页面级 :hover 规则无法覆盖
// (内联样式优先级最高)。改为 span/i 引用 var(--el-il-text/icon),悬停规则改 li 上的变量值。
const vars: Record<string, string> = {};
if (color) vars["--el-il-text"] = color;
if (iconColor) vars["--el-il-icon"] = iconColor;
const ulStyle: CSSProperties = { ...typo };
Object.assign(ulStyle, vars);
if (iconAlign === "left") ulStyle.justifyContent = "flex-start";
else if (iconAlign === "center") ulStyle.justifyContent = "center";
else if (iconAlign === "right") ulStyle.justifyContent = "flex-end";
if (inline && half) ulStyle.marginInline = `-${half}`;
return ( return (
<ul className={"elementor-icon-list-items" + (inline ? " elementor-inline-items" : "")}> <ul className={"elementor-icon-list-items" + (inline ? " elementor-inline-items" : "")} style={Object.keys(ulStyle).length ? ulStyle : undefined}>
{items.map((it, i) => { {items.map((it, i) => {
const link = it.link?.url; const link = it.link?.url;
const external = it.link?.is_external === "on"; const external = it.link?.is_external === "on";
const icon = s(it.selected_icon?.value);
const liStyle: CSSProperties | undefined = half
? inline
? { marginInline: half }
: { paddingBottom: i < items.length - 1 ? half : undefined, marginTop: i > 0 ? half : undefined }
: undefined;
const iconStyle: CSSProperties = { color: iconColor ? "var(--el-il-icon)" : undefined, fontSize: iconSize };
const text = ( const text = (
<span <span
className="elementor-icon-list-text" className="elementor-icon-list-text"
style={color ? { color } : undefined} style={color ? { color: "var(--el-il-text)" } : undefined}
dangerouslySetInnerHTML={{ __html: s(it.text) }} dangerouslySetInnerHTML={{ __html: s(it.text) }}
/> />
); );
return ( return (
<li key={it._id || i} className={"elementor-icon-list-item" + (inline ? " elementor-inline-item" : "")}> <li key={it._id || i} className={"elementor-icon-list-item" + (inline ? " elementor-inline-item" : "")} style={liStyle}>
{link ? ( {link ? (
<a href={link} target={external ? "_blank" : undefined} rel={external ? "noopener" : undefined}> <a href={link} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined}>
{icon ? <span className="elementor-icon-list-icon"><i className={icon} aria-hidden="true" style={iconStyle} /></span> : null}
{text} {text}
</a> </a>
) : ( ) : (
text <>
{icon ? <span className="elementor-icon-list-icon"><i className={icon} aria-hidden="true" style={iconStyle} /></span> : null}
{text}
</>
)} )}
</li> </li>
); );
@@ -1323,6 +1514,70 @@ function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
); );
} }
/** # 组件 icon widget(Elementor .elementor-widget-icon)
* 页脚 post 58 的 5ffe573:view=stacked + _position=fixed(左下角返回顶部按钮),
* 48×48 圆(图标 24px + padding 0.5em)、底色 secondary #FFA11B、悬停主色 #ED6C00(post-58.css 取证);
* 088727e(询价弹窗入口)在桌面/平板/移动全部隐藏,由可见性类名隐藏。
*/
function IconWidget({ settings }: { settings: Record<string, unknown> }) {
const icon = s((settings.selected_icon as { value?: string } | undefined)?.value);
if (!icon) return null;
const view = s(settings.view);
const size = sizeCss(settings.size) || "24px";
const primary = s(settings.primary_color);
const secondary = s(settings.secondary_color);
const link = (settings.link as { url?: string } | undefined)?.url;
const wrapper: CSSProperties = {};
if (s(settings._position) === "fixed") {
wrapper.position = "fixed";
const x = sizeCss(settings._offset_x);
const xe = sizeCss(settings._offset_x_end);
const y = sizeCss(settings._offset_y);
const ye = sizeCss(settings._offset_y_end);
if (s(settings._offset_orientation_h) === "end") {
if (xe) wrapper.right = xe;
} else if (x) wrapper.left = x;
else if (xe) wrapper.right = xe;
if (s(settings._offset_orientation_v) === "end") {
if (ye) wrapper.bottom = ye;
} else if (y) wrapper.top = y;
else if (ye) wrapper.bottom = ye;
wrapper.zIndex = 99;
wrapper.width = "auto";
}
const iconStyle: CSSProperties = { fontSize: size, lineHeight: 0 };
if (view === "stacked") {
iconStyle.display = "inline-flex";
iconStyle.alignItems = "center";
iconStyle.justifyContent = "center";
iconStyle.padding = "0.5em";
iconStyle.borderRadius = "50%";
iconStyle.backgroundColor = secondary || primary || "#FFA11B";
iconStyle.color = "#ffffff";
} else {
if (primary) iconStyle.color = primary;
if (view === "framed") {
iconStyle.display = "inline-flex";
iconStyle.padding = "0.5em";
iconStyle.borderRadius = "50%";
iconStyle.border = `3px solid ${primary || "currentColor"}`;
}
}
return (
<div className="elementor-icon-wrapper" style={{ textAlign: "center", ...wrapper }}>
{link ? (
<a className="elementor-icon" href={link} style={iconStyle}>
<i className={icon} aria-hidden="true" />
</a>
) : (
<div className="elementor-icon" style={iconStyle}>
<i className={icon} aria-hidden="true" />
</div>
)}
</div>
);
}
/** # 组件 toggle / accordion widget:手风琴 */ /** # 组件 toggle / accordion widget:手风琴 */
function AccordionWidget({ settings }: { settings: Record<string, unknown> }) { function AccordionWidget({ settings }: { settings: Record<string, unknown> }) {
const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : []; const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : [];
+48
View File
@@ -28,7 +28,17 @@ const SETTING_KEYS = new Set([
"border_width", "border_width",
"content_width", "content_width",
"custom_height", "custom_height",
// section 自定义标签(页脚 d834160 = footer)与列垂直对齐(middle)
"html_tag",
"content_position",
// 列响应式宽度(页脚内层四列 tablet 43% / mobile 100%)
"_inline_size_tablet",
"_inline_size_mobile",
"padding", "padding",
"padding_tablet",
"padding_mobile",
"margin_tablet",
"margin_mobile",
"layout", "layout",
"height", "height",
"size", "size",
@@ -59,6 +69,14 @@ const SETTING_KEYS = new Set([
"description_typography_font_size", "description_typography_font_size",
"description_typography_font_size_mobile", "description_typography_font_size_mobile",
"description_typography_font_size_tablet", "description_typography_font_size_tablet",
// icon-list 项间距与文字排版(页脚链接列 5px / 14px 300,实测 2026-11-02)
"space_between",
"space_between_tablet",
"space_between_mobile",
"icon_typography_typography",
"icon_typography_font_size",
"icon_typography_font_size_tablet",
"icon_typography_font_weight",
"gradient_angle", "gradient_angle",
"color_a", "color_a",
"color_b", "color_b",
@@ -93,6 +111,7 @@ const SETTING_KEYS = new Set([
"selected_icon", "selected_icon",
"url", "url",
"width", "width",
"width_mobile",
"text", "text",
"editor", "editor",
"html", "html",
@@ -162,8 +181,37 @@ const SETTING_KEYS = new Set([
"hover_animation", "hover_animation",
// widget 高级盒模型(如 eael-info-box 边框卡:_border_* + _padding/_margin) // widget 高级盒模型(如 eael-info-box 边框卡:_border_* + _padding/_margin)
"_margin", "_margin",
"_margin_tablet",
"_margin_mobile",
"_padding_tablet",
"_padding_mobile",
// widget 宽度模式(auto → 按内容宽横排,如 H7 图标行) // widget 宽度模式(auto → 按内容宽横排,如 H7 图标行)
"_element_width", "_element_width",
"_element_width_mobile",
"_element_vertical_align_mobile",
// icon widget:堆叠/描边视图、固定定位与偏移(页脚返回顶部按钮)
"primary_color",
"secondary_color",
"hover_primary_color",
"hover_secondary_color",
"_position",
"_offset_x",
"_offset_x_end",
"_offset_y",
"_offset_y_end",
"_offset_orientation_h",
"_offset_orientation_v",
// icon-list 图标样式(页脚社交行 icon_color/icon_size/icon_align)
"icon_color",
"icon_size",
"icon_align",
"icon_align_mobile",
// 响应式可见性(页脚 46d7afa 移动端隐藏、088727e 全断点隐藏)
"hide_desktop",
"hide_tablet",
"hide_mobile",
// 动态标签(页脚版权年份 current-date-time)
"__dynamic__",
// widget 容器背景(如 H4 标题下的橙线图) // widget 容器背景(如 H4 标题下的橙线图)
"_background_image", "_background_image",
"_background_position", "_background_position",
+147 -44
View File
@@ -1,12 +1,20 @@
/** /**
* # 组件 网站页脚 Footer(对齐原站,Tailwind + shadcn/ui 实现) * # 组件 网站页脚 Footer
* 深灰底两段:上段 4 个菜单组;下段左侧品牌区(logo + 简介 + 社交 + 语言下拉), * 原站页脚为 Elementor 模板(elementor_library post 58),由 Layout 从 contents
* 右侧 3 个菜单组;底部版权条。 * (type=template, slug=footer)取出后交给 ElementorRenderer 一比一渲染:
* 数据来自导入的 footer 菜单(menu-2);为空时降级为内置链接。 * - 46d7afa:顶部四列链接(System on Modules / ODM-OEM / Multiprotocol IoT Gateway /
* One-Stop Hardware Solutions,桌面显示、移动隐藏);
* - d834160(html_tag=footer):返回顶部按钮(fixed 左下)、分隔线、内嵌 28c7f0b8
* (logo + 简介 + 社交图标 + Google 徽章 + About/Developer/Policy 三列)与
* a428bb4(版权条)。
* 短代码 widget 由本组件预渲染注入:menu-2 折叠菜单(仅移动端,桌面/平板隐藏)、
* gtranslate(原站该控件在桌面渲染为空,保持为空以对齐)。
* 模板缺失时降级为内置菜单页脚,保证站点可用。
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Headset } from "lucide-react"; import { Headset } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { ElementorRenderer, type ElDocument } from "~/components/elementor/Renderer";
import { useT } from "~/i18n"; import { useT } from "~/i18n";
import { LOCALES, getUiLocale, setUiLocale } from "~/i18n/shared"; import { LOCALES, getUiLocale, setUiLocale } from "~/i18n/shared";
import type { MenuNode } from "~/server/data"; import type { MenuNode } from "~/server/data";
@@ -26,10 +34,139 @@ const FALLBACK_GROUPS: MenuNode[] = [
]; ];
/** # 组件 页脚 */ /** # 组件 页脚 */
export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[] }) { export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[]; doc?: ElDocument | null }) {
// 优先渲染原站 Elementor 页脚模板(一比一);短代码按 widget id 注入
if (props.doc) {
const doc = props.doc;
const hasElements = Array.isArray(doc) ? doc.length > 0 : !!(doc as { elements?: unknown[] }).elements?.length;
if (hasElements) {
return (
<footer className="elementor elementor-58 elementor-location-footer" data-elementor-type="footer" data-elementor-id="58">
<ElementorRenderer
doc={doc}
shortcodes={{
"0683e73": <FooterMobileMenu menu={props.menu} />,
"2481635": <FooterLanguageSelect />,
}}
/>
</footer>
);
}
}
return <FallbackFooter categories={props.categories} menu={props.menu} />;
}
/** # 组件 移动端折叠菜单(原站 [maxmegamenu location=menu-2],桌面/平板由 Elementor 隐藏)
* 结构对齐 Max Mega Menu 输出(mega-menu-wrap / mega-menu-toggle / ul.mega-menu),
* 交互为点击展开(原站 data-effect-mobile=disabled,移动端即点击展开)。
*/
function FooterMobileMenu({ menu }: { menu: MenuNode[] }) {
const [open, setOpen] = useState(false);
const groups = menu.length > 0 ? menu : FALLBACK_GROUPS;
return (
<div id="mega-menu-wrap-menu-2" className="mega-menu-wrap">
<div className="mega-menu-toggle">
<div className="mega-toggle-blocks-left" />
<div className="mega-toggle-blocks-center">
<div className="mega-toggle-block mega-menu-toggle-animated-block mega-toggle-block-1" id="mega-toggle-block-1">
<button
type="button"
aria-label="Toggle Menu"
aria-expanded={open}
aria-controls="mega-menu-menu-2"
className="mega-toggle-animated mega-toggle-animated-slider"
onClick={() => setOpen(!open)}
>
<span className="mega-toggle-animated-box">
<span className="mega-toggle-animated-inner" />
</span>
</button>
</div>
</div>
<div className="mega-toggle-blocks-right" />
</div>
<ul id="mega-menu-menu-2" className="mega-menu max-mega-menu" hidden={!open}>
{groups.map((g) => (
<li key={g.id} className={"mega-menu-item" + (g.children.length ? " mega-menu-item-has-children" : "")}>
<a className="mega-menu-link" href={g.url || "#"} target={g.target === "_blank" ? "_blank" : undefined}>
{g.title}
</a>
{g.children.length ? (
<ul className="mega-sub-menu">
{g.children.map((c) => (
<li key={c.id} className="mega-menu-item">
<a className="mega-menu-link" href={c.url || "#"} target={c.target === "_blank" ? "_blank" : undefined}>
{c.title}
</a>
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
</div>
);
}
/** # 常量 页脚语言下拉选项(对齐原站 GTranslate 列表顺序与英文名;
* 切换写入 ui_lang cookie 后整页刷新,由自建 i18n 词典接管渲染) */
const FOOTER_LANGS: { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "de", label: "German" },
{ code: "fr", label: "French" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
{ code: "ru", label: "Russian" },
{ code: "es", label: "Spanish" },
{ code: "th", label: "Thai" },
{ code: "ar", label: "Arabic" },
{ code: "zh-TW", label: "Chinese (Traditional)" },
{ code: "it", label: "Italian" },
{ code: "hi", label: "Hindi" },
{ code: "uk", label: "Ukrainian" },
{ code: "pt", label: "Portuguese" },
{ code: "tr", label: "Turkish" },
{ code: "nl", label: "Dutch" },
{ code: "tl", label: "Filipino" },
{ code: "id", label: "Indonesian" },
{ code: "no", label: "Norwegian" },
{ code: "zh-CN", label: "Chinese (Simplified)" },
];
/** # 组件 页脚语言下拉(原站 [gtranslate] 短代码控件 2481635)
* 线上渲染为原生 select.gt_selector(202×39、白字、#3E3E3F 底、1px #666 边框,实测 2026-11-02);
* 这里用同结构原生 select 承载自建 i18n,选择后写 cookie 刷新。
*/
function FooterLanguageSelect() {
const [lang, setLang] = useState("en");
useEffect(() => setLang(getUiLocale()), []);
return (
<div className="gtranslate_wrapper gt_container">
<select
className="gt_selector notranslate"
aria-label="Select Language"
value={lang}
onChange={(e) => {
const v = e.target.value;
if (v && v !== lang) setUiLocale(v);
}}
>
<option value="">Select Language</option>
{FOOTER_LANGS.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
</div>
);
}
/** # 组件 降级页脚(Elementor 模板缺失时) */
function FallbackFooter(props: { categories: FooterCategory[]; menu: MenuNode[] }) {
const t = useT(); const t = useT();
const groups = props.menu.length > 0 ? props.menu : FALLBACK_GROUPS; const groups = props.menu.length > 0 ? props.menu : FALLBACK_GROUPS;
// 前 4 组为上段列,后 3 组为下段右侧列(与原站 footer 菜单顺序一致)
const topGroups = groups.slice(0, 4); const topGroups = groups.slice(0, 4);
const bottomGroups = groups.slice(4, 7); const bottomGroups = groups.slice(4, 7);
return ( return (
@@ -43,22 +180,12 @@ export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[] }
<div className="border-t border-white/12" /> <div className="border-t border-white/12" />
<div className="grid grid-cols-1 gap-10 py-[30px] pb-9 min-[900px]:grid-cols-[1.3fr_2fr]"> <div className="grid grid-cols-1 gap-10 py-[30px] pb-9 min-[900px]:grid-cols-[1.3fr_2fr]">
<div> <div>
{/* 直接复用原站页脚 logo(同图:wp-content/uploads/2023/01/dusun-iot-logo.png,实测渲染 144.77×42.73px) */}
<a href="/" className="inline-block"> <a href="/" className="inline-block">
<img src="/uploads/2023/01/dusun-iot-logo.png" width={254} height={75} alt="Zhejiang Dusun Electron Co., Ltd." className="h-auto w-[145px]" /> <img src="/uploads/2023/01/dusun-iot-logo.png" width={254} height={75} alt="Zhejiang Dusun Electron Co., Ltd." className="h-auto w-[145px]" />
</a> </a>
<p className="mt-3.5 mb-4 leading-[1.7] text-[#b5b5b6]">{t("footer_intro")}</p> <p className="mt-3.5 mb-4 leading-[1.7] text-[#b5b5b6]">{t("footer_intro")}</p>
<div className="mb-4 flex gap-2.5"> <div className="mb-4 flex gap-2.5">
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white"> <a href="https://www.linkedin.com/company/dusun-electron-ltd/" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
<LinkedinGlyph />
</a>
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
<YoutubeGlyph />
</a>
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
<FacebookGlyph />
</a>
<a href="/contact-us/" aria-label="Contact" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
<Headset size={15} aria-hidden /> <Headset size={15} aria-hidden />
</a> </a>
</div> </div>
@@ -74,7 +201,7 @@ export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[] }
<div className="bg-footer-dark py-3.5 text-center text-xs text-[#a9a9aa]"> <div className="bg-footer-dark py-3.5 text-center text-xs text-[#a9a9aa]">
<div className="site-container flex justify-center"> <div className="site-container flex justify-center">
<span> <span>
Copyright ©2026 <a href="/" className="text-brand hover:text-[#ff8a2a]">Dusuniot</a>. {t("footer_rights")} |{" "} Copyright ©{new Date().getFullYear()} <a href="/" className="text-brand hover:text-[#ff8a2a]">Dusuniot</a>. {t("footer_rights")} |{" "}
<a href="/sitemap.xml" className="text-brand hover:text-[#ff8a2a]">{t("footer_sitemap")}</a> <a href="/sitemap.xml" className="text-brand hover:text-[#ff8a2a]">{t("footer_sitemap")}</a>
</span> </span>
</div> </div>
@@ -83,9 +210,8 @@ export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[] }
); );
} }
/** # 组件 页脚语言下拉(选择后写 ui_lang cookie 并刷新,与 Header 切换共用 i18n) */ /** # 组件 页脚语言下拉(降级页脚用;选择后写 ui_lang cookie 并刷新) */
function FooterLangSelect() { function FooterLangSelect() {
// 语言值客户端挂载后再读 cookie,避免 SSR 水合不一致
const [lang, setLang] = useState("en"); const [lang, setLang] = useState("en");
useEffect(() => setLang(getUiLocale()), []); useEffect(() => setLang(getUiLocale()), []);
return ( return (
@@ -104,7 +230,7 @@ function FooterLangSelect() {
); );
} }
/** # 组件 页脚菜单组(橙色标题 + 子链接列表;无子项时仅渲染标题链接) */ /** # 组件 降级页脚菜单组 */
function FooterGroup({ group }: { group: MenuNode }) { function FooterGroup({ group }: { group: MenuNode }) {
return ( return (
<div> <div>
@@ -131,26 +257,3 @@ function FooterGroup({ group }: { group: MenuNode }) {
</div> </div>
); );
} }
/** # 组件 页脚社交图标(lucide 新版无品牌图标,内联 SVG) */
function YoutubeGlyph() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23 7.6a3 3 0 0 0-2.1-2.2C19 4.9 12 4.9 12 4.9s-7 0-8.9.5A3 3 0 0 0 1 7.6 32 32 0 0 0 .6 12 32 32 0 0 0 1 16.4a3 3 0 0 0 2.1 2.1c1.9.6 8.9.6 8.9.6s7 0 8.9-.6a3 3 0 0 0 2.1-2.1A32 32 0 0 0 23.4 12 32 32 0 0 0 23 7.6ZM9.8 15.1V8.9l5.8 3.1-5.8 3.1Z" />
</svg>
);
}
function FacebookGlyph() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13.5 21v-8h2.7l.4-3.1h-3.1V7.9c0-.9.3-1.5 1.6-1.5h1.6V3.6c-.3 0-1.3-.1-2.4-.1-2.4 0-4 1.4-4 4.1v2.3H7.5V13h2.8v8h3.2Z" />
</svg>
);
}
function LinkedinGlyph() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M4.98 3.5A2.5 2.5 0 1 1 5 8.5a2.5 2.5 0 0 1-.02-5ZM3 9.5h4V21H3V9.5Zm6.5 0h3.8v1.6h.1c.5-1 1.8-2 3.7-2 4 0 4.7 2.6 4.7 6V21h-4v-5.2c0-1.2 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8V21h-4V9.5Z" />
</svg>
);
}
+20 -7
View File
@@ -1,13 +1,14 @@
/** /**
* # 组件 网站公共布局 Layout * # 组件 网站公共布局 Layout
* 组合 Header + 页面内容 + Footer;样式入口(Tailwind v4 + shadcn/ui)在此引入一次。 * 组合 Header + 页面内容 + Footer;样式入口(Tailwind v4 + shadcn/ui)在此引入一次。
* 布局数据(菜单/分类)由各路由 loader 通过 getLayoutData() 取得后传入。 * 布局数据(菜单/分类/页脚 Elementor 模板)由各路由 loader 通过 getLayoutData() 取得后传入。
*/ */
import "../../styles/app.css"; import "../../styles/app.css";
import { Header } from "./Header"; import { Header } from "./Header";
import { Footer, type FooterCategory } from "./Footer"; import { Footer, type FooterCategory } from "./Footer";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { getMenuTree, getProductCategoryTree, type MenuNode } from "~/server/data"; import { getFooterTemplate, getMenuTree, getProductCategoryTree, type MenuNode } from "~/server/data";
import type { ElElement } from "~/components/elementor/Renderer";
/** # 类型 布局数据(Header/Footer 所需) */ /** # 类型 布局数据(Header/Footer 所需) */
export type LayoutData = { export type LayoutData = {
@@ -17,12 +18,24 @@ export type LayoutData = {
footerMenu: MenuNode[]; footerMenu: MenuNode[];
/** # 成员变量 产品分类树(含计数) **/ /** # 成员变量 产品分类树(含计数) **/
categories: FooterCategory[]; categories: FooterCategory[];
/** # 成员变量 页脚 Elementor 模板文档(原站 elementor_library post 58) **/
footerDoc: ElElement[] | null;
}; };
/** # 函数 Server Function:获取布局数据(菜单 + 分类) */ /** # 函数 Server Function:获取布局数据(菜单 + 分类 + 页脚模板)
export const getLayoutData = createServerFn({ method: "GET" }).handler(async (): Promise<LayoutData> => { * 返回类型标 any:Elementor 文档的 settings 是递归 unknown(JSON),TanStack 的
const [menu, footerMenu, cats] = await Promise.all([getMenuTree("max_mega_menu_1"), getMenuTree("menu-2"), getProductCategoryTree()]); * 序列化静态校验无法展开递归 Record,会误报不可序列化;运行时就是纯 JSON。
return { menu, footerMenu, categories: cats }; */
export const getLayoutData = createServerFn({ method: "GET" }).handler(async (): Promise<any> => {
const [menu, footerMenu, cats, footer] = await Promise.all([
getMenuTree("max_mega_menu_1"),
getMenuTree("menu-2"),
getProductCategoryTree(),
getFooterTemplate().catch(() => null),
]);
const el = (footer?.meta as { elementor?: unknown } | null)?.elementor;
const footerDoc = Array.isArray(el) ? (el as ElElement[]) : null;
return { menu, footerMenu, categories: cats, footerDoc } satisfies LayoutData;
}); });
/** # 类型 Layout 属性 */ /** # 类型 Layout 属性 */
@@ -31,7 +44,7 @@ export function Layout(props: { layout: LayoutData; children: React.ReactNode })
<> <>
<Header menu={props.layout.menu} /> <Header menu={props.layout.menu} />
<main>{props.children}</main> <main>{props.children}</main>
<Footer categories={props.layout.categories} menu={props.layout.footerMenu} /> <Footer categories={props.layout.categories} menu={props.layout.footerMenu} doc={props.layout.footerDoc} />
</> </>
); );
} }
+5
View File
@@ -109,6 +109,11 @@ export async function getHomePage() {
return getPublishedContent("home", "page"); return getPublishedContent("home", "page");
} }
/** # 函数 页脚 Elementor 模板(legacy elementor_library post 58,导入为 type=template) */
export async function getFooterTemplate() {
return getPublishedContent("footer", "template");
}
/** # 类型 产品筛选状态:每个筛选字段已选值列表 */ /** # 类型 产品筛选状态:每个筛选字段已选值列表 */
export type FilterState = Record<string, string[]>; export type FilterState = Record<string, string[]>;
+116 -2
View File
@@ -39,8 +39,46 @@
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.fa-linkedin::before { content: "\f08c"; } .fa-linkedin::before { content: "\f08c"; }
.fa-linkedin-in::before { content: "\f0e1"; }
.fa-youtube::before { content: "\f167"; } .fa-youtube::before { content: "\f167"; }
.fa-facebook::before { content: "\f09a"; } .fa-facebook::before { content: "\f09a"; }
.fa-facebook-f::before { content: "\f39e"; }
.fa-discord::before { content: "\f392"; }
/* # 字体 Font Awesome Solid / Regular(页脚返回顶部图标 fas fa-chevron-up、
* 询价入口 far fa-comment-dots;文件取自 legacy elementor 插件库) */
@font-face {
font-family: "FA Solid";
font-style: normal;
font-weight: 900;
font-display: block;
src: url("/fonts/fa-solid-900.woff2") format("woff2");
}
@font-face {
font-family: "FA Regular";
font-style: normal;
font-weight: 400;
font-display: block;
src: url("/fonts/fa-regular-400.woff2") format("woff2");
}
.fas, .fa-solid {
font-family: "FA Solid";
font-weight: 900;
font-style: normal;
display: inline-block;
line-height: 1;
-webkit-font-smoothing: antialiased;
}
.far, .fa-regular {
font-family: "FA Regular";
font-weight: 400;
font-style: normal;
display: inline-block;
line-height: 1;
-webkit-font-smoothing: antialiased;
}
.fa-chevron-up::before { content: "\f077"; }
.fa-comment-dots::before { content: "\f4ad"; }
/* # 成员变量 Tailwind 主题令牌(与原 website.css 配色一一对应) **/ /* # 成员变量 Tailwind 主题令牌(与原 website.css 配色一一对应) **/
@theme { @theme {
@@ -318,8 +356,14 @@
* .ele-supplier-dif 注入;文字色 text_color 落 span、悬停主色走 per-widget 规则 */ * .ele-supplier-dif 注入;文字色 text_color 落 span、悬停主色走 per-widget 规则 */
.elementor-icon-list-items { list-style: none; margin: 0; padding: 0; } .elementor-icon-list-items { list-style: none; margin: 0; padding: 0; }
.elementor-icon-list-items.elementor-inline-items { display: flex; flex-wrap: wrap; margin: 0 -8px; } .elementor-icon-list-items.elementor-inline-items { display: flex; flex-wrap: wrap; margin: 0 -8px; }
.elementor-icon-list-item { display: flex; align-items: center; margin: 0 8px; } .elementor-icon-list-item { display: flex; align-items: center; }
.elementor-icon-list-items.elementor-inline-items > .elementor-icon-list-item { margin: 0 8px; }
.elementor-icon-list-item a { display: flex; align-items: center; text-decoration: none; } .elementor-icon-list-item a { display: flex; align-items: center; text-decoration: none; }
/* 图标盒:line-height 0 避免行盒把 li 撑高(页脚社交 20px 图标、图标与文字横排) */
.elementor-icon-list-icon { display: inline-flex; align-items: center; line-height: 0; }
.elementor-icon-list-icon i { font-style: normal; width: 1.25em; text-align: center; }
/* 图标与文字间距(widget-icon-list.min.css):文字左内边距 5px,图标行总宽 30px @20px */
.elementor-icon-list-icon + .elementor-icon-list-text { align-self: center; padding-inline-start: 5px; }
/* ===== 首页 H12 adv-tabs 生成规则(post-18.css 对应块;1436 实测 2026-11-01) /* ===== 首页 H12 adv-tabs 生成规则(post-18.css 对应块;1436 实测 2026-11-01)
* li 背景 #F4F5F8 + 文字 #333 + 底边 3px(默认同背景色、active/hover 变 #ED6C00,故 li 高 = 24+16*2+3); * li 背景 #F4F5F8 + 文字 #333 + 底边 3px(默认同背景色、active/hover 变 #ED6C00,故 li 高 = 24+16*2+3);
@@ -339,7 +383,7 @@
* 楼体大图 = 列 bf3d0ba 背景(bottom left + 100% auto,组件内联输出,post-18 生成规则 * 楼体大图 = 列 bf3d0ba 背景(bottom left + 100% auto,组件内联输出,post-18 生成规则
* background_size initial → 100% auto);icon-list 悬停主色(globals text_color_hover); * background_size initial → 100% auto);icon-list 悬停主色(globals text_color_hover);
* 描边盒/按钮/列 padding 均设置驱动或 custom_css 注入,无额外规则 */ * 描边盒/按钮/列 padding 均设置驱动或 custom_css 注入,无额外规则 */
[data-el="002494b"] .elementor-icon-list-item a:hover .elementor-icon-list-text { color: #ED6C00; } [data-el="002494b"] .elementor-icon-list-item:hover { --el-il-text: #ED6C00; }
/* ===== 首页 H13.5 counter 统计条(post-18.css 对应块;1436 实测 2026-11-01) /* ===== 首页 H13.5 counter 统计条(post-18.css 对应块;1436 实测 2026-11-01)
* 六列 16% 图标卡:列 padding 20px 16px 16px 36px + 边框 1px 0 1px 1px #E0E5F9 * 六列 16% 图标卡:列 padding 20px 16px 16px 36px + 边框 1px 0 1px 1px #E0E5F9
@@ -458,6 +502,9 @@
/* ===== Elementor hover 动画(e-animation-shrink.min.css):hover 缩至 90%,0.3s 过渡 */ /* ===== Elementor hover 动画(e-animation-shrink.min.css):hover 缩至 90%,0.3s 过渡 */
/* heading 标题链接颜色继承(原站不受全局品牌橙影响,实测 #333/#000) */ /* heading 标题链接颜色继承(原站不受全局品牌橙影响,实测 #333/#000) */
.el-heading-title a { color: inherit; } .el-heading-title a { color: inherit; }
/* Elementor 基础规则 .elementor-heading-title{margin:0}(页脚 Policy 为 <p>,
* 不加则 UA 段落下边距把列表推低 30px) */
.el-heading-title { margin: 0; }
/* kit 全局链接排版(post-15.css .elementor-kit-15 a):链接默认 Roboto 400。 /* kit 全局链接排版(post-15.css .elementor-kit-15 a):链接默认 Roboto 400。
* 取证:tab 卡片 .card 为 <a>,kit 字体使其内部 ul/li 用 Roboto 渲染(文字 273.6px), * 取证:tab 卡片 .card 为 <a>,kit 字体使其内部 ul/li 用 Roboto 渲染(文字 273.6px),
* 否则被卡片内嵌样式的 Arial 截住(261.9px),第 4 条规格多折一行(H9 body 高 +21.6)。 * 否则被卡片内嵌样式的 Arial 截住(261.9px),第 4 条规格多折一行(H9 body 高 +21.6)。
@@ -666,8 +713,75 @@
[data-el="fd97340"] .elementor-tab-title:after, [data-el="fd97340"] .elementor-tab-title:after,
[data-el="fd97340"] .elementor-tab-content { border-width: 0; } [data-el="fd97340"] .elementor-tab-content { border-width: 0; }
[data-el="fd97340"] .elementor-tab-title.elementor-active { color: #ED6C00; } [data-el="fd97340"] .elementor-tab-title.elementor-active { color: #ED6C00; }
/* ===== 页脚 Elementor 模板 post 58(线上 1436 实测 + post-58.css,2026-11-02)
* 结构:46d7afa 四列链接(桌面) / d834160(html_tag=footer) 返回顶部 + 分隔线 +
* 28c7f0b8 品牌区三列 + a428bb4 版权条。以下为「仅存生成 CSS」与 DOM 语义补丁 */
.elementor-location-footer a { text-decoration: none; font-weight: inherit; }
/* logo:设置驱动 img width 57%(内联),a 收缩为内容宽 → 254×75 的 57% = 144.8×42.7 */
[data-el="211f98c"] > a { display: inline-block; }
[data-el="211f98c"] img { height: auto; }
/* 简介段落:原站 p 下外边距 0.9rem(实测 14.4px;三行文字高 77.4) */
[data-el="42365c0"] p { margin: 0 0 0.9rem; }
/* 链接列悬停文字变 secondary #FFA11B(post-58.css 生成规则;变量覆盖内联色) */
[data-el="d5fc3c1"] .elementor-icon-list-item:hover,
[data-el="cb239ea"] .elementor-icon-list-item:hover,
[data-el="115705e"] .elementor-icon-list-item:hover,
[data-el="f517f88"] .elementor-icon-list-item:hover,
[data-el="9cf608f"] .elementor-icon-list-item:hover,
[data-el="6ef07ee0"] .elementor-icon-list-item:hover,
[data-el="6c850ad1"] .elementor-icon-list-item:hover { --el-il-text: #FFA11B; }
/* 社交图标悬停变 secondary(白色 → #FFA11B) */
[data-el="405d251"] .elementor-icon-list-item:hover { --el-il-icon: #FFA11B; }
/* 返回顶部圆钮:悬停背景 primary #ED6C00(内联底色需 !important 覆盖) */
[data-el="5ffe573"] .elementor-icon:hover { background-color: #ED6C00 !important; color: #ffffff; }
/* gtranslate 语言下拉(线上 select.gt_selector 202×39、白字、#3E3E3F 底、1px #666,实测) */
[data-el="2481635"] select.gt_selector {
width: 202px; height: 39px; padding: 8px 16px; font-size: 16px; line-height: 1.2;
color: #ffffff; background-color: #3E3E3F; border: 1px solid #666666; border-radius: 0;
}
@media (max-width: 767px) {
[data-el="2481635"] select.gt_selector { width: auto; max-width: 100%; }
}
/* 版权条两列:左列 populated 宽 auto(margin-right 2 扣除语义,实测 568 而非 570) */
[data-el="89e79b8"] > div { width: auto !important; }
/* icon widget 字形盒:Elementor .elementor-icon i 与 svg 同宽 1em(24px → 盒 48×48) */
.elementor-icon i { width: 1em; text-align: center; }
/* 版权条文字:白 12px 100(设置驱动),链接同为白色(无橙色) */
[data-el="a428bb4"] a { color: #ffffff; }
/* 移动端折叠菜单([maxmegamenu location=menu-2],桌面/平板由 elementor-hidden-* 隐藏) */
.mega-menu-wrap .mega-menu-toggle { display: flex; justify-content: center; align-items: center; min-height: 40px; }
.mega-menu-wrap .mega-toggle-animated-box {
position: relative; display: inline-block; width: 40px; height: 26px;
cursor: pointer; background: transparent; border: 0;
}
.mega-menu-wrap .mega-toggle-animated-inner,
.mega-menu-wrap .mega-toggle-animated-inner::before,
.mega-menu-wrap .mega-toggle-animated-inner::after {
position: absolute; left: 4px; right: 4px; height: 2px; background: #ffffff; content: "";
transition: transform .2s ease;
}
.mega-menu-wrap .mega-toggle-animated-inner { top: 12px; }
.mega-menu-wrap .mega-toggle-animated-inner::before { top: -8px; }
.mega-menu-wrap .mega-toggle-animated-inner::after { top: 8px; }
.mega-menu-wrap ul.mega-menu { list-style: none; margin: 0; padding: 0; }
.mega-menu-wrap ul.mega-menu li { border-bottom: 1px solid rgba(255, 255, 255, 0.12); }
.mega-menu-wrap .mega-menu-link { display: block; padding: 10px 0; color: #D9D9D9; font-size: 14px; }
.mega-menu-wrap .mega-sub-menu { padding-left: 16px; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.ele-ban-title div { font-size: 30px; } .ele-ban-title div { font-size: 30px; }
} }
/* # 成员变量 Elementor 响应式可见性(hide_desktop/tablet/mobile → 对应类)
* 断点同 Elementor:desktop >1024、tablet 768–1024、mobile ≤767(页脚移动菜单/隐藏节) **/
@media (min-width: 1025px) {
.elementor-hidden-desktop { display: none !important; }
}
@media (min-width: 768px) and (max-width: 1024px) {
.elementor-hidden-tablet { display: none !important; }
}
@media (max-width: 767px) {
.elementor-hidden-mobile { display: none !important; }
}