Files
dusuniot/src/components/website/Header.tsx
T

534 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 组件 网站顶部导航 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, MessageCircleMore, 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] bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
{/* topbar:口号 + 社交图标(原站实测 2026-11-03:高 36px、容器 1350、文字 16px/16、
图标 36×36 盒 gap 10、顺序 Linkedin/Youtube/Facebook、无登录项;<768 隐藏) */}
<div className="hidden bg-footer text-white min-[768px]:block">
<div className="mx-auto flex h-9 w-full max-w-[1350px] items-center justify-between">
<a href="/landing-pages/oem-odm/" className="text-[16px] leading-4 text-white hover:text-[#f5b278]">
{t("topbar_oem")}
</a>
<div className="flex items-center gap-[10px]">
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="flex h-9 w-9 items-center justify-center text-white hover:text-[#f5b278]">
<LinkedinGlyph />
</a>
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="flex h-9 w-9 items-center justify-center text-white hover:text-[#f5b278]">
<YoutubeGlyph />
</a>
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="flex h-9 w-9 items-center justify-center text-white hover:text-[#f5b278]">
<FacebookGlyph />
</a>
</div>
</div>
</div>
{/* 主导航(原站实测 2026-11-03:桌面 68px 容器 40/25、logo 后 80px 起菜单、项 pad 8/6 项距 6;
平板 768-959 为汉堡导航(顶栏 36 + 导航 48,logo 缩放,右侧 CONTACT US+邮件);
手机 <768 无顶栏、导航 48、汉堡在 logo 右 96px、右侧橙色气泡+邮件) */}
<div className="flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:pl-10 min-[960px]:pr-[25px]">
{/* 直接复用原站 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="block h-auto w-[109px] min-[768px]:w-[103px] min-[880px]:w-[124px] min-[960px]:w-[171px]" />
</Link>
<div className="ml-[80px] hidden min-w-0 min-[960px]:flex min-[960px]:flex-1 min-[960px]:items-center">
<DesktopNav tops={tops} />
{searchOpen ? (
<form action="/search" method="get" className="ml-1.5 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="ml-1.5 flex h-[68px] w-10 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-ink">
<Search size={19} aria-hidden />
</button>
)}
</div>
{/* 汉堡:原站为 mega-toggle 块(手机位于 logo 右 96px、平板右聚拢),图标浅灰 */}
<div className="ml-[96px] flex h-12 items-center justify-end min-[768px]:ml-auto min-[960px]:hidden">
<button aria-label="Toggle menu" onClick={() => setOpen(!open)} className="flex h-12 w-10 cursor-pointer items-center justify-center rounded-[4px] border-0 bg-transparent p-0 text-[#c9c9c9]">
{open ? <X size={32} aria-hidden /> : <Menu size={32} aria-hidden />}
</button>
</div>
<div className="min-w-0 flex-1 min-[960px]:hidden" aria-hidden />
<a href="/contact-us/" aria-label="Contact us" className="flex h-[34px] w-[38px] items-center justify-center text-brand min-[768px]:hidden">
<MessageCircleMore size={28} aria-hidden />
</a>
<Button asChild className="hidden whitespace-nowrap px-[18px] py-[9px] min-[768px]:inline-flex">
<a href="/contact-us/">{t("contact_us")}</a>
</Button>
<a href="/contact-us/" aria-label="Email us" className="flex h-10 w-[52px] items-center justify-center p-0 text-brand">
<Mail size={28} aria-hidden />
</a>
<LangSwitcher />
</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]:items-center gap-1.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} className="pl-2 pr-1.5">
{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 };
}