feat: group sidebar navigation

This commit is contained in:
焦龙言 2026-06-18 14:43:00 +08:00
parent dc74a618f7
commit 1e6230d412
4 changed files with 188 additions and 25 deletions

View File

@ -163,6 +163,7 @@ a {
:root[data-sidebar="edge"] .brand-title, :root[data-sidebar="edge"] .brand-title,
:root[data-sidebar="edge"] .brand-subtitle, :root[data-sidebar="edge"] .brand-subtitle,
:root[data-sidebar="edge"] .nav-section-title,
:root[data-sidebar="edge"] .nav-link { :root[data-sidebar="edge"] .nav-link {
color: white; color: white;
} }
@ -292,7 +293,8 @@ a {
} }
.app-shell.is-sidebar-collapsed .brand-copy, .app-shell.is-sidebar-collapsed .brand-copy,
.app-shell.is-sidebar-collapsed .nav-link span { .app-shell.is-sidebar-collapsed .nav-link span,
.app-shell.is-sidebar-collapsed .nav-section-title {
max-width: 0; max-width: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@ -315,6 +317,39 @@ a {
padding-inline: 0; padding-inline: 0;
} }
.app-shell.is-sidebar-collapsed .nav-section-title {
margin: 0;
}
.nav-section {
display: grid;
gap: 5px;
}
.nav-section + .nav-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.nav-section-title {
max-width: 180px;
margin: 0 8px 2px;
overflow: hidden;
color: var(--muted);
font-size: 12px;
font-weight: 700;
line-height: 1.4;
opacity: 0.78;
transform: translateX(0);
white-space: nowrap;
transition:
max-width var(--motion-duration) var(--motion-easing),
margin var(--motion-duration) var(--motion-easing),
opacity var(--motion-duration-fast) ease,
transform var(--motion-duration) var(--motion-easing);
}
.nav-link { .nav-link {
display: flex; display: flex;
align-items: center; align-items: center;
@ -325,7 +360,7 @@ a {
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: transparent; background: transparent;
color: var(--nav-text); color: var(--nav-text);
padding: 0 16px; padding: 0 14px;
text-align: left; text-align: left;
transition: transition:
background var(--motion-duration-fast) ease, background var(--motion-duration-fast) ease,

View File

@ -21,6 +21,7 @@ import {
import { computed } from "vue"; import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { buildSidebarSections } from "../navigation/sidebarMenu";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
defineProps<{ defineProps<{
@ -51,18 +52,15 @@ const backendIconMap = {
operation_logs: ScrollText, operation_logs: ScrollText,
}; };
const menuItems = computed(() => [ const menuSections = computed(() =>
{ buildSidebarSections(auth.menus).map((section) => ({
code: "dashboard", ...section,
name: "工作台", items: section.items.map((menu) => ({
path: "/dashboard", ...menu,
icon: LayoutDashboard, icon: menu.code === "dashboard" ? LayoutDashboard : backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
}, })),
...auth.menus.map((menu) => ({
...menu,
icon: backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
})), })),
]); );
function logout(): void { function logout(): void {
auth.logout(); auth.logout();
@ -86,18 +84,21 @@ function logout(): void {
</div> </div>
<nav class="sidebar-nav" aria-label="主导航"> <nav class="sidebar-nav" aria-label="主导航">
<RouterLink <section v-for="section in menuSections" :key="section.code" class="nav-section">
v-for="item in menuItems" <p class="nav-section-title">{{ section.title }}</p>
:key="item.code" <RouterLink
:to="item.path" v-for="item in section.items"
class="nav-link" :key="item.code"
active-class="is-active" :to="item.path"
:title="item.name" class="nav-link"
@click="emit('close')" active-class="is-active"
> :title="`${section.title} / ${item.name}`"
<component :is="item.icon" :size="21" aria-hidden="true" /> @click="emit('close')"
<span>{{ item.name }}</span> >
</RouterLink> <component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.name }}</span>
</RouterLink>
</section>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">

View File

@ -0,0 +1,81 @@
export interface SidebarMenuInput {
code: string;
name: string;
path: string;
}
export interface SidebarMenuSection {
code: string;
title: string;
items: SidebarMenuInput[];
}
const DASHBOARD_MENU: SidebarMenuInput = {
code: "dashboard",
name: "工作台",
path: "/dashboard",
};
const MENU_LABELS: Record<string, string> = {
monthly_payroll: "工资核算",
jobs: "计算记录",
commissions: "提成录入",
salary_profiles: "薪资档案",
reports: "工资报表",
};
const SECTION_DEFINITIONS: Array<{
code: string;
title: string;
itemCodes: string[];
}> = [
{
code: "core",
title: "核心工作",
itemCodes: ["dashboard", "attendance_realtime"],
},
{
code: "monthly",
title: "月度核算",
itemCodes: ["monthly_payroll", "jobs", "commissions"],
},
{
code: "employee",
title: "员工薪资",
itemCodes: ["employees", "salary_profiles", "organization"],
},
{
code: "reports",
title: "报表分析",
itemCodes: ["reports"],
},
{
code: "system",
title: "系统管理",
itemCodes: ["configs", "users", "operation_logs"],
},
];
export function buildSidebarSections(backendMenus: SidebarMenuInput[]): SidebarMenuSection[] {
const menuByCode = new Map<string, SidebarMenuInput>();
menuByCode.set(DASHBOARD_MENU.code, DASHBOARD_MENU);
for (const menu of backendMenus) {
menuByCode.set(menu.code, menu);
}
return SECTION_DEFINITIONS.map((section) => ({
code: section.code,
title: section.title,
items: section.itemCodes
.map((code) => menuByCode.get(code))
.filter((item): item is SidebarMenuInput => Boolean(item))
.map(normalizeMenuLabel),
})).filter((section) => section.items.length > 0);
}
function normalizeMenuLabel(menu: SidebarMenuInput): SidebarMenuInput {
return {
...menu,
name: MENU_LABELS[menu.code] || menu.name,
};
}

View File

@ -0,0 +1,46 @@
import {
buildSidebarSections,
type SidebarMenuInput,
} from "../src/navigation/sidebarMenu";
const backendMenus: SidebarMenuInput[] = [
{ code: "attendance_realtime", name: "实时考勤", path: "/attendance/realtime" },
{ code: "monthly_payroll", name: "月度核算", path: "/payroll/monthly" },
{ code: "payroll", name: "工资计算", path: "/payroll" },
{ code: "jobs", name: "计算记录", path: "/payroll/jobs" },
{ code: "commissions", name: "提成管理", path: "/payroll/commissions" },
{ code: "employees", name: "员工维护", path: "/system/employees" },
{ code: "salary_profiles", name: "薪资维护", path: "/system/salary-profiles" },
{ code: "organization", name: "组织架构", path: "/system/organization" },
{ code: "reports", name: "统计报表", path: "/reports" },
{ code: "configs", name: "规则配置", path: "/system/configs" },
{ code: "users", name: "用户管理", path: "/system/users" },
{ code: "operation_logs", name: "操作日志", path: "/system/operation-logs" },
];
const sections = buildSidebarSections(backendMenus);
assertEqual(
sections.map((section) => section.title).join(","),
"核心工作,月度核算,员工薪资,报表分析,系统管理",
);
assertEqual(sectionCodes("核心工作"), "dashboard,attendance_realtime");
assertEqual(sectionCodes("月度核算"), "monthly_payroll,jobs,commissions");
assertEqual(sectionCodes("员工薪资"), "employees,salary_profiles,organization");
assertEqual(sectionCodes("报表分析"), "reports");
assertEqual(sectionCodes("系统管理"), "configs,users,operation_logs");
function sectionCodes(title: string): string {
const section = sections.find((item) => item.title === title);
if (!section) {
throw new Error(`找不到菜单分组:${title}`);
}
return section.items.map((item) => item.code).join(",");
}
function assertEqual(actual: string, expected: string): void {
if (actual !== expected) {
throw new Error(`期望 ${expected},实际 ${actual}`);
}
}