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-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,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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