国庆节10月1日
--


深度解析导航栏模块的完整架构设计,包含类型定义、配置系统、预设映射、多端适配、下拉菜单、搜索栏、亮暗色切换、音乐播放器、毛玻璃效果等全部实现代码与原理。

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



"所有的相遇都是久别重逢。"—— 未知


