Loading
5403 字
27 分钟

博客导航栏模块完整构建方案

AI 摘要

概述#

本文详细介绍博客导航栏模块的完整构建方案。导航栏是整个博客最关键的用户交互入口,它承载了:

  • 品牌标识: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.tsLinkPreset 枚举值解析为完整的 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>

关键技术细节

  1. Logo 智能加载:通过 import.meta.glob 扫描 src 目录下的所有图片,匹配到本地图片后交给 Astro 的 <Picture> 组件进行自动优化(格式转换、压缩、响应式),而 public 和远程图片则使用原生 <img>
  2. 条件渲染:音乐、显示设置、搜索等模块都是条件渲染的,可以通过配置文件一键开关
  3. 数据属性传递:透明模式、毛玻璃开关、全宽模式等通过 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:view
document.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内联样式 │
│ │ - 侧滑动画 + 子菜单折叠 │
└─────────────────┴───────────────────────────────────────┘

核心设计思想

  1. 关注点分离:类型 → 常量 → 配置 → 组件 → 样式,每层只关心自己的职责
  2. 条件渲染:所有可选模块通过配置文件控制,无需修改组件代码
  3. i18n 优先:所有展示文本通过 i18n() 函数获取,预设链接也使用 i18n 文本
  4. 性能优先:滚动检测使用 requestAnimationFrame 节流,下拉菜单使用 CSS 动画而非 JS 动画
  5. 多端统一:桌面端下拉菜单 + 移动端侧滑面板,同一套配置数据驱动两种 UI 形态
  6. GPU 加速:透明效果使用 backdrop-filter + GPU 层的 translate3d,避免重绘

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
博客导航栏模块完整构建方案
https://blog.tsh520.cn/posts/博客指南/导航栏模块完整构建方案/
作者
团子和蛋糕
发布于
2026-05-29
许可协议
CC BY-NC-SA 4.0

评论区

[ 公告 ]

如果你喜欢,那么欢迎来到我的世界!

了解更多
[ 音乐 ]
封面

音乐

暂未播放

0:00 0:00
暂无歌词
找不到相关结果。
[ contents ]
[ 全部文章 ]
我和宝宝在一起已经
---------TSH CXY---------
---------TSH
CXY---------
0 0 0
00 00 00
最近更新
站点统计
文章
84
动态
20
记录次数
89
分类
6
标签
78
总字数
94,329
运行时长
0
最后活动
0 天前