v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* # 模块 后台共享工具
* loader 权限兜底、未登录占位、状态标签配色、分页配置、空状态与日期格式化。
*/
import { Empty, Tag } from "antd";
import { Database, Lock } from "lucide-react";
import type { ReactNode } from "react";
/** # 类型 递归 JSON 值(用于把数据库 jsonb 的 unknown 收窄为可序列化类型) **/
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
/** # 类型 后台列表页 URL 查询参数(全部可选,避免 Link 强制携带 search) **/
export interface AdminSearch {
/** # 成员变量 页码 **/
page?: number;
/** # 成员变量 关键字 **/
q?: string;
/** # 成员变量 状态过滤 **/
status?: string;
/** # 成员变量 类型过滤(内容页用) **/
type?: string;
}
/** # 类型 后台列表统一返回结构 **/
export interface Paged<T> {
/** # 成员变量 当页数据 **/
items: T[];
/** # 成员变量 总条数 **/
total: number;
/** # 成员变量 当前页码 **/
page: number;
/** # 成员变量 每页条数 **/
pageSize: number;
}
/** # 函数 loader 权限兜底包装
* 服务端函数抛出 FORBIDDEN(未登录/无权限)时返回 null,由页面渲染登录提示;其余错误照常抛出。
* 参数 fn:待执行的异步取数函数
* 返回值 数据或 null
*/
export async function guardAdmin<T>(fn: () => Promise<T>): Promise<T | null> {
try {
return await fn();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/forbidden/i.test(msg)) return null;
throw e;
}
}
/** # 函数 未登录 / 无权限占位组件
* 各后台页面在 loader 返回 null(401)时渲染。
*/
export function Forbidden() {
return (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 80 }}>
<Empty
image={<Lock size={48} aria-hidden color="#bfbfbf" />}
imageStyle={{ height: 64 }}
description="未登录或权限不足,请使用管理员账号登录后台"
/>
</div>
);
}
/** # 函数 表格空状态(SVG 图标,禁用默认灰图) */
export function AdminEmpty() {
return <Empty image={<Database size={40} aria-hidden color="#bfbfbf" />} imageStyle={{ height: 48 }} description="暂无数据" />;
}
/** # 函数 表格通用分页配置
* 参数 total:总条数;page:当前页;pageSize:每页条数;onChange:翻页回调
* 返回值 AntD Table pagination 配置对象
*/
export function tablePagination(total: number, page: number, pageSize: number, onChange: (p: number) => void) {
return {
total,
current: page,
pageSize,
showSizeChanger: false,
showTotal: (t: number) => `共 ${t} 条`,
onChange,
};
}
/** # 函数 日期时间格式化(兼容 ISO 字符串与 Date)
* 参数 v:时间值
* 返回值 YYYY-MM-DD HH:mm 或空串
*/
export function fmtDateTime(v: string | Date | null | undefined): string {
if (!v) return "";
const d = typeof v === "string" ? new Date(v) : v;
if (Number.isNaN(d.getTime())) return String(v);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** # 成员变量 内容状态配色(draft/review/published) **/
export const CONTENT_STATUS_COLOR: Record<string, string> = { draft: "default", review: "gold", published: "green" };
/** # 成员变量 内容类型中文标签(page/post/landing) **/
export const CONTENT_TYPE_LABEL: Record<string, string> = { page: "页面", post: "文章", landing: "落地页" };
/** # 成员变量 订单状态中文标签 **/
export const ORDER_STATUS_LABEL: Record<string, string> = {
pending: "待支付",
"on-hold": "挂起",
processing: "处理中",
completed: "已完成",
cancelled: "已取消",
refunded: "已退款",
};
/** # 成员变量 订单状态标签配色 **/
export const ORDER_STATUS_COLOR: Record<string, string> = {
pending: "orange",
"on-hold": "gold",
processing: "blue",
completed: "green",
cancelled: "red",
refunded: "purple",
};
/** # 成员变量 询盘状态标签配色(new/read/replied/spam/archived) **/
export const INQUIRY_STATUS_COLOR: Record<string, string> = {
new: "blue",
read: "default",
replied: "green",
spam: "red",
archived: "default",
};
/** # 成员变量 询盘状态中文标签 **/
export const INQUIRY_STATUS_LABEL: Record<string, string> = {
new: "新询盘",
read: "已读",
replied: "已回复",
spam: "垃圾",
archived: "归档",
};
/** # 成员变量 订单状态下拉选项 **/
export const ORDER_STATUS_OPTIONS = (["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({
value: s,
label: ORDER_STATUS_LABEL[s] ?? s,
}));
/** # 成员变量 内容状态下拉选项 **/
export const CONTENT_STATUS_OPTIONS = (["draft", "review", "published"] as const).map((s) => ({ value: s, label: s }));
/** # 函数 通用状态标签渲染(未知状态兜底灰色) */
export function StatusTag({ value, colorMap, labelMap }: { value: string; colorMap: Record<string, string>; labelMap?: Record<string, string> }) {
return <Tag color={colorMap[value] ?? "default"}>{labelMap?.[value] ?? value}</Tag>;
}
/** # 函数 页面标题条(子页面统一的标题 + 右侧操作区) */
export function PageHeader({ title, extra }: { title: ReactNode; extra?: ReactNode }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
<div style={{ display: "flex", gap: 8 }}>{extra}</div>
</div>
);
}
+981
View File
@@ -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}</>;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* # 模块 Elementor 文档瘦身
* WordPress 导入的 Elementor JSON 包含大量桌面/平板/移动端响应式字段与空值,
* 整份文档会随 TanStack Router 脱水序列化进 SSR HTML(首页曾达 391KB),
* 严重影响 SEO 与首字节体积。本模块只保留渲染器实际读取的字段。
*/
/** # 成员变量 渲染器(Renderer.tsx)实际读取的 settings 字段白名单 **/
const SETTING_KEYS = new Set([
// 通用布局/背景/边框
"__globals__",
"align",
"background_background",
"background_color",
"background_color_b",
"background_color_stop_b",
"background_image",
"border_border",
"border_color",
"border_radius",
"border_width",
"content_width",
"custom_height",
"padding",
"layout",
"height",
"size",
"space",
"color",
"text_color",
"title_color",
"button_text_color",
// image-box 系列:图定位(position 默认 top)与标题标签(title_size 默认 h3)
"position",
"title_size",
"gradient_angle",
"color_a",
"color_b",
"header_size",
// 列结构
"_column_size",
"_inline_size",
// 各 widget 数据
"heading_title",
"title",
"title_text",
"description",
"description_text",
"selected_icon",
"url",
"width",
"text",
"editor",
"html",
"source_code",
"image",
"link",
"prefix",
"suffix",
"ending_number",
"icon_list",
"social_icon_list",
"slides",
"slides_height",
"tabs",
"video_url",
"youtube_url",
"map",
"posts_per_page",
"cards_posts_per_page",
"post_type",
"eael_ticker_tag_text",
// EAEL Infobox / flip-box / 高级标签页 / 数据表 / 评价等(Renderer 对应组件读取)
"custom_css",
"_css_classes",
"eael_infobox_title",
"eael_infobox_text",
"eael_infobox_title_tag",
"title_text_a",
"description_text_a",
"button_text",
"background_a_image",
"eael_adv_tabs_tab",
"eael_data_table_header_cols_data",
"eael_data_table_content_rows",
"eael_testimonial_name",
"eael_testimonial_company_title",
"eael_testimonial_description",
]);
/** # 函数 收窄对象型 settings 值(渲染器只读 url/alt/target 等少数字段)
* 参数 key settings 键名;val 原始值
* 返回值 收窄后的值;非命中键原样返回
*/
function slimSettingValue(key: string, val: unknown): unknown {
if (!val || typeof val !== "object" || Array.isArray(val)) return val;
const v = val as Record<string, unknown>;
if (key === "image" || key === "background_image" || key === "background_a_image") {
// 渲染器只读 url / alt
return { url: v.url, alt: v.alt };
}
if (key === "link") {
// 渲染器只读 url / target / is_external(is_external=on → target=_blank,与原站 link_attributes 一致)
return { url: v.url, target: v.target, is_external: v.is_external };
}
return val;
}
/** # 函数 递归瘦身单个 Elementor 元素
* 参数 el 原始元素(section/column/widget 通用结构)
* 返回值 仅含 id/elType/widgetType/isInner/白名单 settings/elements 的新对象
*/
function slimElement(el: unknown): unknown {
if (!el || typeof el !== "object") return el;
const src = el as Record<string, unknown>;
const out: Record<string, unknown> = {};
if (src.id != null) out.id = src.id;
if (src.elType != null) out.elType = src.elType;
if (src.widgetType != null) out.widgetType = src.widgetType;
if (src.isInner) out.isInner = true;
const st = src.settings;
if (st && typeof st === "object" && !Array.isArray(st)) {
const slim: Record<string, unknown> = {};
for (const key of Object.keys(st as Record<string, unknown>)) {
if (SETTING_KEYS.has(key)) slim[key] = slimSettingValue(key, (st as Record<string, unknown>)[key]);
}
if (Object.keys(slim).length > 0) out.settings = slim;
}
if (Array.isArray(src.elements) && src.elements.length > 0) {
out.elements = src.elements.map(slimElement);
}
return out;
}
/** # 函数 瘦身整份 Elementor 文档(数组或 {elements:[]} 包装均支持)
* 参数 doc 原始文档(来自 meta.elementor)
* 返回值 瘦身后的文档;非对象/数组输入原样返回
*/
export function slimElementorDoc(doc: unknown): unknown {
if (Array.isArray(doc)) return doc.map(slimElement);
if (doc && typeof doc === "object" && Array.isArray((doc as Record<string, unknown>).elements)) {
const wrapped = doc as Record<string, unknown>;
return { elements: (wrapped.elements as unknown[]).map(slimElement) };
}
return doc;
}
/** # 函数 瘦身内容 meta:替换 meta.elementor 为瘦身版(原 meta 其余字段保留)
* 参数 meta 原始 meta JSON(可能为 null)
* 返回值 elementor 已瘦身的 meta;无 elementor 时原样返回
*/
export function slimMetaElementor<T>(meta: T): T {
if (!meta || typeof meta !== "object") return meta;
const m = meta as Record<string, unknown>;
if (!m.elementor) return meta;
return { ...m, elementor: slimElementorDoc(m.elementor) } as T;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* # 组件 shadcn/ui Accordion(Radix)
* 对齐原 .el-accordion:浅底标题、line 色分隔、6px 圆角。
*/
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "~/lib/utils";
/** # 组件 手风琴容器 */
function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root className={cn("overflow-hidden rounded-md border border-line", className)} {...props} />;
}
/** # 组件 手风琴项 */
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return <AccordionPrimitive.Item className={cn("border-b border-line last:border-b-0", className)} {...props} />;
}
/** # 组件 手风琴标题(浅底、600 字重) */
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={cn(
"flex flex-1 cursor-pointer items-center justify-between bg-soft px-4 py-3 text-left text-[15px] font-semibold text-ink transition-colors hover:text-brand",
className,
)}
{...props}
>
{children}
<ChevronDown size={16} aria-hidden className="shrink-0 text-muted-foreground transition-transform duration-200 [[data-state=open]>&]:rotate-180" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
/** # 组件 手风琴内容 */
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content className="overflow-hidden text-sm" {...props}>
<div className={cn("px-4 py-3", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+32
View File
@@ -0,0 +1,32 @@
/**
* # 组件 shadcn/ui Badge
* 状态徽标:库存/订单/支付状态等;色板对齐原 .badge ok/warn。
*/
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
/** # 成员变量 徽标变体样式 **/
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-line bg-soft text-muted-foreground",
outline: "border-line bg-white text-muted-foreground",
success: "border-[#b5e3c4] bg-[#e7f6ec] text-[#15803d]",
warning: "border-[#f2ddb0] bg-[#fef7e6] text-[#92600a]",
danger: "border-[#f3c1c1] bg-[#fdeaea] text-[#b91c1c]",
brand: "border-brand bg-brand text-white",
},
},
defaultVariants: { variant: "default" },
},
);
/** # 类型 Badge 属性 **/
function Badge({ className, variant, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
/**
* # 组件 shadcn/ui Breadcrumb
* 对齐原 .breadcrumb:13px 灰蓝、分隔符浅色。
*/
import * as React from "react";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { cn } from "~/lib/utils";
/** # 组件 面包屑容器 */
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="Breadcrumb" className={cn("py-3.5 text-[13px] text-muted-foreground", className)} {...props} />;
}
/** # 组件 面包屑列表 */
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return <ol className={cn("flex flex-wrap items-center gap-[7px]", className)} {...props} />;
}
/** # 组件 面包屑项 */
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li className={cn("inline-flex items-center gap-[7px]", className)} {...props} />;
}
/** # 组件 面包屑链接(仅用于无路由参数的固定路径;带参数的链接请直接用 Link) */
function BreadcrumbLink({ className, ...props }: React.ComponentProps<typeof Link>) {
return <Link className={cn("text-muted-foreground hover:text-brand", className)} {...props} />;
}
/** # 组件 面包屑分隔符 */
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"span">) {
return (
<span role="presentation" aria-hidden="true" className={cn("text-[#b6c2cf]", className)} {...props}>
{children ?? <ChevronRight size={12} aria-hidden />}
</span>
);
}
/** # 组件 面包屑当前页 */
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return <span aria-current="page" className={cn("text-muted-foreground", className)} {...props} />;
}
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage };
+44
View File
@@ -0,0 +1,44 @@
/**
* # 组件 shadcn/ui Button
* 变体与尺寸对齐原模板 .btn 体系:主色橙、4px 圆角、600 字重。
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
/** # 成员变量 按钮变体样式(primary/outline/ghost/link 对齐原 .btn-*) **/
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[4px] text-sm font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-60 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-brand text-white hover:bg-brand-dark",
outline: "border border-brand bg-white text-brand hover:bg-soft",
ghost: "text-ink hover:bg-soft",
link: "text-brand underline-offset-4 hover:underline",
},
size: {
default: "px-[22px] py-[10px]",
sm: "px-4 py-2 text-[13px]",
lg: "px-8 py-3.5 text-[15px]",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
/** # 类型 Button 属性(支持 asChild 渲染为链接) **/
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
export { Button, buttonVariants };
+18
View File
@@ -0,0 +1,18 @@
/**
* # 组件 shadcn/ui Card
* 对齐原 .card:8px 圆角、line 色边框;悬停抬升效果由使用方按需追加。
*/
import * as React from "react";
import { cn } from "~/lib/utils";
/** # 组件 卡片容器 */
function Card({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("overflow-hidden rounded-lg border border-line bg-white text-ink", className)} {...props} />;
}
/** # 组件 卡片内容区(原 .card-body:上下 14/18、左右 16) */
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("px-4 pt-3.5 pb-[18px]", className)} {...props} />;
}
export { Card, CardContent };
+24
View File
@@ -0,0 +1,24 @@
/**
* # 组件 shadcn/ui Input
* 对齐原表单输入:4px 圆角、line 色 1px 边框、聚焦变品牌橙。
*/
import * as React from "react";
import { cn } from "~/lib/utils";
/** # 组件 文本输入框 */
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
className={cn(
"flex h-[38px] w-full rounded-[4px] border border-line bg-white px-3 py-1 text-sm text-ink transition-colors",
"placeholder:text-muted-foreground/60 focus-visible:border-brand focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
{...props}
/>
);
}
export { Input };
+13
View File
@@ -0,0 +1,13 @@
/**
* # 组件 shadcn/ui Label
* 表单字段标签。
*/
import * as React from "react";
import { cn } from "~/lib/utils";
/** # 组件 标签 */
function Label({ className, ...props }: React.ComponentProps<"label">) {
return <label className={cn("flex flex-col gap-[5px] text-sm font-medium text-ink", className)} {...props} />;
}
export { Label };
+109
View File
@@ -0,0 +1,109 @@
/**
* # 组件 shadcn/ui NavigationMenu
* 基于 @radix-ui/react-navigation-menu,用于顶部主导航(含巨幕面板)。
* 样式对齐原站 Max Mega Menu(megamenu 内联 CSS 实测值):
* - 顶级项:高 68px、16px/400/#222,hover/展开仅文字变橙(背景不变);
* 指示器为实心三角 ▾(dashicons arrow-down 等价物),展开不旋转、颜色随文字。
* - 巨幕/飞出面板:#fcfbfa 底 + 顶部 2px #f5b279 橙线 + 0 10px 30px 阴影,直角。
* 切换动效保留 shadcn/ui(tw-animate-css 的 fade/zoom/slide + Radix motion 属性)。
*/
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cn } from "~/lib/utils";
/** # 组件 导航菜单根容器 */
function NavigationMenu({ className, children, viewportPosition = "inline", ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { viewportPosition?: "inline" | "full" }) {
return (
<NavigationMenuPrimitive.Root data-viewport={viewportPosition} className={cn("relative z-[90] flex max-w-max items-center justify-center", className)} {...props}>
{children}
{viewportPosition === "full" ? <ViewportFullWidth /> : <ViewportCentered />}
</NavigationMenuPrimitive.Root>
);
}
/** # 组件 导航菜单列表(顶级项容器)
* p-0:本项目不引入 Tailwind preflight,需重置 UL 的浏览器默认左内边距。
*/
function NavigationMenuList({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return <NavigationMenuPrimitive.List className={cn("m-0 flex flex-1 list-none items-center justify-center gap-0.5 p-0", className)} {...props} />;
}
/** # 组件 导航菜单项 */
function NavigationMenuItem({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return <NavigationMenuPrimitive.Item className={cn("relative", className)} {...props} />;
}
/** # 类型 NavigationMenuTrigger 属性 */
const triggerStyle =
"inline-flex h-[68px] w-max cursor-pointer items-center justify-center gap-[6px] rounded-none border-0 bg-transparent py-0 pr-[6px] pl-2 text-[16px] font-normal whitespace-nowrap text-[#222] transition-colors hover:text-brand focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-brand";
/** # 组件 导航菜单触发器(顶级可展开项)
* chevron=false 时子元素原样透传(asChild 场景:Radix Slot 要求 children 是
* 单个 React 元素,不能被 JSX 双槽包装成数组)。
*/
function NavigationMenuTrigger({ className, children, chevron = true, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger> & { chevron?: boolean }) {
const content = chevron ? (
<>
{children}
<CaretDown />
</>
) : (
children
);
return (
<NavigationMenuPrimitive.Trigger className={cn(triggerStyle, "group", className)} {...props}>
{content}
</NavigationMenuPrimitive.Trigger>
);
}
/** # 组件 导航菜单内容面板(下拉/巨幕内容)
* data-[motion] 动画类依赖 tw-animate-css(app.css 已引入)。
*/
function NavigationMenuContent({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return <NavigationMenuPrimitive.Content className={cn("data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion=from-end]:fade-in data-[motion=from-start]:fade-in data-[motion=to-end]:fade-out data-[motion=to-start]:fade-out", className)} {...props} />;
}
/** # 组件 导航菜单链接(无下级的普通链接项) */
function NavigationMenuLink({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return <NavigationMenuPrimitive.Link className={cn(triggerStyle, className)} {...props} />;
}
/** # 组件 巨幕面板标题(面板内小标题/副标题) */
function NavigationMenuHeading({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("text-[16px] leading-relaxed font-bold text-[#222]", className)} {...props} />;
}
/** # 组件 实心下三角指示器(等价原站 dashicons arrow-down,展开不旋转,颜色随文字) */
function CaretDown() {
return (
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="shrink-0">
<path d="M4.5 7h11L10 13.5 4.5 7Z" />
</svg>
);
}
/** # 组件 居中式视口(小尺寸下拉用)
* 面板 chrome 对齐原站 flyout:#fcfbfa 底 + 顶部 2px 橙线 + 直角 + 大阴影。
*/
function ViewportCentered() {
return (
<div className="absolute top-full left-0 flex w-max justify-center">
<NavigationMenuPrimitive.Viewport className="relative mt-0 h-[var(--radix-navigation-menu-viewport-height)] w-[var(--radix-navigation-menu-viewport-width)] origin-top overflow-hidden rounded-none border-t-2 border-panel-line bg-panel shadow-[0_10px_30px_rgba(13,21,46,0.1)] transition-[width,height] duration-300 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-95" />
</div>
);
}
/** # 组件 全宽视口(巨幕面板用:固定在导航条下方、水平居中)
* 导航条(topbar 40px + 主导航 68px)sticky 置顶,故固定定位 top-108px 与其底边对齐;
* 面板宽度按原站实测 1340px(1436 视口),窄视口收窄到留 16px 边距。
*/
function ViewportFullWidth() {
return (
<div className="fixed inset-x-0 top-[108px] z-[90] flex justify-center">
<NavigationMenuPrimitive.Viewport className="relative h-[var(--radix-navigation-menu-viewport-height)] w-[calc(100%-32px)] max-w-[1340px] origin-top overflow-visible rounded-none border-t-2 border-panel-line bg-panel shadow-[0_10px_30px_rgba(13,21,46,0.1)] transition-[height] duration-300 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:pointer-events-none data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2" />
</div>
);
}
export { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuHeading, CaretDown };
+53
View File
@@ -0,0 +1,53 @@
/**
* # 组件 shadcn/ui Pagination(链接型)
* 对齐原 .pagination:36px 最小宽方框、当前页品牌橙填充。
*/
import * as React from "react";
import { Link } from "@tanstack/react-router";
import { cn } from "~/lib/utils";
/** # 组件 分页容器 */
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="Pagination" className={cn("mx-0 my-8 flex flex-wrap justify-center gap-1.5", className)} {...props} />;
}
/** # 组件 分页内容列表 */
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return <ul className={cn("flex flex-wrap items-center gap-1.5", className)} {...props} />;
}
/** # 组件 分页项 */
function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
return <li className={cn("", className)} {...props} />;
}
/** # 类型 分页链接属性(href 由调用方生成,isActive 标记当前页) **/
function PaginationLink({ className, isActive, disabled, children, ...props }: Omit<React.ComponentProps<typeof Link>, "children"> & { isActive?: boolean; disabled?: boolean; children?: React.ReactNode }) {
if (disabled) {
return <span aria-disabled className={cn(baseLink, "pointer-events-none text-[#b6c2cf]", className)}>{children}</span>;
}
return (
<Link
aria-current={isActive ? "page" : undefined}
className={cn(baseLink, isActive && "border-brand bg-brand text-white", className)}
{...props}
>
{children}
</Link>
);
}
/** # 成员变量 分页链接基础样式 **/
const baseLink =
"inline-flex h-9 min-w-9 items-center justify-center rounded-[4px] border border-line px-2.5 text-sm text-ink hover:border-brand hover:text-brand";
/** # 组件 分页省略号 */
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span aria-hidden className={cn("pointer-events-none inline-flex h-9 min-w-9 items-center justify-center text-[#b6c2cf]", className)} {...props}>
…
</span>
);
}
export { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationEllipsis };
+78
View File
@@ -0,0 +1,78 @@
/**
* # 组件 shadcn/ui Select(Radix)
* 用于页脚语言切换等下拉场景;触发器/面板样式可通过 className 覆写。
*/
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "~/lib/utils";
/** # 组件 Select 根 */
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root {...props} />;
}
/** # 组件 Select 触发器 */
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-[38px] w-full items-center justify-between rounded-[4px] border border-line bg-white px-3 py-2 text-sm text-ink transition-colors",
"focus:border-brand focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 [&>span]:truncate",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown size={16} aria-hidden className="shrink-0 opacity-60" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
/** # 组件 Select 选中值 */
function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value {...props} />;
}
/** # 组件 Select 下拉面板 */
function SelectContent({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
className={cn(
"z-[120] max-h-72 min-w-[8rem] overflow-hidden rounded-md border border-line bg-white text-sm text-ink shadow-[0_8px_24px_rgba(16,42,76,0.12)]",
className,
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
/** # 组件 Select 选项 */
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"flex cursor-pointer items-center rounded-[4px] py-1.5 pl-8 pr-3 text-sm outline-none select-none",
"focus:bg-soft data-[state=checked]:font-semibold data-[state=checked]:text-brand",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check size={14} aria-hidden />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem };
+38
View File
@@ -0,0 +1,38 @@
/**
* # 组件 shadcn/ui Tabs(Radix)
* 对齐原 .el-tabs:底部 2px 线、激活项品牌橙下划线。
*/
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/lib/utils";
/** # 组件 Tabs 容器 */
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root className={cn("", className)} {...props} />;
}
/** # 组件 标签栏 */
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn("mb-[-2px] flex flex-wrap gap-1 border-b-2 border-line", className)} {...props} />;
}
/** # 组件 标签触发器 */
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"cursor-pointer border-0 border-b-2 border-transparent bg-none px-4 py-2.5 text-[15px] font-semibold text-muted-foreground transition-colors",
"hover:text-ink data-[state=active]:border-brand data-[state=active]:text-brand",
className,
)}
{...props}
/>
);
}
/** # 组件 标签内容 */
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn("pt-3.5 focus-visible:outline-none", className)} {...props} />;
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
+23
View File
@@ -0,0 +1,23 @@
/**
* # 组件 shadcn/ui Textarea
* 样式与 Input 一致,多行文本。
*/
import * as React from "react";
import { cn } from "~/lib/utils";
/** # 组件 多行文本框 */
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
className={cn(
"flex min-h-16 w-full rounded-[4px] border border-line bg-white px-3 py-2 text-sm text-ink transition-colors",
"placeholder:text-muted-foreground/60 focus-visible:border-brand focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
{...props}
/>
);
}
export { Textarea };
+156
View File
@@ -0,0 +1,156 @@
/**
* # 组件 网站页脚 Footer(对齐原站,Tailwind + shadcn/ui 实现)
* 深灰底两段:上段 4 个菜单组;下段左侧品牌区(logo + 简介 + 社交 + 语言下拉),
* 右侧 3 个菜单组;底部版权条。
* 数据来自导入的 footer 菜单(menu-2);为空时降级为内置链接。
*/
import { useEffect, useState } from "react";
import { Headset } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { useT } from "~/i18n";
import { LOCALES, getUiLocale, setUiLocale } from "~/i18n/shared";
import type { MenuNode } from "~/server/data";
/** # 类型 页脚产品分类节点(Layout 接口兼容保留) */
export type FooterCategory = { id: number; name: string; slug: string; parentId: number | null; count: number };
/** # 常量 无菜单数据时的降级分组 */
const FALLBACK_GROUPS: MenuNode[] = [
{ id: -1, title: "System on Modules", url: "/collection/", target: "", children: [] },
{ id: -2, title: "ODM/OEM Services", url: "/contact-us/", target: "", children: [] },
{ id: -3, title: "Multiprotocol IoT Gateway", url: "/collection/", target: "", children: [] },
{ id: -4, title: "One-Stop Hardware Solutions", url: "/collection/", target: "", children: [] },
{ id: -5, title: "About Dusun", url: "/about-us/", target: "", children: [{ id: -51, title: "Contact us", url: "/contact-us/", target: "", children: [] }] },
{ id: -6, title: "Developer Support", url: "/blog/", target: "", children: [] },
{ id: -7, title: "POLICY", url: "/about-us/", target: "", children: [] },
];
/** # 组件 页脚 */
export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[] }) {
const t = useT();
const groups = props.menu.length > 0 ? props.menu : FALLBACK_GROUPS;
// 前 4 组为上段列,后 3 组为下段右侧列(与原站 footer 菜单顺序一致)
const topGroups = groups.slice(0, 4);
const bottomGroups = groups.slice(4, 7);
return (
<footer className="mt-14 bg-footer text-[13px] text-[#c9c9ca]">
<div className="site-container">
<div className="grid grid-cols-2 gap-7 pt-[42px] pb-[26px] min-[900px]:grid-cols-4">
{topGroups.map((g) => (
<FooterGroup key={g.id} group={g} />
))}
</div>
<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>
{/* 直接复用原站页脚 logo(同图:wp-content/uploads/2023/01/dusun-iot-logo.png,实测渲染 144.77×42.73px) */}
<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]" />
</a>
<p className="mt-3.5 mb-4 leading-[1.7] text-[#b5b5b6]">{t("footer_intro")}</p>
<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">
<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 />
</a>
</div>
<FooterLangSelect />
</div>
<div className="grid grid-cols-1 gap-7 min-[640px]:grid-cols-3">
{bottomGroups.map((g) => (
<FooterGroup key={g.id} group={g} />
))}
</div>
</div>
</div>
<div className="bg-footer-dark py-3.5 text-center text-xs text-[#a9a9aa]">
<div className="site-container flex justify-center">
<span>
Copyright ©2026 <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>
</span>
</div>
</div>
</footer>
);
}
/** # 组件 页脚语言下拉(选择后写 ui_lang cookie 并刷新,与 Header 切换共用 i18n) */
function FooterLangSelect() {
// 语言值客户端挂载后再读 cookie,避免 SSR 水合不一致
const [lang, setLang] = useState("en");
useEffect(() => setLang(getUiLocale()), []);
return (
<Select value={lang} onValueChange={(v) => v !== lang && setUiLocale(v)}>
<SelectTrigger aria-label="Language selector" className="w-[180px] border-white/25 bg-white/6 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOCALES.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** # 组件 页脚菜单组(橙色标题 + 子链接列表;无子项时仅渲染标题链接) */
function FooterGroup({ group }: { group: MenuNode }) {
return (
<div>
<h4 className="mb-3.5 text-[13px] font-bold tracking-[0.8px] text-brand uppercase">{group.title}</h4>
{group.children.length > 0 ? (
<ul className="m-0 list-none p-0">
{group.children.map((c) => (
<li key={c.id} className="mb-[9px]">
<a href={c.url || "#"} target={c.target === "_blank" ? "_blank" : undefined} className="text-[#c9c9ca] hover:text-white">
{c.title}
</a>
</li>
))}
</ul>
) : group.url && group.url !== "#" ? (
<ul className="m-0 list-none p-0">
<li className="mb-[9px]">
<a href={group.url} className="text-[#c9c9ca] hover:text-white">
{group.title}
</a>
</li>
</ul>
) : null}
</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>
);
}
+525
View File
@@ -0,0 +1,525 @@
/**
* # 组件 网站顶部导航 Header(对齐原站,Tailwind + shadcn/ui 实现)
* 结构:深灰 topbar(口号 + 社交/登录)+ 白底主导航(logo + NavigationMenu +
* 搜索图标 + CONTACT US + 邮件/语言图标)。移动端汉堡菜单(纯 React state)。
*
* 桌面导航用 shadcn/ui NavigationMenu(Radix)实现,巨幕面板逐列复刻原站
* Max Mega Menu 布局与样式(对原站 megamenu 内联 CSS + 无头浏览器 computed style
* 的逐项实测结论,2026-10):
* - 顶级条:高 68px、16px/400/#222,hover/展开文字变橙,实心三角指示器
* - Product:4 列巨幕(SoM | 主板+SBC+图 | 灰底 IoT 网关宽列(双子栏+芯片logo) | 其它产品)
* - Services:3 列(图 | ODM/OEM 服务 | 开发者支持),分组头带 #fad3b3 下边线
* - Solutions:4 列分组 + 首列「一站式硬件方案」橙色标题
* - Resources:原站为自定义 HTML 标签面板(博客/新闻/认证表格),本站以资源链接面板呈现
* - Success Stories:普通链接;Company:flyout 下拉列表
* - 面板 chrome:#fcfbfa 底 + 顶部 2px #f5b279 橙线 + 0 10px 30px 阴影,直角
* - 分组头三级样式:橙 18px(星标) / 橙 16px / 深色加粗+#e0e5f9 下边线;叶子 16px/400
* 切换动效为 shadcn/ui(tw-animate-css fade/zoom/slide),与原站视觉分离保留。
* 菜单数据来自 DB max_mega_menu_1(getMenuTree);面板列布局/样式以标题匹配的
* 配置常量描述(menu_items 表不存布局信息,widget 图片不在菜单表内,只能配置化)。
*/
import { useState } from "react";
import { Link } from "@tanstack/react-router";
import { Search, Menu, X, ChevronRight, Mail, User, Star } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuTrigger,
NavigationMenuContent,
CaretDown,
} from "~/components/ui/navigation-menu";
import { LangSwitcher } from "~/components/website/LangSwitcher";
import { useT } from "~/i18n";
import type { MenuNode } from "~/server/data";
/** # 类型 巨幕面板列配置(groups 引用 menu_items 表里的分组标题) */
type MegaCol = {
/** # 成员变量 本列分组标题清单(与 DB 菜单标题一致) **/
groups?: string[];
/** # 成员变量 列顶标题(原站 Solutions 首列「一站式硬件方案」) **/
heading?: string;
/** # 成员变量 列内双子栏分组(原站 IoT 网关灰底宽列:左「By Application/多协议」右「平台支持」) **/
subCols?: string[][];
/** # 成员变量 列底部 widget 图片(原站 media_image widget) **/
image?: { src: string; width: number; height: number; alt: string };
/** # 成员变量 列底部芯片厂商 logo 行(原站 6 个 media_image widget) **/
logos?: boolean;
/** # 成员变量 灰底列(原站实测 #f4f5f8,仅 IoT 网关宽列使用) **/
grey?: boolean;
};
/** # 成员变量 Product 巨幕 4 列布局(列宽比按原站实测 239:253:519:319)
* 注意 menu_items 里 Product 的子项是「平铺的分组」(NXP Serials / By Application /
* Other Products 的子分组均为 Product 直接子级),原站同列多个分组靠 CSS 呈现
* 层级;这里按原站视觉把它们编入同一列。灰底宽列含双子栏与芯片 logo 行。
**/
const PRODUCT_COLS: MegaCol[] = [
{ groups: ["System on Modules >", "NXP Serials", "Rockchip Serials", "MediaTek Serials"] },
{ groups: ["Embedded Mainboard", "Custom Industrial SBCs"], image: { src: "/uploads/2024/08/som-board.png", width: 211, height: 137, alt: "som board" } },
{ grey: true, groups: ["IoT Gateway >"], subCols: [["By Application", "Multi Protocols Support"], ["Platform/Software Support"]], logos: true },
{ groups: ["Other Products", "Remote Control >", "Far-Field Voice >", "BLE Beacon >", "Control Board/IoT Terminal Devices >", "Smart Sensors >"] },
];
/** # 成员变量 Services 巨幕 3 列布局(列宽比按原站实测 230:460:230;分组头带浅橙下边线) **/
const SERVICES_COLS: MegaCol[] = [
{ image: { src: "/uploads/2024/08/odm-oem-service-dusuniot.jpg", width: 234, height: 300, alt: "ODM/OEM Services" } },
{ groups: ["ODM/OEM Services >"] },
{ groups: ["Developer Support"] },
];
/** # 成员变量 Solutions 巨幕 4 列布局(列宽比按原站实测 289:246:278:257,首列带「一站式硬件方案」标题) **/
const SOLUTIONS_COLS: MegaCol[] = [
{ groups: ["Smart Control", "Building Automation"], heading: "One-Stop Hardware Solutions" },
{ groups: ["Healthcare IoT", "Smart Home"] },
{ groups: ["Security & Automation", "Smart Retail >"] },
{ groups: ["Embedded Applications"] },
];
/** # 成员变量 橙色星标分组头(原站 dashicons-star-filled + mega-has-icon) **/
const STAR_GROUPS = new Set(["System on Modules >", "Embedded Mainboard", "IoT Gateway >", "ODM/OEM Services >"]);
/** # 成员变量 橙色 18px 无星分组头(原站对应列标题,实测 computed style) **/
const ORANGE_GROUPS = new Set(["Other Products", "Custom Industrial SBCs", "Developer Support", "Embedded Applications"]);
/** # 成员变量 橙色 16px 小分组头(原站 Other Products 列内嵌套橙色组,实测 16px/700) **/
const ORANGE_SM_GROUPS = new Set(["Control Board/IoT Terminal Devices >", "Smart Sensors >"]);
/** # 成员变量 深色加粗但无下边线的分组头(原站 Smart Retail >,实测无 border-bottom) **/
const DARK_NOLINE_GROUPS = new Set(["Smart Retail >"]);
/** # 成员变量 Resources 面板链接(原站为自定义 HTML 标签面板;此处收录其全部资源入口,本地均已 200) **/
const RESOURCES_LINKS: { title: string; url: string; note: string }[] = [
{ title: "Blog", url: "/blog/", note: "博客归档" },
{ title: "News", url: "/news/", note: "新闻归档" },
{ title: "Gateway Articles", url: "/gateway/", note: "网关技术文章归档" },
{ title: "Certification", url: "/certification/", note: "认证页(原站 Resources 面板标签)" },
{ title: "Hardware News", url: "/resources/hardware-news/", note: "原站 Resources 面板链接" },
{ title: "Software Update & News", url: "/resources/software-updates/", note: "原站 Resources 面板链接" },
{ title: "WiKi Resources", url: "https://wiki.dusuniot.com/", note: "原站 Services 面板同款外链" },
{ title: "Videos", url: "https://www.youtube.com/channel/UCyb4PpqVgvKgC9KpkByZaaQ", note: "原站 Services 面板同款外链" },
];
/** # 类型 Header 属性:菜单树由服务端 loader 传入 */
export function Header(props: { menu: MenuNode[] }) {
const t = useT();
const [open, setOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// 菜单为空时使用内置后备导航(数据导入未完成时优雅降级)
const items: MenuNode[] =
props.menu.length > 0
? props.menu
: [
{ id: -1, title: "Home", url: "/", target: "", children: [] },
{ id: -2, title: "Products", url: "/collection/", target: "", children: [] },
{ id: -3, title: "Blog", url: "/blog/", target: "", children: [] },
{ id: -4, title: "Contact Us", url: "/contact-us/", target: "", children: [] },
];
// 原站顶栏不含「Search」菜单项(搜索是独立图标),过滤掉
const tops = items.filter((it) => it.title !== "Search");
return (
<header className="sticky top-0 z-[100] border-b border-line bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
{/* topbar:口号 + 社交图标 + 登录 */}
<div className="bg-footer text-[13px] text-white">
<div className="site-container flex h-10 items-center justify-between">
<a href="/landing-pages/oem-odm/" className="text-white hover:text-[#f5b278]">
{t("topbar_oem")}
</a>
<div className="flex items-center gap-3.5">
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
<YoutubeGlyph />
</a>
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
<FacebookGlyph />
</a>
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
<LinkedinGlyph />
</a>
<span className="h-3.5 w-px bg-white/35" aria-hidden />
<a href="/my-account/" className="inline-flex items-center gap-1 text-white hover:text-[#f5b278]">
<User size={15} aria-hidden /> {t("login")}
</a>
</div>
</div>
</div>
{/* 主导航(原站实测:白底条高 68px,topbar 40px,合计 108px) */}
<div className="site-container flex h-[68px] items-center gap-6">
{/* 直接复用原站 header logo(Elementor site-logo 同一图片,实测渲染 171×42.75px) */}
<Link to="/" className="shrink-0">
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="h-auto w-[171px]" />
</Link>
<DesktopNav tops={tops} />
{searchOpen ? (
<form action="/search" method="get" className="flex items-center overflow-hidden rounded-full border border-line bg-soft">
<Input type="search" name="q" placeholder={t("search_placeholder")} aria-label="Search" autoFocus className="w-40 rounded-none border-0 bg-transparent focus-visible:border-0" />
<button type="submit" aria-label="Submit search" className="flex cursor-pointer items-center border-0 bg-transparent px-2.5 py-1.5 text-muted-foreground">
<Search size={16} aria-hidden />
</button>
</form>
) : (
<button aria-label="Open search" onClick={() => setSearchOpen(true)} className="flex cursor-pointer items-center border-0 bg-transparent p-1.5 text-ink">
<Search size={19} aria-hidden />
</button>
)}
<Button asChild className="hidden whitespace-nowrap px-[18px] py-[9px] min-[960px]:inline-flex">
<a href="/contact-us/">{t("contact_us")}</a>
</Button>
<a href="/contact-us/" aria-label="Email us" className="hidden items-center p-1 text-brand min-[960px]:flex">
<Mail size={19} aria-hidden />
</a>
<LangSwitcher />
<button aria-label="Toggle menu" onClick={() => setOpen(!open)} className="ml-auto cursor-pointer rounded-[4px] border-0 bg-transparent p-1.5 text-ink min-[960px]:hidden">
{open ? <X size={24} aria-hidden /> : <Menu size={24} aria-hidden />}
</button>
</div>
{/* 移动端菜单 */}
{open ? (
<div className="border-t border-line bg-white px-4 pt-2 pb-4 min-[960px]:hidden">
{tops.map((it) => (
<MobileNavItem key={it.id} node={it} onNavigate={() => setOpen(false)} />
))}
<a href="/contact-us/" onClick={() => setOpen(false)} className="block border-b border-line px-1 py-[9px] font-medium text-ink">
{t("contact_us")}
</a>
<form action="/search" method="get" className="mt-2.5 flex gap-2">
<input type="search" name="q" placeholder={t("search_placeholder_m")} className="min-w-0 flex-1 rounded-[4px] border border-line px-2.5 py-2" />
<Button type="submit" size="sm">
{t("search_go")}
</Button>
</form>
</div>
) : null}
</header>
);
}
/** # 组件 桌面主导航(shadcn/ui NavigationMenu)
* 按原站菜单顺序逐项渲染,每个顶级项一个 NavigationMenu 根(视口形态不同:
* 巨幕项用全宽视口,Company 用居中小视口,Success Stories 是纯链接)。
* 独立根互不干扰,指针移出根范围即收起;根间距用外层 gap 保持一致。
*/
function DesktopNav({ tops }: { tops: MenuNode[] }) {
return (
<div className="hidden min-[960px]:flex min-[960px]:flex-1 min-[960px]:items-center gap-0.5">
{tops.map((node) => {
const linkHref = TOP_URL_FIX[node.title] ?? normalizeUrl(node.url) ?? undefined;
// 巨幕面板项(全宽视口)
if (node.title === "Product" || node.title === "Services" || node.title === "Solutions" || node.title === "Resources") {
return (
<NavigationMenu key={node.id} viewportPosition="full" className="min-[960px]:flex">
<NavigationMenuList className="justify-start">
<NavigationMenuItem>
<TopTrigger title={node.title} href={linkHref} />
<NavigationMenuContent>
{node.title === "Product" ? (
<MegaPanel cols={PRODUCT_COLS} source={node.children} layout="grid-cols-[1fr_1.06fr_2.17fr_1.33fr]" />
) : node.title === "Services" ? (
<MegaPanel cols={SERVICES_COLS} source={node.children} layout="grid-cols-[1fr_2fr_1fr]" headLine />
) : node.title === "Solutions" ? (
<MegaPanel cols={SOLUTIONS_COLS} source={node.children} layout="grid-cols-[1.13fr_0.96fr_1.09fr_1fr]" />
) : (
<ResourcesPanel />
)}
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}
// Company:flyout 简单下拉列表(居中小视口)
if (node.children.length > 0) {
return (
<NavigationMenu key={node.id} className="min-[960px]:flex">
<NavigationMenuList className="justify-start">
<NavigationMenuItem>
<TopTrigger title={node.title} href={linkHref} />
<NavigationMenuContent>
<ul className="w-[240px] list-none p-0 py-2">
{node.children.map((c) => {
const info = linkInfo(TOP_URL_FIX[c.title] ?? c.url);
return info ? (
<li key={c.id} className="list-none">
<a href={info.href} target={info.external ? "_blank" : c.target || undefined} rel={info.external ? "noreferrer" : undefined} className="block px-5 py-[6px] text-[16px] leading-[1.7] text-[#222] hover:text-brand">
{c.title}
</a>
</li>
) : (
<li key={c.id} className="list-none px-5 py-[6px] text-[16px] leading-[1.7] text-muted-foreground">
{c.title}
</li>
);
})}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}
// 无面板的普通链接(Success Stories,样式与顶级触发项一致)
return linkHref ? (
<a key={node.id} href={linkHref} target={node.target || undefined} className="inline-flex h-[68px] w-max items-center rounded-none border-0 bg-transparent py-0 pr-[6px] pl-2 text-[16px] font-normal whitespace-nowrap text-[#222] transition-colors hover:text-brand">
{node.title}
</a>
) : null;
})}
</div>
);
}
/** # 组件 Resources 面板(原站为自定义 HTML 标签面板,此处收录其全部资源入口;链接均已本地 200) */
function ResourcesPanel() {
return (
<div className="grid w-full grid-cols-1 gap-x-10 px-4 py-6 min-[1280px]:grid-cols-2">
{RESOURCES_LINKS.map((r) => (
<a key={r.title} href={r.url} target={/^https?:/.test(r.url) ? "_blank" : undefined} rel="noreferrer" className="flex items-center justify-between gap-4 border-b border-subline py-3 text-[16px] text-[#222] transition-colors hover:text-brand">
<span>{r.title}</span>
<ChevronRight size={16} aria-hidden className="shrink-0 text-brand" />
</a>
))}
</div>
);
}
/** # 成员变量 顶级/子级触发链接修正(DB 存「#」或旧路径,按原站现值修正;均已本地 200) **/
const TOP_URL_FIX: Record<string, string> = {
Product: "/shop/", // 原站 Product 触发链接 → /shop/
Solutions: "/solution/", // 原站 Solutions 触发链接 → /solution/
"Blog >": "/blog/", // Company/Services 里的 Blog 子项(DB 为 #)
"News >": "/news/", // Company 里的 News 子项(DB 为 #)
};
/** # 组件 顶级触发项:有链接时 asChild 渲染 <a>(点击可跳转、样式落在 <a> 上
* 以免被全局 a{color:brand} 染橙),否则纯按钮。指示器为实心三角(原站
* dashicons arrow-down,展开不旋转、颜色随文字变橙)。
*/
function TopTrigger({ title, href }: { title: string; href?: string }) {
if (href) {
return (
<NavigationMenuTrigger asChild chevron={false}>
<a href={href}>
{title}
<CaretDown />
</a>
</NavigationMenuTrigger>
);
}
return <NavigationMenuTrigger>{title}</NavigationMenuTrigger>;
}
/** # 组件 巨幕面板:按列配置从菜单树取分组并渲染
* 参数 cols 列布局配置;source 菜单树(顶级项 children);layout 列宽 grid-template;
* headLine 分组头是否带浅橙下边线(原站实测:Services 面板有 #fad3b3 线,Product/Solutions 无)。
* 样式按原站 megamenu CSS 实测:面板内边距 10px 0 20px 10px;每个分组条目块
* padding 15px(底 0);灰底列 #f4f5f8 通高。
*/
function MegaPanel(props: { cols: MegaCol[]; source: MenuNode[]; layout: string; headLine?: boolean }) {
return (
<div className="w-full pt-2.5 pb-5 pl-2.5">
<div className={`grid grid-cols-1 ${props.layout}`}>
{props.cols.map((col, i) => {
const groups = (col.groups ?? []).map((g) => props.source.find((n) => n.title === g)).filter((n): n is MenuNode => Boolean(n));
const subCols = (col.subCols ?? []).map((titles) => titles.map((g) => props.source.find((n) => n.title === g)).filter((n): n is MenuNode => Boolean(n)));
const body = (
<>
{col.heading ? <div className="mb-3 px-[15px] pt-[15px] text-[18px] font-bold text-brand">{col.heading}</div> : null}
{groups.map((g) => (
<MegaGroup key={g.id} node={g} headLine={props.headLine} />
))}
{subCols.length > 0 ? (
<div className="grid grid-cols-2">
{subCols.map((gs, si) => (
<div key={si}>
{gs.map((g) => (
<MegaGroup key={g.id} node={g} headLine={props.headLine} />
))}
</div>
))}
</div>
) : null}
{col.image ? (
<figure className="mt-4 mb-1 px-[15px]">
<img src={col.image.src} width={col.image.width} height={col.image.height} alt={col.image.alt} loading="lazy" className="h-auto max-w-full" />
</figure>
) : null}
{col.logos ? (
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-3 px-[15px] pb-4 opacity-90">
{["rockchip", "nxp", "mtk", "espressif", "silicon", "nordic"].map((n) => (
<img key={n} src={`/uploads/2023/07/${n}.png`} alt={n} loading="lazy" className="h-[24px] w-auto" />
))}
</div>
) : null}
</>
);
return col.grey ? (
<div key={i} className="-mt-2.5 -mb-5 h-full bg-panel-grey">
{body}
</div>
) : (
<div key={i}>{body}</div>
);
})}
</div>
</div>
);
}
/** # 组件 巨幕分组:分组头(橙 18px 星标 / 橙 16px / 深色加粗+#e0e5f9 下边线)
* + 子项列表(子项再含子级时渲染二级分组)。条目块 padding 15px(底 0)按原站实测。
*/
function MegaGroup({ node, headLine }: { node: MenuNode; headLine?: boolean }) {
const head = linkInfo(node.url);
const star = STAR_GROUPS.has(node.title);
const orangeLg = star || ORANGE_GROUPS.has(node.title);
const orangeSm = ORANGE_SM_GROUPS.has(node.title);
const darkNoLine = DARK_NOLINE_GROUPS.has(node.title);
let headCls: string;
if (orangeLg) headCls = `text-[18px] font-bold text-brand${headLine ? " border-b border-group-line pb-2.5" : ""}`;
else if (orangeSm) headCls = "text-[16px] font-bold text-brand";
else if (darkNoLine) headCls = "text-[16px] font-bold text-[#222]";
else headCls = "border-b border-subline pb-1 text-[16px] font-bold text-[#222]";
const headWrap = `flex items-center gap-1.5 whitespace-nowrap ${orangeLg ? "mb-3" : "mb-2.5"} ${headCls}`;
return (
<section className="px-[15px] pt-[15px]">
{head ? (
<a href={head.href} target={head.external ? "_blank" : node.target || undefined} rel={head.external ? "noreferrer" : undefined} className={headWrap}>
{star ? <Star size={17} aria-hidden className="fill-current" /> : null}
{node.title}
</a>
) : (
<div className={headWrap}>
{star ? <Star size={17} aria-hidden className="fill-current" /> : null}
{node.title}
</div>
)}
<ul className="list-none p-0">
{node.children.map((c) => {
// 二级分组(如 Remote Control → 遥控器型号列表)
if (c.children.length > 0) {
return (
<li key={c.id} className="mt-3.5 list-none">
<div className="mb-1.5 border-b border-subline pb-1 text-[16px] font-bold text-[#222]">{c.title}</div>
<ul className="list-none p-0">
{c.children.map((g) => (
<li key={g.id} className="list-none">
<MegaItemLink node={g} />
</li>
))}
</ul>
</li>
);
}
return (
<li key={c.id} className="list-none">
<MegaItemLink node={c} />
</li>
);
})}
</ul>
</section>
);
}
/** # 组件 巨幕叶子链接:站内 <a> / 外链新窗口 / 无链接的普通文本(原站 16px/400/#222) */
function MegaItemLink({ node }: { node: MenuNode }) {
const info = linkInfo(node.url);
const cls = "block leading-[1.7] text-[16px] text-[#222] hover:text-brand";
if (info) {
return (
<a href={info.href} target={info.external ? "_blank" : node.target || undefined} rel={info.external ? "noreferrer" : undefined} className={cls}>
{node.title}
</a>
);
}
return <span className={cls}>{node.title}</span>;
}
/** # 组件 顶部社交图标(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>
);
}
/** # 组件 移动端导航项(子菜单平铺展示) */
function MobileNavItem({ node, onNavigate }: { node: MenuNode; onNavigate: () => void }) {
const href = normalizeUrl(node.url);
return (
<div>
{href ? (
<a href={href} onClick={onNavigate} className="block border-b border-line px-1 py-[9px] font-medium text-ink">
{node.title}
</a>
) : (
<span className="block border-b border-line px-1 py-[9px] font-medium text-ink">{node.title}</span>
)}
{node.children.map((c) => {
const ch = normalizeUrl(c.url);
return (
<div key={c.id}>
{ch ? (
<a href={ch} onClick={onNavigate} className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">
{c.title}
</a>
) : (
<span className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">{c.title}</span>
)}
</div>
);
})}
</div>
);
}
/** # 函数 菜单 URL 归一化为站内路径(外部链接返回 null) */
function normalizeUrl(url: string): string | null {
if (!url) return null;
// 完整 URL:仅同域或相对路径可站内跳转
if (/^https?:\/\//i.test(url)) {
try {
const u = new URL(url);
if (u.host === "dusuniot.com" || u.host === "www.dusuniot.com") return u.pathname;
return null;
} catch {
return null;
}
}
if (url.startsWith("#") || url.startsWith("mailto:") || url.startsWith("tel:")) return null;
return url;
}
/** # 类型 菜单链接解析结果 */
/** # 函数 菜单 URL → 可渲染链接(站内路径 / 外链原样;「#」等返回 null)
* 与 normalizeUrl 的差异:外链(WiKi/YouTube/LinkedIn 等)原样保留,由调用方
* 以新窗口锚点渲染,与原站 mega menu 行为一致。
*/
function linkInfo(url: string): { href: string; external: boolean } | null {
if (!url || url.startsWith("#") || url.startsWith("mailto:") || url.startsWith("tel:")) return null;
if (/^https?:\/\//i.test(url)) {
try {
const u = new URL(url);
if (u.host === "dusuniot.com" || u.host === "www.dusuniot.com") return { href: u.pathname, external: false };
return { href: url, external: true };
} catch {
return null;
}
}
return { href: url, external: false };
}
+119
View File
@@ -0,0 +1,119 @@
/**
* # 组件 询盘表单 InquiryForm(Tailwind + shadcn/ui 实现)
* 字段:name/email/phone/company/country/message + productInterest(hidden) + 蜜罐。
* 提交走 Server Function submitInquiry;成功显示绿色提示,失败显示红色错误。
*/
import { useState, type FormEvent } from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { submitInquiry } from "~/server/inquiries";
import { useT } from "~/i18n";
/** # 类型 InquiryForm 属性 */
export function InquiryForm(props: {
/** # 成员变量 表单标识(对应 inquiry_forms.form_key) **/
formKey: string;
/** # 成员变量 产品兴趣(产品页填产品名,博客/联系页可空) **/
productInterest?: string;
/** # 成员变量 标题(默认 Get a Quote) **/
title?: string;
}) {
const t = useT();
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
/** # 函数 表单提交处理(组装 InquiryPayload 调用 Server Function) */
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const fd = new FormData(form);
setStatus("submitting");
setErrorMsg("");
try {
const res = await submitInquiry({
data: {
formKey: props.formKey,
name: String(fd.get("name") || ""),
email: String(fd.get("email") || ""),
phone: String(fd.get("phone") || ""),
company: String(fd.get("company") || ""),
country: String(fd.get("country") || ""),
message: String(fd.get("message") || ""),
productInterest: props.productInterest || String(fd.get("productInterest") || ""),
_gotcha: String(fd.get("_gotcha") || ""),
},
});
if (res.ok) {
setStatus("success");
form.reset();
} else {
setStatus("error");
setErrorMsg(res.error || t("inquiry_error"));
}
} catch (err) {
setStatus("error");
setErrorMsg(err instanceof Error ? err.message : t("inquiry_network"));
}
}
if (status === "success") {
return (
<div className="max-w-[720px] rounded-lg border border-line bg-soft p-6" id="inquiry">
<h2 className="mt-0">{props.title || t("inquiry_title")}</h2>
<div className="rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
{t("inquiry_success")}
</div>
</div>
);
}
return (
<div className="max-w-[720px] rounded-lg border border-line bg-soft p-6" id="inquiry">
<h2 className="mt-0">{props.title || t("inquiry_title")}</h2>
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleSubmit}>
{/* 蜜罐字段:隐藏,机器人填写即视为垃圾提交 */}
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" className="hidden" aria-hidden="true" />
<Label>
{t("inquiry_name")}
<Input name="name" required maxLength={255} />
</Label>
<Label>
{t("inquiry_email")}
<Input name="email" type="email" required maxLength={255} />
</Label>
<Label>
{t("inquiry_phone")}
<Input name="phone" type="tel" maxLength={128} />
</Label>
<Label>
{t("inquiry_company")}
<Input name="company" maxLength={255} />
</Label>
<Label>
{t("inquiry_country")}
<Input name="country" maxLength={128} />
</Label>
<Label>
{t("inquiry_interest")}
<Input name="productInterest" defaultValue={props.productInterest || ""} readOnly={!!props.productInterest} />
</Label>
<Label className="col-span-full">
{t("inquiry_message")}
<Textarea name="message" required rows={5} placeholder={t("inquiry_message_ph")} />
</Label>
{status === "error" ? (
<div role="alert" className="col-span-full rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">
{errorMsg}
</div>
) : null}
<div className="col-span-full">
<Button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? t("inquiry_submitting") : t("inquiry_submit")}
</Button>
</div>
</form>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* # 组件 Header 语言切换(Globe 图标 + 下拉语言列表)
* 选择后写 ui_lang cookie 并整页刷新;UI 文案按 cookie 的语言 SSR 渲染(自建 i18n 词典)。
*/
import { useEffect, useRef, useState } from "react";
import { Globe } from "lucide-react";
import { LOCALES, getUiLocale, setUiLocale } from "~/i18n/shared";
export function LangSwitcher() {
const [open, setOpen] = useState(false);
/** # 成员变量 当前语言(客户端挂载后从 cookie 读取,避免 SSR 水合不一致) **/
const [cur, setCur] = useState("en");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => setCur(getUiLocale()), []);
// 点击组件外部收起下拉
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div ref={ref} className="relative hidden min-[960px]:block">
<button
aria-label="Language"
aria-expanded={open}
type="button"
onClick={() => setOpen(!open)}
className="flex cursor-pointer items-center border-0 bg-transparent p-1 text-brand"
>
<Globe size={19} aria-hidden />
</button>
{open ? (
<div className="absolute right-0 top-full z-50 max-h-[340px] w-[200px] overflow-auto rounded-md border border-line bg-white py-1 shadow-[0_8px_24px_rgba(16,42,76,0.14)]">
{LOCALES.map((l) => (
<button
key={l.code}
type="button"
onClick={() => {
setOpen(false);
if (l.code !== cur) setUiLocale(l.code);
}}
className={
"block w-full cursor-pointer border-0 bg-transparent px-4 py-2 text-left text-[13px] hover:bg-soft hover:text-brand " +
(l.code === cur ? "font-bold text-brand" : "text-ink")
}
>
{l.label}
</button>
))}
</div>
) : null}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* # 组件 网站公共布局 Layout
* 组合 Header + 页面内容 + Footer;样式入口(Tailwind v4 + shadcn/ui)在此引入一次。
* 布局数据(菜单/分类)由各路由 loader 通过 getLayoutData() 取得后传入。
*/
import "../../styles/app.css";
import { Header } from "./Header";
import { Footer, type FooterCategory } from "./Footer";
import { createServerFn } from "@tanstack/react-start";
import { getMenuTree, getProductCategoryTree, type MenuNode } from "~/server/data";
/** # 类型 布局数据(Header/Footer 所需) */
export type LayoutData = {
/** # 成员变量 顶部导航菜单树(max_mega_menu_1) **/
menu: MenuNode[];
/** # 成员变量 页脚公司链接菜单树(menu-2) **/
footerMenu: MenuNode[];
/** # 成员变量 产品分类树(含计数) **/
categories: FooterCategory[];
};
/** # 函数 Server Function:获取布局数据(菜单 + 分类) */
export const getLayoutData = createServerFn({ method: "GET" }).handler(async (): Promise<LayoutData> => {
const [menu, footerMenu, cats] = await Promise.all([getMenuTree("max_mega_menu_1"), getMenuTree("menu-2"), getProductCategoryTree()]);
return { menu, footerMenu, categories: cats };
});
/** # 类型 Layout 属性 */
export function Layout(props: { layout: LayoutData; children: React.ReactNode }) {
return (
<>
<Header menu={props.layout.menu} />
<main>{props.children}</main>
<Footer categories={props.layout.categories} menu={props.layout.footerMenu} />
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* # 组件 分页 Pagination(基于 shadcn/ui Pagination 链接组件)
* 上一页/下一页 + 页码;页码链接由调用方生成(保持当前 URL 的其他参数)。
*/
import { Pagination as PaginationRoot, PaginationContent, PaginationItem, PaginationLink, PaginationEllipsis } from "~/components/ui/pagination";
import { useT } from "~/i18n";
/** # 类型 Pagination 属性:makeHref(page) 返回对应页 URL */
export function Pagination(props: { page: number; total: number; pageSize: number; makeHref: (page: number) => string }) {
const t = useT();
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize));
if (totalPages <= 1) return null;
// 页码窗口:当前页前后各 2 页
const pages: number[] = [];
for (let i = Math.max(1, props.page - 2); i <= Math.min(totalPages, props.page + 2); i++) pages.push(i);
return (
<PaginationRoot>
<PaginationContent>
<PaginationItem>
<PaginationLink href={props.makeHref(props.page - 1)} disabled={props.page <= 1}>
{t("pagination_prev")}
</PaginationLink>
</PaginationItem>
{pages[0] > 1 ? (
<>
<PaginationItem>
<PaginationLink href={props.makeHref(1)}>1</PaginationLink>
</PaginationItem>
{pages[0] > 2 ? (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
) : null}
</>
) : null}
{pages.map((p) => (
<PaginationItem key={p}>
<PaginationLink href={props.makeHref(p)} isActive={p === props.page}>
{p}
</PaginationLink>
</PaginationItem>
))}
{pages[pages.length - 1] < totalPages ? (
<>
{pages[pages.length - 1] < totalPages - 1 ? (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
) : null}
<PaginationItem>
<PaginationLink href={props.makeHref(totalPages)}>{totalPages}</PaginationLink>
</PaginationItem>
</>
) : null}
<PaginationItem>
<PaginationLink href={props.makeHref(props.page + 1)} disabled={props.page >= totalPages}>
{t("pagination_next")}
</PaginationLink>
</PaginationItem>
</PaginationContent>
</PaginationRoot>
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* # 组件 产品卡片 ProductCard(Tailwind + shadcn/ui 实现)
* 产品网格卡片:图 / 标题 / Read more 链接。首页、列表页、搜索页复用。
* 整卡为链接,文字继承品牌橙(与原 .card 于 a 上同色一致)。
*/
import { Link } from "@tanstack/react-router";
import { useT } from "~/i18n";
/** # 类型 产品卡片数据(listProducts.items 元素子集) */
export type ProductCardData = {
id: number;
slug: string;
title: string;
sku: string | null;
imageUrl: string | null;
imageAlt?: string | null;
};
/** # 组件 产品卡片 */
export function ProductCard({ p }: { p: ProductCardData }) {
const t = useT();
return (
<Link
to="/product/$slug/"
params={{ slug: p.slug }}
className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]"
>
<div className="flex aspect-[4/3] items-center justify-center bg-white p-3">
{p.imageUrl ? <img src={p.imageUrl} alt={p.imageAlt || p.title} loading="lazy" className="max-h-full object-contain" /> : null}
</div>
<div className="px-4 pt-3.5 pb-[18px] text-center">
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{p.title}</div>
<span className="mt-2.5 inline-block rounded-[4px] border border-brand bg-white px-4 py-1.5 text-sm font-semibold text-brand hover:bg-soft">
{t("card_read_more")}
</span>
</div>
</Link>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* # 组件 TickerBar 公告滚动条(复刻原站 eael-content-ticker / Essential Addons)
* 结构还原原站渲染 HTML(React 挂载后由 swapIdx 状态轮播):
* .eael-ticker-wrap > .ticker-badge(span 标签文本) + .eael-ticker > .content-ticker
* > .swiper-wrapper >(site 枚举) .swiper-slide > .ticker-content > a.ticker-content-link
*
* 交互:淡入淡出切换(effect=fade),窗口 opacity 过渡时长 = speed,
* 轮播间隔 = interval;条目不足 2 条时常显(不轮播)。
* 验收参照(2026-10-31 原站头部实测 1436px 视口):
* 外层高 45px;badge/内容 padding 8px 12px;badge 文字 18px/600 #000;
* 链接 16px #222,hover #ED6C00;内容行高 28.8px(1.8)。
*/
"use client";
import { useEffect, useState } from "react";
export interface TickerItem {
/** # 成员变量 条目标题(显示文本) */
title: string;
/** # 成员变量 条目链接(原站为产品页绝对地址,本项目用站内相对地址) */
url: string;
}
export interface TickerBarProps {
/** # 成员变量 左侧标签文本(原站 "Latest Products:") */
badge: string;
/** # 成员变量 轮播条目(原站取最新 3 个产品) */
items: TickerItem[];
/** # 成员变量 切换过渡时长 ms(原站 data-speed=200) */
speed?: number;
/** # 成员变量 自动轮播间隔 ms(原站 data-autoplay=5000) */
interval?: number;
}
/** # 组件 TickerBar 带标签的淡入淡出滚动条 */
export default function TickerBar({ badge, items, speed = 200, interval = 5000 }: TickerBarProps) {
// 变量 indicator 当前展示条目下标(0..items.length-1)
const [idx, setIdx] = useState(0);
useEffect(() => {
if (items.length < 2) return;
const timer = setInterval(() => setIdx(i => (i + 1) % items.length), interval);
return () => clearInterval(timer);
}, [items.length, interval]);
if (!items.length) return null;
return (
<div className="eael-ticker-wrap">
{badge ? (
<div className="ticker-badge">
<span>{badge}</span>
</div>
) : null}
<div className="eael-ticker">
<div className="content-ticker" data-effect="fade" data-speed={speed} data-autoplay={interval} data-loop="1">
<div className="swiper-wrapper">
{items.map((t, i) => (
<div key={i} className={"swiper-slide" + (i === idx ? " active" : "")} style={{ transitionDuration: `${speed}ms` }}>
<div className="ticker-content">
<a className="ticker-content-link" href={t.url}>{t.title}</a>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* # 模块 catch-all 页面共享逻辑($.tsx 只导出 Route)
*/
import { getPublishedContent, listPosts, type PostListItem, type ProductListResult } from "~/server/data";
import type { LayoutData } from "~/components/website/Layout";
/** # 类型 兜底 loader 返回结构 */
export type CatchAllData = {
/** # 成员变量 布局数据(菜单/分类) **/
layout: LayoutData;
/** # 成员变量 命中的内容(page/post),null 表示未找到 **/
content: null | {
slug: string;
type: string;
title: string;
excerpt: string | null;
html: string | null;
seoTitle: string | null;
seoDescription: string | null;
meta: Record<string, unknown> | null;
featuredUrl: string | null;
featuredAlt: string | null;
terms: { name: string; slug: string; taxonomy: string }[];
};
/** # 成员变量 识别出的语言前缀(如 zh-CN),空串表示英语/无前缀 **/
lang: string;
/** # 成员变量 404 时的热门产品 **/
hotProducts: ProductListResult;
/** # 成员变量 WP /%category%/ 归档命中:分类 + 分页文章(content 为 null 时可能命中) **/
archive: null | { name: string; slug: string; posts: { items: PostListItem[]; total: number; page: number; pageSize: number } };
};
/** # 类型 归档分页结果(listPosts 返回形状的窄化别名) */
export type ArchivePosts = NonNullable<CatchAllData["archive"]>["posts"];
export type { PostListItem };
export { listPosts };
/** # 函数 判断路径段是否像语言代码(2 字母,或 zh-CN 格式;排除 en) */
export function isLangCode(seg: string): boolean {
return seg !== "en" && (/^[a-z]{2}$/.test(seg) || /^[a-z]{2}-[A-Za-z]{2,4}$/.test(seg));
}
/** # 语言代码 → 显示名(title 标注用) */
export const LANG_NAMES: Record<string, string> = {
"zh-CN": "中文(简体)",
"zh-TW": "中文(繁體)",
de: "German",
fr: "French",
es: "Spanish",
it: "Italian",
pt: "Portuguese",
ru: "Russian",
ja: "Japanese",
ko: "Korean",
ar: "Arabic",
nl: "Dutch",
pl: "Polish",
tr: "Turkish",
};
/** # 函数 把内容行收窄为可序列化结构(meta/terms/jsonb 处理) */
export function pickContent(row: NonNullable<Awaited<ReturnType<typeof getPublishedContent>>>): CatchAllData["content"] {
return {
slug: row.slug,
type: row.type,
title: row.title,
excerpt: row.excerpt,
html: row.html,
seoTitle: row.seoTitle,
seoDescription: row.seoDescription,
meta: (row.meta ?? null) as Record<string, unknown> | null,
featuredUrl: row.featuredUrl,
featuredAlt: row.featuredAlt,
terms: row.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy })),
};
}
@@ -0,0 +1,69 @@
/**
* # 模块 collection 页面共享类型与 search 参数解析
* 供 collection.index.tsx 与 collection.$slug.tsx 复用(路由文件只导出 Route)。
*
* 设计说明:validateSearch 的输出会被 TanStack 序列化回 URL,
* 因此 search 状态保持「扁平字符串」形态(属性slug=值1,值2),
* 与输入 URL 完全一致,避免路由规范化重定向;
* 结构化的 FilterState 在 loader / 交互处理时再从扁平 search 解析。
*/
import type { FilterState } from "~/server/data";
/** # 类型 /collection 的扁平 search 参数:page 数字 + 其余属性筛选键(逗号分隔值) */
export type CollectionSearch = { page?: number } & Record<string, string | undefined>;
/** # 函数 解析 collection search 参数(保持扁平原样,仅规范化 page) */
export function parseCollectionSearch(search: Record<string, unknown>): CollectionSearch {
const out: Record<string, string | undefined> = {};
const page = Number(search.page);
if (Number.isFinite(page) && page > 1) out.page = String(Math.floor(page));
for (const [k, v] of Object.entries(search)) {
if (k === "page" || v == null) continue;
if (Array.isArray(v)) {
const joined = v.filter((x) => typeof x === "string" && x.trim()).join(",");
if (joined) out[k] = joined;
} else if (typeof v === "string" && v.trim()) {
out[k] = v;
}
}
return out as CollectionSearch;
}
/** # 函数 扁平 search → 结构化筛选状态(每个属性键按逗号拆分为值数组) */
export function searchToFilters(search: CollectionSearch): FilterState {
const filters: FilterState = {};
for (const [k, v] of Object.entries(search)) {
if (k === "page" || v == null) continue;
const values = String(v)
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (values.length) filters[k] = values;
}
return filters;
}
/** # 函数 结构化筛选状态 → 扁平 search(navigate 用,page > 1 时附带) */
export function filtersToSearch(filters: FilterState | undefined, page: number): CollectionSearch {
const out: Record<string, string | undefined> = {};
if (filters) {
for (const [k, vals] of Object.entries(filters)) {
if (vals.length) out[k] = vals.join(",");
}
}
if (page > 1) out.page = String(page);
return out as CollectionSearch;
}
/** # 函数 把 filters + page 序列化为 URL query string(分页链接用) */
export function buildCollectionQuery(filters: FilterState | undefined, page: number): string {
const params = new URLSearchParams();
if (filters) {
for (const [k, vals] of Object.entries(filters)) {
if (vals.length) params.set(k, vals.join(","));
}
}
if (page > 1) params.set("page", String(page));
const qs = params.toString();
return qs ? `?${qs}` : "";
}