534 lines
28 KiB
TypeScript
534 lines
28 KiB
TypeScript
/**
|
||
* # 组件 网站顶部导航 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-04 用户指定:容器 1400 居中、
|
||
高 36px、图标 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-[1400px] items-center justify-between px-4 min-[960px]:px-0">
|
||
<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-04 用户指定:导航容器 1400px 居中;桌面 68px、logo 后 80px 起菜单;
|
||
平板 768-959 为汉堡导航(顶栏 36 + 导航 48,logo 缩放,右侧 CONTACT US+邮件);
|
||
手机 <768 无顶栏、导航 48、汉堡在 logo 右 96px、右侧橙色气泡+邮件) */}
|
||
<div className="mx-auto flex h-12 items-center px-2.5 pr-[13px] min-[768px]:pr-[68px] min-[960px]:h-[68px] min-[960px]:max-w-[1400px] min-[960px]:px-10">
|
||
{/* 直接复用原站 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 };
|
||
}
|