给博客添加多级菜单功能
📝 前言
目前我使用的这个博客模版是基于Astro的fuwari开源博客模版。🌟
我也是经过了一系列魔改给博客添加了如下功能:🛠️
- 🎵 音乐播放器
- 📌 文章置顶固定(原作者仓库未合并的PR中找到的)
- 💬 评论系统【基于twikoo】
- 🔗 友链页面
- 🎬 首图支持视频
如果对于这些功能感兴趣的可以参考下面的地址进行修改添加。🔧
Github地址:https://github.com/ZyPLJ/fuwai_zyplj
我的旧站也是基于开源项目进行魔改的,旧站的菜单非常之多,迁移到新增后舍弃了很多菜单。因为新博客默认是不支持多级菜单的,我在之前在原作者项目中提过PR,不过这种小功能,作者肯定是没时间去弄的,而且可能不符合作者的设计理念。🤔
那么只能自己动手做了,在提PR之前我就自己尝试过,但是有点缺陷,后面也就没有提交代码,我的初版如图:

由于有点缺陷我也就没继续做下去,后面也是有人在PR中提到自己已经实现了,那我这次也是为后面的功能做准备,必须要把多级菜单搞定了。💪
🚀 开始操作
这次具体实现是参考 Mizuki主题库实现的,参考下来,发现设计思路和我最初版是一致的,哈哈。😄
不过我把菜单图标去掉了,如果有喜欢菜单图标的可以自己参考然后修改,不是很难。
⚙️ config.ts
一步一步来,先在types->config.ts中的NavBarLink类型变量添加children类型声明
export type NavBarLink = { name: string; url: string; external?: boolean; children?: (NavBarLink | LinkPreset)[]; // 支持子菜单,可以是NavBarLink或LinkPreset};🎨 main.css
styles->main.css然后把css搞定,直接复制拿来用,注意放在text-25类名后面
.text-25 { @apply text-black/25 dark:text-white/25}
/* 下拉菜单样式 */.dropdown-container { @apply relative;}
.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 { @apply opacity-100 visible pointer-events-auto translate-y-0;}
.dropdown-container:hover .dropdown-arrow,.dropdown-container:focus-within .dropdown-arrow { @apply rotate-180;}
.dropdown-content { @apply bg-[var(--float-panel-bg)] rounded-[var(--radius-large)] shadow-xl dark:shadow-none border border-black/5 dark:border-white/10 py-2 min-w-[12rem];}
.dropdown-item { @apply flex items-center justify-between px-4 py-2.5 text-black/75 dark:text-white/75 hover:text-[var(--primary)] hover:bg-[var(--btn-plain-bg-hover)] transition-colors duration-150 font-medium;}
.dropdown-item:first-child { @apply rounded-t-[calc(var(--radius-large)-0.5rem)];}
.dropdown-item:last-child { @apply rounded-b-[calc(var(--radius-large)-0.5rem)];}
/* 移动端菜单样式 */.mobile-submenu { @apply max-h-0 overflow-hidden transition-all duration-300 ease-in-out;}
.mobile-dropdown[data-expanded="true"] .mobile-submenu { @apply max-h-96;}
.mobile-dropdown[data-expanded="true"] .mobile-dropdown-arrow { @apply rotate-180;}
/* 响应式隐藏 */@media (max-width: 768px) { .dropdown-container { @apply hidden; }}
/* 无障碍支持 */.dropdown-container:focus-within .dropdown-menu { @apply opacity-100 visible pointer-events-auto translate-y-0;}
.dropdown-item:focus { @apply outline-none;}
.mobile-dropdown button:focus { @apply outline-none;}🧩 DropdownMenu.astro
components->widget->DropdownMenu.astro
接下来新建DropdownMenu.astro组件把代码复制过来,代码比较多,就不细讲了,我也是用AI优化了一下js部分代码,为什么要优化,因为我在执行pnpm check代码检查的时候报错了,大部分是ts类型错误,如图:

---import { Icon } from "astro-icon/components";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;
// 转换子菜单中的LinkPreset为NavBarLinkconst processedLink = { ...link, children: link.children?.map((child: NavBarLink | LinkPreset): NavBarLink => { if (typeof child === "number") { return LinkPresets[child]; } return child; }),};
const hasChildren = processedLink.children && processedLink.children.length > 0;---
<div class:list={["dropdown-container", className]} data-dropdown> {hasChildren ? ( <button class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95 dropdown-trigger" aria-expanded="false" aria-haspopup="true" data-dropdown-trigger > <div class="flex items-center"> {processedLink.name} <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> <div class="dropdown-content"> {processedLink.children?.map((child) => ( <a href={child.external ? child.url : url(child.url)} target={child.external ? "_blank" : null} class="dropdown-item" aria-label={child.name} > <div class="flex items-center"> <span>{child.name}</span> </div> {child.external && ( <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.75rem] text-black/25 dark:text-white/25" /> )} </a> ))} </div> </div> ) : ( <a aria-label={processedLink.name} 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" > <div class="flex items-center"> {processedLink.name} {processedLink.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]" />} </div> </a> )}</div>
<style> .dropdown-container { @apply relative; }
.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 { @apply opacity-100 visible pointer-events-auto translate-y-0; }
.dropdown-container:hover .dropdown-arrow, .dropdown-container:focus-within .dropdown-arrow { @apply rotate-180; }
.dropdown-content { @apply bg-[var(--float-panel-bg)] rounded-[var(--radius-large)] shadow-xl dark:shadow-none border border-black/5 dark:border-white/10 py-2 min-w-[12rem]; }
.dropdown-item { @apply flex items-center justify-between px-4 py-2.5 text-black/75 dark:text-white/75 hover:text-[var(--primary)] hover:bg-[var(--btn-plain-bg-hover)] transition-colors duration-150 font-medium; }
.dropdown-item:first-child { @apply rounded-t-[calc(var(--radius-large)-0.5rem)]; }
.dropdown-item:last-child { @apply rounded-b-[calc(var(--radius-large)-0.5rem)]; }
/* 移动端隐藏下拉菜单 */ @media (max-width: 768px) { .dropdown-container { @apply hidden; } }</style>
<script> // 键盘导航支持 document.addEventListener('DOMContentLoaded', () => { const dropdowns = document.querySelectorAll('[data-dropdown]');
// 点击外部关闭下拉菜单 const handleDocumentClick = (e: Event) => { dropdowns.forEach(dropdown => { if (!dropdown.contains(e.target as Node)) { const trigger = dropdown.querySelector('[data-dropdown-trigger]') as HTMLElement; const menu = dropdown.querySelector('[data-dropdown-menu]') as HTMLElement; if (trigger && menu) { closeDropdown(trigger, menu); } } }); };
// 键盘事件处理函数 const handleTriggerKeydown = ( e: KeyboardEvent, trigger: HTMLElement, menu: HTMLElement, items: NodeListOf<HTMLElement> ) => { switch (e.key) { case 'Enter': case ' ': e.preventDefault(); toggleDropdown(trigger, menu); break; case 'ArrowDown': e.preventDefault(); openDropdown(trigger, menu); if (items.length > 0) { items[0].focus(); } break; case 'Escape': closeDropdown(trigger, menu); break; } };
// 菜单项键盘导航处理 const handleItemKeydown = ( e: KeyboardEvent, index: number, items: NodeListOf<HTMLElement>, trigger: HTMLElement, menu: HTMLElement ) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); const nextIndex = (index + 1) % items.length; items[nextIndex].focus(); break; case 'ArrowUp': e.preventDefault(); const prevIndex = (index - 1 + items.length) % items.length; items[prevIndex].focus(); break; case 'Escape': closeDropdown(trigger, menu); trigger.focus(); break; } };
// 初始化下拉菜单 dropdowns.forEach(dropdown => { const trigger = dropdown.querySelector('[data-dropdown-trigger]') as HTMLElement; const menu = dropdown.querySelector('[data-dropdown-menu]') as HTMLElement; const items = dropdown.querySelectorAll('.dropdown-item') as NodeListOf<HTMLElement>;
if (!trigger || !menu) return;
// 触发器键盘事件 trigger.addEventListener('keydown', (e) => { handleTriggerKeydown(e as KeyboardEvent, trigger, menu, items); });
// 菜单项键盘事件 items.forEach((item, index) => { item.addEventListener('keydown', (e) => { handleItemKeydown(e as KeyboardEvent, index, items, trigger, menu); }); }); });
document.addEventListener('click', handleDocumentClick); });
// 下拉菜单状态管理 const toggleDropdown = (trigger: HTMLElement, menu: HTMLElement) => { const isOpen = trigger.getAttribute('aria-expanded') === 'true'; isOpen ? closeDropdown(trigger, menu) : openDropdown(trigger, menu); };
const openDropdown = (trigger: HTMLElement, menu: HTMLElement) => { trigger.setAttribute('aria-expanded', 'true'); updateMenuClasses(menu, false); };
const closeDropdown = (trigger: HTMLElement, menu: HTMLElement) => { trigger.setAttribute('aria-expanded', 'false'); updateMenuClasses(menu, true); };
// 菜单类名管理 const updateMenuClasses = (menu: HTMLElement, isClosing: boolean) => { const classesToAdd = isClosing ? ['opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]'] : ['opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0'];
const classesToRemove = isClosing ? ['opacity-100', 'visible', 'pointer-events-auto', 'translate-y-0'] : ['opacity-0', 'invisible', 'pointer-events-none', 'translate-y-[-8px]'];
menu.classList.remove(...classesToRemove); menu.classList.add(...classesToAdd); };</script>📱 NavMenuPanel.astro
components->widget->NavMenuPanel.astro
NavMenuPanel.astro是fuwari主题原本的菜单组件,也是需要修改的,因为修改的地方还比较多,我就直接把全部代码贴出来。
---import { Icon } from "astro-icon/components";import { LinkPresets } from "../../constants/link-presets";import { LinkPreset, type NavBarLink } from "../../types/config";import { url } from "../../utils/url-utils";
interface Props { links: NavBarLink[];}
// 处理links中的LinkPreset转换const processedLinks = Astro.props.links.map( (link: NavBarLink): NavBarLink => ({ ...link, children: link.children?.map( (child: NavBarLink | LinkPreset): NavBarLink => { if (typeof child === "number") { return LinkPresets[child]; } return child; }, ), }),);---<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2 max-h-[80vh] overflow-y-auto"]}> {processedLinks.map((link) => ( <div class="mobile-menu-item"> {link.children && link.children.length > 0 ? ( <!-- 有子菜单的项目 --> <div class="mobile-dropdown" data-mobile-dropdown> <button class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8 w-full text-left hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition" data-mobile-dropdown-trigger aria-expanded="false" > <div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]"> {link.name} </div> <Icon name="material-symbols:keyboard-arrow-down-rounded" class="transition text-[1.25rem] text-[var(--primary)] mobile-dropdown-arrow duration-200" /> </button> <div class="mobile-submenu" data-mobile-submenu> {link.children.map((child) => { const childLink = child as NavBarLink; return ( <a href={childLink.external ? childLink.url : url(childLink.url)} class="group flex justify-between items-center py-2 pl-6 pr-1 rounded-lg gap-8 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition" target={childLink.external ? "_blank" : null} > <div class="transition text-black/60 dark:text-white/60 font-medium group-hover:text-[var(--primary)] group-active:text-[var(--primary)]"> {childLink.name} </div> {childLink.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1" />} </a> ); })} </div> </div> ) : ( <!-- 普通链接项目 --> <a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition " target={link.external ? "_blank" : null} > <div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]"> {link.name} </div> {!link.external && <Icon name="material-symbols:chevron-right-rounded" class="transition text-[1.25rem] text-[var(--primary)]" />} {link.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1" />} </a> )} </div> ))}</div>
<style> .mobile-submenu { @apply max-h-0 overflow-hidden transition-all duration-300 ease-in-out; }
.mobile-dropdown[data-expanded="true"] .mobile-submenu { @apply max-h-96; }
.mobile-dropdown[data-expanded="true"] .mobile-dropdown-arrow { @apply rotate-180; }</style>
<script> document.addEventListener('DOMContentLoaded', function() { const mobileDropdowns = document.querySelectorAll('[data-mobile-dropdown]');
mobileDropdowns.forEach(dropdown => { const trigger = dropdown.querySelector('[data-mobile-dropdown-trigger]'); const submenu = dropdown.querySelector('[data-mobile-submenu]');
if (!trigger || !submenu) return;
trigger.addEventListener('click', function(e) { e.preventDefault(); const isExpanded = dropdown.getAttribute('data-expanded') === 'true';
// 关闭其他打开的下拉菜单 mobileDropdowns.forEach(otherDropdown => { if (otherDropdown !== dropdown) { otherDropdown.setAttribute('data-expanded', 'false'); const otherTrigger = otherDropdown.querySelector('[data-mobile-dropdown-trigger]'); if (otherTrigger) { otherTrigger.setAttribute('aria-expanded', 'false'); } } });
// 切换当前下拉菜单 const newState = !isExpanded; dropdown.setAttribute('data-expanded', newState.toString()); trigger.setAttribute('aria-expanded', newState.toString()); }); }); });</script>🧭 Navbar.astro
components->Navbar.astro
接下来修改Navbar.astro组件差不多就快完工了,这个组件只修改了2处,首先导入我们上面创建的DropdownMenu组件,然后把原本的代码替换一下就行了。
import DropdownMenu from "./widget/DropdownMenu.astro";
/*<div class="hidden md:flex"> {links.map((l) => { return <a aria-label={l.name} href={l.external ? l.url : url(l.url)} target={l.external ? "_blank" : null} class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95" > <div class="flex items-center"> {l.name} {l.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>} </div> </a>; })}</div>*/<div class="hidden md:flex"> {links.map((l) => { return <DropdownMenu link={l} />; })}</div>⚙️ config.ts
src->config.ts
最后修改config.ts配置文件就行了
export const navBarConfig: NavBarConfig = { links: [ LinkPreset.Home, LinkPreset.Archive, LinkPreset.About, { name: "社交", url: "/links/", children: [LinkPreset.Links], }, { name: "其他", url: "/content/", children: [ LinkPreset.Images, // 如果没有lsky.pro图床,则注释掉 https://docs.lsky.pro/archive/free/v2/ { name: "网站监控", url: "https://stats.uptimerobot.com/f3bIMzwfwF", external: true, }, ], }, { name: "旧站", url: "https://pljzy.top", // Internal links should not include the base path, as it is automatically added external: true, // Show an external link icon and will open in a new tab }, { name: "开往🚆", url: "https://www.travellings.cn/go.html", // Internal links should not include the base path, as it is automatically added external: true, // Show an external link icon and will open in a new tab }, ],};🎉 实现效果
首先pnpm check✅

可以看到是没有错误的了
然后pnpm dev运行项目


📋 总结
对于fuwari博客模版添加多级菜单只需要修改6处地方。🔧
- 📝 types->config.ts
- 🎨 styles->main.css
- 🧩 components->widget->DropdownMenu.astro
- 📱 components->widget->NavMenuPanel.astro
- 🧭 components->Navbar.astro
- ⚙️ src->config.ts
对于开源的博客,自己虽然可以任意魔改,但要保持初心~ ❤️