feat: group sidebar navigation
This commit is contained in:
parent
dc74a618f7
commit
1e6230d412
@ -163,6 +163,7 @@ a {
|
||||
|
||||
:root[data-sidebar="edge"] .brand-title,
|
||||
:root[data-sidebar="edge"] .brand-subtitle,
|
||||
:root[data-sidebar="edge"] .nav-section-title,
|
||||
:root[data-sidebar="edge"] .nav-link {
|
||||
color: white;
|
||||
}
|
||||
@ -292,7 +293,8 @@ a {
|
||||
}
|
||||
|
||||
.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;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@ -315,6 +317,39 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -325,7 +360,7 @@ a {
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--nav-text);
|
||||
padding: 0 16px;
|
||||
padding: 0 14px;
|
||||
text-align: left;
|
||||
transition:
|
||||
background var(--motion-duration-fast) ease,
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { buildSidebarSections } from "../navigation/sidebarMenu";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
defineProps<{
|
||||
@ -51,18 +52,15 @@ const backendIconMap = {
|
||||
operation_logs: ScrollText,
|
||||
};
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
code: "dashboard",
|
||||
name: "工作台",
|
||||
path: "/dashboard",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
...auth.menus.map((menu) => ({
|
||||
...menu,
|
||||
icon: backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
|
||||
const menuSections = computed(() =>
|
||||
buildSidebarSections(auth.menus).map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((menu) => ({
|
||||
...menu,
|
||||
icon: menu.code === "dashboard" ? LayoutDashboard : backendIconMap[menu.code as keyof typeof backendIconMap] || ClipboardList,
|
||||
})),
|
||||
})),
|
||||
]);
|
||||
);
|
||||
|
||||
function logout(): void {
|
||||
auth.logout();
|
||||
@ -86,18 +84,21 @@ function logout(): void {
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav" aria-label="主导航">
|
||||
<RouterLink
|
||||
v-for="item in menuItems"
|
||||
:key="item.code"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
active-class="is-active"
|
||||
:title="item.name"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<component :is="item.icon" :size="21" aria-hidden="true" />
|
||||
<span>{{ item.name }}</span>
|
||||
</RouterLink>
|
||||
<section v-for="section in menuSections" :key="section.code" class="nav-section">
|
||||
<p class="nav-section-title">{{ section.title }}</p>
|
||||
<RouterLink
|
||||
v-for="item in section.items"
|
||||
:key="item.code"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
active-class="is-active"
|
||||
:title="`${section.title} / ${item.name}`"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<component :is="item.icon" :size="20" aria-hidden="true" />
|
||||
<span>{{ item.name }}</span>
|
||||
</RouterLink>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
|
||||
81
frontend/src/navigation/sidebarMenu.ts
Normal file
81
frontend/src/navigation/sidebarMenu.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
frontend/tests/sidebarMenu.test.ts
Normal file
46
frontend/tests/sidebarMenu.test.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user