博客导航栏模块完整构建方案
概述
本文详细介绍博客导航栏模块的完整构建方案。导航栏是整个博客最关键的用户交互入口,它承载了:
- 品牌标识:Logo + 标题
- 页面导航:多级下拉菜单
- 全局搜索:基于 Pagefind 的全文搜索
- 主题切换:亮色/暗色/跟随系统
- 音乐播放:导航栏内嵌音乐播放器
- 显示设置:壁纸模式、布局切换等
- 移动端适配:侧滑抽屉菜单
整个导航栏系统由 类型层 → 配置层 → 常量层 → 组件层 → 样式层 五层架构组成。
一、类型定义层
所有导航相关的 TypeScript 类型定义在 config.ts 中:
// 预设链接枚举 —— 将常用的页面链接定义为枚举,避免各处硬编码export enum LinkPreset { Home = 0, Archive = 1, About = 2, Friends = 3, Sponsor = 4, Guestbook = 5, Bangumi = 6, Books = 7, MoviesGames = 8, MusicPage = 9, Changelog = 10,}
// 单个导航链接类型export type NavBarLink = { name: string; // 显示名称 url: string; // 跳转地址 external?: boolean; // 是否外部链接 icon?: string; // Iconify 图标名 children?: (NavBarLink | LinkPreset)[]; // 子菜单,嵌套结构};
// 搜索方式枚举export enum NavBarSearchMethod { PageFind = 0,}
export type NavBarSearchConfig = { method: NavBarSearchMethod;};
// 导航栏整体配置export type NavBarConfig = { links: (NavBarLink | LinkPreset)[];};设计亮点:
LinkPreset枚举作为预设链接的”快捷方式”,在配置中用数字代替完整对象,减少冗余children字段使用(NavBarLink | LinkPreset)[],子菜单同样支持预设,支持无限层级嵌套external字段区分内部路由和外部跳转,外部链接会在渲染时添加_blank和小箭头图标
二、常量层:LinkPresets 映射表
link-presets.ts 将 LinkPreset 枚举值解析为完整的 NavBarLink 对象,同时集成 i18n 国际化:
import I18nKey from "@i18n/i18nKey";import { i18n } from "@i18n/translation";import { LinkPreset, type NavBarLink } from "@/types/config";
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = { [LinkPreset.Home]: { name: i18n(I18nKey.home), url: "/", icon: "material-symbols:home", }, [LinkPreset.About]: { name: i18n(I18nKey.about), url: "/about/", icon: "material-symbols:person", }, [LinkPreset.Archive]: { name: i18n(I18nKey.archive), url: "/archive/", icon: "material-symbols:archive", }, [LinkPreset.Friends]: { name: i18n(I18nKey.friends), url: "/friends/", icon: "material-symbols:group", }, [LinkPreset.Sponsor]: { name: i18n(I18nKey.sponsor), url: "/sponsor/", icon: "material-symbols:favorite", }, [LinkPreset.Guestbook]: { name: i18n(I18nKey.guestbook), url: "/guestbook/", icon: "material-symbols:chat", }, [LinkPreset.Bangumi]: { name: i18n(I18nKey.bangumi), url: "/bangumi/", icon: "material-symbols:camera-outdoor", }, [LinkPreset.Books]: { name: i18n(I18nKey.books), url: "/books/", icon: "material-symbols:book-5", }, [LinkPreset.MoviesGames]: { name: i18n(I18nKey.moviesGames), url: "/movies-games/", icon: "material-symbols:movie", }, [LinkPreset.MusicPage]: { name: i18n(I18nKey.musicPage), url: "/music/", icon: "material-symbols:music-note", }, [LinkPreset.Changelog]: { name: i18n(I18nKey.changelog), url: "/changelog/", icon: "material-symbols:history", },};设计亮点:
{ [key in LinkPreset]: NavBarLink }使用 TypeScript 映射类型,确保所有枚举值都被覆盖,不会遗漏- 调用
i18n()函数时读取运行时语言配置,切换语言后所有导航栏文本自动更新
三、配置层
3.1 导航栏核心配置
navBarConfig.ts 是导航栏菜单配置的核心:
import { LinkPreset, type NavBarConfig, type NavBarLink, type NavBarSearchConfig, NavBarSearchMethod,} from "../types/config";import { siteConfig } from "./siteConfig";
const getDynamicNavBarConfig = (): NavBarConfig => { const links: (NavBarLink | LinkPreset)[] = [ LinkPreset.Home, { name: "分类", url: "/categories/", icon: "material-symbols:folder-open", }, LinkPreset.Archive, { name: "网站导航", url: "/projects/", icon: "material-symbols:public", }, ];
// 动态(带下拉子菜单) links.push({ name: "动态", url: "/moments/", icon: "material-symbols:local-cafe", children: [ { name: "说说", url: "/moments/", icon: "material-symbols:chat-bubble-outline", }, { name: "相册", url: "/album/", icon: "material-symbols:photo-album-outline", }, { name: "留言板", url: "/guestbook/", icon: "material-symbols:edit-outline", }, ], });
// 记录入口 - 根据页面开关动态生成子菜单 const recordChildren: (NavBarLink | LinkPreset)[] = []; if (siteConfig.pages.books) recordChildren.push(LinkPreset.Books); if (siteConfig.pages.moviesGames) recordChildren.push(LinkPreset.MoviesGames); if (siteConfig.pages.musicPage) recordChildren.push(LinkPreset.MusicPage); if (siteConfig.pages.changelog) recordChildren.push(LinkPreset.Changelog); recordChildren.push({ name: "规划", url: "/life/routines/", icon: "material-symbols:list-alt", }); recordChildren.push({ name: "足迹", url: "/life/places/", icon: "material-symbols:location-on", });
if (recordChildren.length > 0) { links.push({ name: "记录", url: "/books/", icon: "material-symbols:camera-outdoor", children: recordChildren, }); }
// 关于及其子菜单 links.push({ name: "关于", url: "/about/", icon: "material-symbols:info", children: [ LinkPreset.About, LinkPreset.Friends, ...(siteConfig.pages.sponsor ? [LinkPreset.Sponsor] : []), ], });
return { links } as NavBarConfig;};
export const navBarSearchConfig: NavBarSearchConfig = { method: NavBarSearchMethod.PageFind,};
export const navBarConfig: NavBarConfig = getDynamicNavBarConfig();设计亮点:
- 动态生成:根据
siteConfig.pages中的开关动态增删菜单项,关闭书架页面则”记录”下拉中不显示”书架” - 混合使用:既可以用
LinkPreset枚举快捷引用预设链接,也可以直接写NavBarLink对象实现自定义 - 展开运算符:
...(condition ? [item] : [])模式优雅地实现条件渲染子菜单
3.2 站点配置中的导航栏设置
siteConfig.ts 中的 navbar 字段:
navbar: { // Logo 支持四种类型:icon、image(src目录)、image(public目录)、url logo: { type: "image", value: "assets/images/firefly.png", alt: "🍀", }, title: "团子和蛋糕", hoverTitle: "w(゚Д゚)w 不要走!再看看嘛!", // 离开页面时标题变化 widthFull: false, // 是否全宽导航栏 followTheme: false, // Logo和标题是否跟随主题色},3.3 壁纸配置中的导航栏透明模式
backgroundWallpaper.ts 中的 banner.navbar:
navbar: { // "semi" 半透明 | "full" 完全透明 | "semifull" 动态透明(滚动时变化) transparentMode: "semifull", enableBlur: true, // 毛玻璃模糊效果开关 blur: 3, // 模糊度(px)},四、组件层
4.1 导航栏布局嵌套关系
导航栏的实际渲染入口在 MainGridLayout.astro 中:
<div id="navbar-wrapper" class="pointer-events-auto transition-all" data-full-width={String(navbarWidthFull)}> <Navbar></Navbar></div>组件树结构如下:
Navbar.astro ← 主容器├── Logo区域 (a标签)│ ├── Icon (icon类型)│ ├── Picture/Image (image/url类型)│ └── 标题文字├── 桌面端导航链接列表 (.hidden.lg:flex)│ └── DropdownMenu.astro × N ← 每个链接一个│ ├── 有子菜单 → button + DropdownPanel → DropdownItem│ └── 无子菜单 → a标签├── 右侧工具栏 (.flex.items-center)│ ├── Search.svelte ← 搜索组件│ ├── 音乐按钮 (条件渲染)│ ├── 显示设置按钮 (条件渲染)│ ├── LightDarkSwitch.svelte ← 亮暗切换│ └── 汉堡菜单按钮 (移动端) .lg:hidden├── 音乐播放面板 (浮动)│ └── MusicPlayer.astro├── NavMenuPanel.astro ← 移动端侧滑菜单└── DisplaySettingsIntegrated.svelte ← 显示设置面板4.2 Navbar.astro — 主组件
Navbar.astro 是导航栏的核心组件,完整代码如下:
---import { Picture } from "astro:assets";import * as path from "node:path";import type { ImageMetadata } from "astro";import { Icon } from "astro-icon/components";import DisplaySettings from "@/components/controls/DisplaySettingsIntegrated.svelte";import LightDarkSwitch from "@/components/controls/LightDarkSwitch.svelte";import Search from "@/components/controls/Search.svelte";import MusicPlayer from "@/components/features/MusicPlayer.astro";import { backgroundWallpaper, navBarConfig, siteConfig } from "@/config";import { musicPlayerConfig } from "@/config/musicConfig";import { LinkPresets } from "@/constants/link-presets";import I18nKey from "@/i18n/i18nKey";import { i18n } from "@/i18n/translation";import { LinkPreset, type NavBarLink } from "@/types/config";import { getFallbackFormat, getImageFormats } from "@/utils/image-utils";import { isHomePage } from "@/utils/layout-utils";import { url } from "@/utils/url-utils";import DropdownMenu from "./DropdownMenu.astro";import NavMenuPanel from "./NavMenuPanel.astro";
const className = Astro.props.class;
// 获取导航栏透明模式配置const navbarTransparentMode = backgroundWallpaper.mode === "banner" ? backgroundWallpaper.banner?.navbar?.transparentMode || "semi" : "semi";
// 获取毛玻璃效果配置const navbarEnableBlur = backgroundWallpaper.mode === "banner" ? (backgroundWallpaper.banner?.navbar?.enableBlur ?? true) : false;
const navbarBlur = backgroundWallpaper.mode === "banner" ? (backgroundWallpaper.banner?.navbar?.blur ?? 20) : 0;
const navbarTitle = siteConfig.navbar.title || siteConfig.title;const navbarWidthFull = siteConfig.navbar.widthFull ?? false;const isHomePageCheck = isHomePage(Astro.url.pathname);
// 显示设置可用性判断const showThemeColor = !siteConfig.themeColor.fixed;const isWallpaperSwitchable = backgroundWallpaper.switchable ?? true;const allowLayoutSwitch = siteConfig.postListLayout.allowSwitch;const hasDisplaySettings = showThemeColor || isWallpaperSwitchable || allowLayoutSwitch;
// 将预设链接解析为真实链接let links: NavBarLink[] = navBarConfig.links.map( (item: NavBarLink | LinkPreset): NavBarLink => { if (typeof item === "number") { return LinkPresets[item]; } return item; },);
// 智能处理Logo:区分src目录图片(Astro优化)和public/远程图片const logoValue = siteConfig.navbar.logo?.value || "";const isLocalSrcLogo = siteConfig.navbar.logo?.type === "image" && logoValue && !logoValue.startsWith("/") && !logoValue.startsWith("http");let logoImg: ImageMetadata | null = null;
if (isLocalSrcLogo) { const files = import.meta.glob<ImageMetadata>( "../../**/*.{png,jpg,jpeg,webp,avif,gif,svg}", { import: "default" }, ); const normalizedPath = path .normalize(path.join("../../", logoValue)) .replace(/\\/g, "/"); const file = files[normalizedPath]; if (file) { logoImg = await file(); }}---
<div id="navbar" class="z-50" style={`--navbar-glass-blur: ${navbarBlur}px;`} data-transparent-mode={navbarTransparentMode} data-enable-blur={String(navbarEnableBlur)} data-is-home={isHomePageCheck} data-full-width={navbarWidthFull}>
<div class:list={[ className, "overflow-visible! h-18 mx-auto flex items-center px-4", navbarWidthFull ? "" : "justify-between max-w-(--page-width)" ]}>
<!-- ========== Logo + 标题 ========== --> <a href={url('/')} class="btn-plain scale-animation rounded-lg h-13 px-5 font-bold active:scale-95 navbar-logo-link"> <div class:list={[ "flex flex-row items-center text-md navbar-logo-content", siteConfig.navbar.followTheme ? "text-(--primary)" : "dark:text-white text-black" ]}> {siteConfig.navbar.logo?.type === "icon" ? ( <Icon name={siteConfig.navbar.logo.value || "material-symbols:home-pin-outline"} class="text-[1.75rem] mb-1 mr-2 navbar-logo-icon" /> ) : siteConfig.navbar.logo?.type === "image" && logoImg ? ( <Picture src={logoImg} alt={siteConfig.navbar.logo.alt || siteConfig.title} class="h-7 w-7 mb-1 mr-2 object-contain navbar-logo-img" formats={getImageFormats()} fallbackFormat={getFallbackFormat()} widths={[28, 56]} loading="eager" fetchpriority="high" /> ) : siteConfig.navbar.logo?.type === "image" ? ( <img src={url(siteConfig.navbar.logo.value)} alt={siteConfig.navbar.logo.alt || siteConfig.title} class="h-7 w-7 mb-1 mr-2 object-contain navbar-logo-img" fetchpriority="high" /> ) : siteConfig.navbar.logo?.type === "url" ? ( <img src={siteConfig.navbar.logo.value} alt={siteConfig.navbar.logo.alt || siteConfig.title} class="h-7 w-7 mb-1 mr-2 object-contain navbar-logo-img" fetchpriority="high" /> ) : ( <Icon name="material-symbols:home-pin-outline" class="text-[1.75rem] mb-1 mr-2 navbar-logo-icon" /> )} <span class="navbar-logo-text">{navbarTitle}</span> </div> </a>
<!-- ========== 桌面端导航链接 ========== --> <div class="hidden lg:flex items-center space-x-1"> {links.map((l) => { return <DropdownMenu link={l} />; })} </div>
<!-- ========== 右侧工具栏 ========== --> <div class:list={["flex items-center", navbarWidthFull ? "ml-auto" : ""]}> <!-- 搜索 --> <Search client:load /> <!-- 音乐播放器开关 --> {musicPlayerConfig.showInNavbar && ( <button id="music-player-switch" class="btn-plain rounded-lg h-11 w-11 navbar-icon-btn active:scale-90"> <Icon name="material-symbols:music-note-rounded" class="text-[1.25rem]" /> </button> )} <!-- 显示设置开关 --> {hasDisplaySettings && ( <button id="display-settings-switch" class="btn-plain rounded-lg h-11 w-11 navbar-icon-btn active:scale-90"> <Icon name="material-symbols:palette-outline" class="text-[1.25rem]" /> </button> )} <!-- 亮暗切换 --> <LightDarkSwitch client:load /> <!-- 汉堡菜单 (移动端) --> <button id="nav-menu-switch" class="btn-plain rounded-lg w-11 h-11 lg:hidden! nav-hamburger-btn"> <Icon name="material-symbols:menu-rounded" class="text-[1.5rem] hamburger-icon-menu" /> <Icon name="material-symbols:close-rounded" class="text-[1.5rem] hamburger-icon-close hidden" /> </button> </div>
<!-- 音乐面板(浮动) --> {musicPlayerConfig.showInNavbar && ( <div id="music-nav-panel" class="float-panel float-panel-closed absolute transition-all right-16 p-2 pt-4.5 w-80 z-50"> <MusicPlayer /> </div> )}
<!-- 移动端菜单面板 --> <NavMenuPanel links={links} siteTitle={navbarTitle} />
<!-- 显示设置面板 --> <DisplaySettings client:load /> </div></div>关键技术细节:
- Logo 智能加载:通过
import.meta.glob扫描src目录下的所有图片,匹配到本地图片后交给 Astro 的<Picture>组件进行自动优化(格式转换、压缩、响应式),而 public 和远程图片则使用原生<img> - 条件渲染:音乐、显示设置、搜索等模块都是条件渲染的,可以通过配置文件一键开关
- 数据属性传递:透明模式、毛玻璃开关、全宽模式等通过
data-*属性传递给 CSS,实现纯 CSS 驱动样式变化
4.3 DropdownMenu.astro — 桌面端下拉菜单
DropdownMenu.astro 处理每个导航链接,自动判断是否有子菜单:
---import { Icon } from "astro-icon/components";import DropdownItem from "@/components/common/DropdownItem.astro";import DropdownPanel from "@/components/common/DropdownPanel.astro";import { LinkPresets } from "@/constants/link-presets";import { LinkPreset, type NavBarLink } from "@/types/config";import { url } from "@/utils/url-utils";
interface Props { link: NavBarLink; class?: string;}
const { link, class: className } = Astro.props;
if (!link) return null;
// 将子菜单中的 LinkPreset 枚举转为真实的 NavBarLink 对象const processedLink = { ...link, children: link.children ?.map((child: NavBarLink | LinkPreset): NavBarLink | null => { if (typeof child === "number") { if (child in LinkPresets) return LinkPresets[child as LinkPreset]; return null; } return child; }) .filter((child): child is NavBarLink => child !== null),};
const hasChildren = processedLink.children && processedLink.children.length > 0;---
<div class:list={["dropdown-container", className]} data-dropdown> {hasChildren ? ( <> <!-- 有子菜单:渲染为 button,点击展开下拉 --> <button class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95 dropdown-trigger-link navbar-link-item" data-dropdown-trigger aria-haspopup="true" aria-expanded="false" > <div class="flex items-center"> {processedLink.icon && ( <Icon name={processedLink.icon} class="text-[1.1rem] mr-2 navbar-icon" /> )} <span class="navbar-link-text">{processedLink.name}</span> <Icon name="material-symbols:keyboard-arrow-down-rounded" class="text-[1.25rem] transition-transform duration-200 dropdown-arrow ml-1" /> </div> </button> <!-- 下拉面板 --> <div class="dropdown-menu" data-dropdown-menu> <DropdownPanel class="dropdown-content"> {processedLink.children?.map((child, index) => ( <DropdownItem href={child.external ? child.url : url(child.url)} target={child.external ? "_blank" : undefined} isLast={index === (processedLink.children?.length || 0) - 1} class="dropdown-item" > {child.icon && ( <Icon name={child.icon} class="text-[1.25rem] mr-3 navbar-icon" /> )} <span>{child.name}</span> {child.external && ( <Icon name="fa7-solid:arrow-up-right-from-square" class="text-[0.75rem] text-black/25 dark:text-white/25 ml-auto" /> )} </DropdownItem> ))} </DropdownPanel> </div> </> ) : ( <!-- 无子菜单:渲染为普通链接 --> <a href={processedLink.external ? processedLink.url : url(processedLink.url)} target={processedLink.external ? "_blank" : null} class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95 navbar-link-item" > <div class="flex items-center"> {processedLink.icon && ( <Icon name={processedLink.icon} class="text-[1.1rem] mr-2 navbar-icon" /> )} <span class="navbar-link-text">{processedLink.name}</span> {processedLink.external && ( <Icon name="fa7-solid:arrow-up-right-from-square" class="text-[0.875rem] -translate-y-px ml-1 text-black/20 dark:text-white/20" /> )} </div> </a> )}</div>下拉菜单交互脚本(is
(function() { function initDesktopDropdowns() { var dropdowns = document.querySelectorAll('[data-dropdown]');
dropdowns.forEach(function(dropdown) { var trigger = dropdown.querySelector('[data-dropdown-trigger]'); var menu = dropdown.querySelector('[data-dropdown-menu]'); if (!trigger || !menu) return;
// 克隆节点清除旧事件监听器 var newTrigger = trigger.cloneNode(true); trigger.parentNode && trigger.parentNode.replaceChild(newTrigger, trigger);
// 点击切换下拉 newTrigger.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); dropdown.classList.toggle('dropdown-open'); var isOpen = dropdown.classList.contains('dropdown-open'); newTrigger.setAttribute('aria-expanded', String(isOpen)); });
// 键盘导航:ArrowDown 展开,Escape 关闭 newTrigger.addEventListener('keydown', function(e) { if (e.key === 'ArrowDown') { e.preventDefault(); dropdown.classList.add('dropdown-open'); newTrigger.setAttribute('aria-expanded', 'true'); var firstItem = menu.querySelector('.dropdown-item'); if (firstItem) firstItem.focus(); } else if (e.key === 'Escape') { dropdown.classList.remove('dropdown-open'); newTrigger.setAttribute('aria-expanded', 'false'); newTrigger.focus(); } }); });
// 点击外部关闭所有下拉 document.addEventListener('click', function(e) { dropdowns.forEach(function(dropdown) { if (!dropdown.contains(e.target)) { dropdown.classList.remove('dropdown-open'); var trigger = dropdown.querySelector('[data-dropdown-trigger]'); if (trigger) trigger.setAttribute('aria-expanded', 'false'); } }); });
// Escape 关闭所有 document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { dropdowns.forEach(function(dropdown) { dropdown.classList.remove('dropdown-open'); var trigger = dropdown.querySelector('[data-dropdown-trigger]'); if (trigger) { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); } }); }); }); }
// 首次加载 + Swup 导航后重新初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initDesktopDropdowns); } else { initDesktopDropdowns(); } document.addEventListener('swup:contentReplaced', initDesktopDropdowns);})();CSS 下拉动画:
.dropdown-menu { @apply absolute top-full left-0 pt-2 opacity-0 invisible pointer-events-none transition-all duration-200 ease-out transform translate-y-[-8px] z-50;}
/* 悬停或激活时显示 */.dropdown-container:hover .dropdown-menu,.dropdown-container:focus-within .dropdown-menu,.dropdown-container.dropdown-open .dropdown-menu { @apply opacity-100 visible pointer-events-auto translate-y-0;}
/* 箭头旋转动画 */.dropdown-container:hover .dropdown-arrow,.dropdown-container.dropdown-open .dropdown-arrow { @apply rotate-180;}4.4 NavMenuPanel.astro — 移动端侧滑菜单
NavMenuPanel.astro 实现了移动端的侧滑抽屉菜单,包含 个人信息卡片 + 导航链接列表:
核心结构:
<!-- 遮罩层 --><div id="nav-menu-overlay" class="menu-overlay"></div>
<!-- 侧滑面板 --><div id="nav-menu-panel" class="menu-panel" data-menu-open="false" role="dialog" aria-modal="true" aria-hidden="true">
<!-- Profile Card: 头像、昵称、简介、统计数据、CTA按钮、社交链接 --> <div class="menu-profile"> <a href="/about/" class="group relative block w-20 h-20 mb-3 rounded-full overflow-hidden border-2 border-white dark:border-neutral-700"> <ImageWrapper src={profileConfig.avatar} alt="Profile Image" class="w-full h-full object-cover" loading="eager" fetchpriority="high" /> </a> <div class="text-center"> <div class="font-bold text-lg">{profileConfig.name}</div> <div class="text-xs text-neutral-500">{profileConfig.bio}</div> </div> <!-- 统计数据:文章数、标签数、动态数 --> <div class="flex w-full justify-around py-3 border-y"> {stats.map(stat => ( <div class="flex flex-col items-center"> <span class="text-[10px]">{stat.label}</span> <span class="text-base font-bold">{stat.count}</span> </div> ))} </div> <!-- CTA 按钮和社交图标 --> ... </div>
<!-- 导航链接 --> <nav class="menu-nav"> {processedLinks.map((link) => ( <div> {link.children?.length > 0 ? ( <!-- 有子菜单:可折叠展开 --> <div class="menu-dropdown" data-mobile-dropdown> <button class="menu-link" data-mobile-dropdown-trigger> {link.icon && <Icon name={link.icon} class="menu-link-icon" />} <span class="menu-link-text">{link.name}</span> <Icon name="material-symbols:chevron-right-rounded" class="menu-arrow" /> </button> <div class="menu-submenu" data-mobile-submenu> {link.children.map((child) => ( <a href={child.url} class="menu-sublink"> {child.icon && <Icon name={child.icon} class="menu-link-icon sub-icon" />} <span>{child.name}</span> </a> ))} </div> </div> ) : ( <!-- 无子菜单:直接链接 --> <a href={link.url} class="menu-link"> {link.icon && <Icon name={link.icon} class="menu-link-icon" />} <span class="menu-link-text">{link.name}</span> </a> )} </div> ))} </nav></div>交互逻辑:
// 打开菜单:显示遮罩、侧滑面板、锁定body滚动function openMenu() { ensureMenuPortal(); // 确保面板挂载到body下(避免被overflow裁剪) resetDropdowns(); panel.setAttribute("data-menu-open", "true"); overlay.classList.add("active"); btn.classList.add("hamburger-open"); document.body.style.overflow = "hidden";}
// 关闭菜单:恢复所有状态function closeMenu() { panel.setAttribute("data-menu-open", "false"); overlay.classList.remove("active"); btn.classList.remove("hamburger-open"); document.body.style.overflow = ""; resetDropdowns();}
// 注册事件:astro:page-load + swup:page:viewdocument.addEventListener("astro:page-load", init);document.addEventListener("swup:contentReplaced", onPageTransition);CSS 动画:
.menu-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(82vw, 20rem); height: 100dvh; background: var(--card-bg); transform: translate3d(100%, 0, 0); /* 初始在屏幕外 */ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);}.menu-panel[data-menu-open="true"] { transform: translate3d(0, 0, 0); /* 滑入 */}
/* 子菜单折叠动画 */.menu-submenu { max-height: 0; overflow: hidden; transition: max-height 0.25s ease;}.menu-dropdown[data-expanded="true"] .menu-submenu { max-height: 20rem;}.menu-dropdown[data-expanded="true"] .menu-arrow { transform: rotate(90deg); /* 箭头旋转 */}设计亮点:
ensureMenuPortal()将面板挂载到 body 下,避免被父容器的overflow: hidden裁剪- 侧滑面板使用
translate3d触发 GPU 加速,确保流畅动画 - 子菜单使用
max-height过渡实现折叠动画,同时箭头旋转 90°
4.5 公共组件:DropdownPanel 和 DropdownItem
DropdownPanel.astro — 下拉面板容器:
<div class:list={["float-panel p-2", className]} role="none"> <slot /></div>DropdownItem.astro — 下拉选项项:
---interface Props { href?: string; target?: string; isActive?: boolean; isLast?: boolean; class?: string; onclick?: string;}
const Tag = href ? "a" : "button";---
<Tag href={href} target={target} class={allClasses} onclick={onclick}> <slot /></Tag>这两个组件非常精简,作为通用 UI 原子被导航栏下拉菜单、亮暗色切换面板、显示设置面板等多处复用。
4.6 Search.svelte — 搜索组件
Search.svelte 基于 Pagefind 实现全文搜索:
<script lang="ts">import { onMount } from "svelte";
let keywordDesktop = "";let keywordMobile = "";let result: SearchResult[] = [];let isSearching = false;let initialized = false;
// 300ms 防抖搜索const search = async (keyword: string, isDesktop: boolean): Promise<void> => { if (!keyword) { setPanelVisibility(false, isDesktop); return; } if (!initialized) return;
isSearching = true; clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { try { let searchResults: SearchResult[] = []; if (import.meta.env.PROD && window.pagefind) { const response = await window.pagefind.search(keyword); searchResults = await Promise.all( response.results.map((item) => item.data()), ); } else if (import.meta.env.DEV) { searchResults = fakeResult; // 开发环境使用假数据 } result = searchResults; } finally { isSearching = false; } }, 300);};
onMount(() => { // 等待 Pagefind 初始化完成 if (window.pagefind) { initialized = true; } else { document.addEventListener("pagefindready", () => { initialized = true; }); }});</script>
<!-- 桌面端搜索框:favorite 展开 --><div id="search-bar" class="hidden lg:flex ..."> <Icon icon="material-symbols:search" /> <input bind:value={keywordDesktop} class="h-full w-40 focus:w-60 transition-all ..." /></div>
<!-- 移动端搜索按钮 --><button id="search-switch" class="btn-plain lg:hidden! ..."> <Icon icon="material-symbols:search" /></button>
<!-- 搜索结果面板 --><div id="search-panel" class="float-panel float-panel-closed ..."> {#if isSearching} <div>搜索中...</div> {:else if result.length > 0} {#each result.slice(0, 5) as item} <a href={item.url} class="group block rounded-xl px-3 py-2 ..."> <div class="flex items-center"> {@html item.meta.title} <Icon icon="fa7-solid:chevron-right" /> </div> {#if item.excerpt.includes('<mark>')} <div class="flex items-start mt-0.5"> <span class="badge">摘要</span> <div>{@html item.excerpt}</div> </div> {/if} </a> {/each} {#if result.length > 5} <a href={getSearchUrl(keyword)}>查看更多结果 ({result.length - 5} 个更多)</a> {/if} {:else if result.length === 0} <div>找不到相关结果</div> {/if}</div>4.7 LightDarkSwitch.svelte — 亮暗色切换
LightDarkSwitch.svelte 集成在导航栏右侧:
<script lang="ts">import { DARK_MODE, LIGHT_MODE, SYSTEM_MODE } from "@/constants/constants";import type { LIGHT_DARK_MODE } from "@/types/config";import { getStoredTheme, setTheme } from "@/utils/setting-utils";
let mode: LIGHT_DARK_MODE = $state(LIGHT_MODE);let displayedMode: LIGHT_DARK_MODE = $state(LIGHT_MODE);
function switchScheme(newMode: LIGHT_DARK_MODE) { mode = newMode; setTheme(newMode);}
// 在 system 模式下显示实际主题图标function updateDisplayedMode() { if (mode === SYSTEM_MODE) { const isSystemDark = window.matchMedia( "(prefers-color-scheme: dark)", ).matches; displayedMode = isSystemDark ? DARK_MODE : LIGHT_MODE; } else { displayedMode = mode; }}
onMount(() => { const storedTheme = getStoredTheme(); mode = storedTheme; updateDisplayedMode(); // 监听系统主题变化 if (storedTheme === SYSTEM_MODE) { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); mediaQuery.addEventListener("change", () => updateDisplayedMode()); }});</script>
<div class="relative z-50"> <button id="scheme-switch" class="btn-plain rounded-lg h-11 w-11 navbar-icon-btn"> <!-- 使用 opacity 切换太阳/月亮图标 --> <div class:opacity-0={displayedMode !== LIGHT_MODE}> <Icon icon="material-symbols:wb-sunny-outline-rounded" /> </div> <div class:opacity-0={displayedMode !== DARK_MODE}> <Icon icon="material-symbols:dark-mode-outline-rounded" /> </div> </button>
<!-- 下拉面板:亮色/暗色/跟随系统 --> <div id="theme-mode-panel" class="float-panel-closed ..."> <DropdownPanel> <DropdownItem isActive={mode === LIGHT_MODE} onclick={() => switchScheme(LIGHT_MODE)}> {i18n(I18nKey.lightMode)} </DropdownItem> <DropdownItem isActive={mode === DARK_MODE} onclick={() => switchScheme(DARK_MODE)}> {i18n(I18nKey.darkMode)} </DropdownItem> <DropdownItem isActive={mode === SYSTEM_MODE} onclick={() => switchScheme(SYSTEM_MODE)}> {i18n(I18nKey.systemMode)} </DropdownItem> </DropdownPanel> </div></div>4.8 DisplaySettingsIntegrated.svelte — 显示设置面板
DisplaySettingsIntegrated.svelte 提供壁纸模式、主题色相、布局切换等设置:
<script lang="ts">import { WALLPAPER_BANNER, WALLPAPER_NONE, WALLPAPER_OVERLAY } from "@constants/constants";import { getHue, setHue, getStoredWallpaperMode, setWallpaperMode } from "@utils/setting-utils";
let hue = $state(getHue());let wallpaperMode: WALLPAPER_MODE = $state(backgroundWallpaper.mode);let currentLayout: "list" | "grid" = $state("list");
function switchWallpaperMode(newMode: WALLPAPER_MODE) { wallpaperMode = newMode; setWallpaperMode(newMode);}
function switchLayout() { currentLayout = currentLayout === "list" ? "grid" : "list"; localStorage.setItem("postListLayout", currentLayout); window.dispatchEvent(new CustomEvent("layoutChange", { detail: { layout: currentLayout } }));}</script>
<div id="display-setting" class="float-panel float-panel-closed ..."> <!-- 主题色相滑块 --> <div> <span>主题色相</span> <input type="range" min="0" max="360" bind:value={hue} step="5" /> </div>
<!-- 壁纸模式切换(横幅/全屏透明/纯色) --> <div> <button onclick={() => switchWallpaperMode(WALLPAPER_BANNER)}>横幅壁纸</button> <button onclick={() => switchWallpaperMode(WALLPAPER_OVERLAY)}>全屏壁纸</button> <button onclick={() => switchWallpaperMode(WALLPAPER_NONE)}>纯色背景</button> </div>
<!-- 文章布局切换(列表/网格) --> <div> <button onclick={switchLayout}>列表</button> <button onclick={switchLayout}>网格</button> </div></div>4.9 按钮交互脚本
Navbar.astro 中的内联脚本处理所有浮动面板的打开/关闭和点击外部关闭逻辑:
function loadButtonScript() { // 显示设置按钮 → 切换面板 document.getElementById("display-settings-switch").onclick = () => { document.getElementById("display-setting") ?.classList.toggle("float-panel-closed"); };
// 音乐按钮 → 切换面板 document.getElementById("music-player-switch").onclick = () => { document.getElementById("music-nav-panel") ?.classList.toggle("float-panel-closed"); };
// 主题按钮 → 切换面板 document.getElementById("scheme-switch").onclick = () => { document.getElementById("theme-mode-panel") ?.classList.toggle("float-panel-closed"); };
// 点击外部关闭所有面板 document.addEventListener("click", (event) => { const panels = ["display-setting", "music-nav-panel", "theme-mode-panel"]; const buttons = ["display-settings-switch", "music-player-switch", "scheme-switch"]; panels.forEach((id, i) => { const panel = document.getElementById(id); const button = document.getElementById(buttons[i]); if (panel && button && !panel.classList.contains("float-panel-closed")) { if (!panel.contains(event.target) && !button.contains(event.target)) { panel.classList.add("float-panel-closed"); } } }); });}五、样式层 — 导航栏 CSS
5.1 透明模式系统
navbar.css 实现了三种透明模式的完整样式,按屏幕尺寸和亮暗主题分四组定义:
| 模式 | 外观 | 行为 |
|---|---|---|
semi | 毛玻璃半透明 + 阴影 | 始终可见 |
full | 完全透明,无阴影 | 始终透明 |
semifull | 初始透明 → 滚动后变半透明 | 动态切换 |
CSS 变量注入:
<!-- Navbar.astro 中通过 style 属性注入 ---><div id="navbar" style="--navbar-glass-blur: ${navbarBlur}px;" data-transparent-mode="semifull" data-enable-blur="true">桌面端亮色 - semifull 模式:
/* 初始状态:完全透明 */#navbar[data-transparent-mode="semifull"] > div { background: transparent !important; border: none !important; box-shadow: none !important;}
/* 滚动后(.scrolled):半透明毛玻璃 */#navbar[data-transparent-mode="semifull"].scrolled > div { backdrop-filter: blur(var(--navbar-glass-blur, 20px)) !important; background: rgba(255, 255, 255, 0.55) !important; border: 1px solid rgba(255, 255, 255, 0.55) !important; box-shadow: var(--shadow-navbar) !important;}暗色主题同理:
:root.dark #navbar[data-transparent-mode="semifull"].scrolled > div { background: rgba(0, 0, 0, 0.55) !important; border: 1px solid rgba(0, 0, 0, 0.55) !important; box-shadow: var(--shadow-navbar-dark) !important;}关闭毛玻璃:
#navbar[data-enable-blur="false"] > div { backdrop-filter: none !important;}5.2 滚动检测脚本
Navbar.astro 中的 semifull 滚动检测使用 requestAnimationFrame 优化性能:
function initSemifullScrollDetection() { const navbar = document.getElementById('navbar'); if (!navbar) return; if (navbar.getAttribute('data-transparent-mode') !== 'semifull') return;
let ticking = false;
function updateNavbarState() { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const threshold = 50; // 滚动50px后触发状态切换 if (scrollTop > threshold) { navbar.classList.add('scrolled'); } else { navbar.classList.remove('scrolled'); } ticking = false; }
function requestTick() { if (!ticking) { requestAnimationFrame(updateNavbarState); ticking = true; } }
window.addEventListener('scroll', requestTick, { passive: true });}
// 页面加载完成 + Swup导航后重新初始化document.addEventListener('astro:page-load', initSemifullScrollDetection);document.addEventListener('swup:contentReplaced', initSemifullScrollDetection);5.3 导航链接下划线效果
全部导航链接(包括 Logo、菜单项、图标按钮)都使用主题色渐变下划线:
/* 菜单链接 */#navbar .dropdown-container > a .navbar-link-text::after { content: ''; position: absolute; bottom: -2px; left: 0; width: 100%; height: 2px; background: linear-gradient(90deg, var(--primary), var(--primary)); border-radius: 1px; transform: scaleX(0); transform-origin: center; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);}
#navbar .dropdown-container > a:hover .navbar-link-text::after { transform: scaleX(1);}
/* Logo 同款下划线 */.navbar-logo-content::after { content: ''; position: absolute; bottom: -4px; left: 0; width: 100%; height: 2px; background: linear-gradient(90deg, var(--primary), var(--primary)); transform: scaleX(0); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);}
/* 图标按钮下划线 */.navbar-icon-btn::after { content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%) scaleX(0); width: 20px; height: 2px; background: linear-gradient(90deg, var(--primary), var(--primary)); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);}5.4 响应式适配
/* 平板端隐藏导航栏图标,节省空间 */@media (max-width: 1199px) { .navbar-icon { display: none !important; }}
/* 窗口缩小逐渐缩小文字 */@media (max-width: 1400px) { #navbar .dropdown-container > a { font-size: 0.875rem !important; }}
/* 移动端隐藏下拉菜单,汉堡菜单接管 */@media (max-width: 768px) { .dropdown-container { display: none; }}
/* 桌面端隐藏侧滑菜单 */@media (min-width: 1024px) { .menu-overlay, .menu-panel { display: none !important; }}5.5 全宽导航栏
@media (min-width: 1024px) { #navbar[data-full-width="true"] > div { border-radius: 0 !important; width: 100% !important; max-width: none !important; }}六、架构总结
整个导航栏模块的完整构建方案可以概括为以下架构图:
┌─────────────────────────────────────────────────────────┐│ 五层架构 │├─────────────────┬───────────────────────────────────────┤│ 1. 类型定义层 │ src/types/config.ts ││ │ - LinkPreset 枚举 ││ │ - NavBarLink 类型 ││ │ - NavBarConfig 类型 │├─────────────────┼───────────────────────────────────────┤│ 2. 常量层 │ src/constants/link-presets.ts ││ │ - LinkPresets 对象映射 ││ │ - i18n 集成(多语言文本) │├─────────────────┼───────────────────────────────────────┤│ 3. 配置层 │ src/config/navBarConfig.ts ││ │ - 动态菜单生成 ││ │ - 页面开关控制子菜单 ││ │ src/config/siteConfig.ts ││ │ - Logo, 标题, 全宽 ││ │ src/config/backgroundWallpaper.ts ││ │ - 透明模式, 毛玻璃 │├─────────────────┼───────────────────────────────────────┤│ 4. 组件层 │ Navbar.astro (主容器) ││ │ ├── DropdownMenu.astro (桌面下拉) ││ │ ├── NavMenuPanel.astro (移动端侧滑) ││ │ ├── Search.svelte (搜索) ││ │ ├── LightDarkSwitch.svelte (主题切换) ││ │ ├── DisplaySettings.svelte (显示设置) ││ │ ├── DropdownPanel/DropdownItem (通用) ││ │ └── 嵌入位置: MainGridLayout.astro │├─────────────────┼───────────────────────────────────────┤│ 5. 样式层 │ src/styles/navbar.css ││ │ - 3种透明模式 × 2个主题 × 2个设备 ││ │ - 下划线hover动画 ││ │ - 响应式适配 ││ │ - 全宽/半宽适配 ││ │ DropdownMenu内联样式 ││ │ - 下拉动画 ││ │ NavMenuPanel内联样式 ││ │ - 侧滑动画 + 子菜单折叠 │└─────────────────┴───────────────────────────────────────┘核心设计思想:
- 关注点分离:类型 → 常量 → 配置 → 组件 → 样式,每层只关心自己的职责
- 条件渲染:所有可选模块通过配置文件控制,无需修改组件代码
- i18n 优先:所有展示文本通过
i18n()函数获取,预设链接也使用 i18n 文本 - 性能优先:滚动检测使用
requestAnimationFrame节流,下拉菜单使用 CSS 动画而非 JS 动画 - 多端统一:桌面端下拉菜单 + 移动端侧滑面板,同一套配置数据驱动两种 UI 形态
- GPU 加速:透明效果使用
backdrop-filter+ GPU 层的translate3d,避免重绘
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
评论区
如果你喜欢,那么欢迎来到我的世界!
了解更多