给博客添加多级菜单功能
📝 前言
目前我使用的这个博客模版是基于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为NavBarLink
const 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
对于开源的博客,自己虽然可以任意魔改,但要保持初心~ ❤️