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
+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}` : "";
}