refactor: 菜单栏角色化降噪(role-based-navigation-simplification)

这个提交包含在:
2026-05-29 21:11:32 +08:00
父节点 325a7950c9
当前提交 541593a5b6
共修改 58 个文件,包含 796 行新增和 366 行删除
+87 -133
查看文件
@@ -1,4 +1,4 @@
import { NavLink } from "react-router-dom";
import { NavLink, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
import { useAuth } from "../../auth/AuthContext";
@@ -19,67 +19,53 @@ export type NavItem = {
export const NAV_ITEMS: NavItem[] = [
{ to: "/portal", label: "工作台", icon: "home_app_logo" },
{ to: "/team", label: "团队", icon: "groups" },
{ to: "/leave-requests", label: "请假", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
{ to: "/team", label: "团队", icon: "groups", anyOf: ["report.dashboard.view", "project.view_all", "project.manage", "system.users"] },
{ to: "/leave-requests", label: "请假记录", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
{ to: "/attendance", label: "考勤", icon: "schedule" },
{ to: "/reimbursements", label: "报销", icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"] },
{ to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
{ to: "/weekly-reports", label: "周报", icon: "article" },
{ to: "/todos", label: "待办", icon: "check_circle" },
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "付款批次", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/ap-operations", label: "AP 运营", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/invoice-pool", label: "票据中心", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
{ to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] },
{ to: "/system/integrations/erp", label: "外部系统桥接", icon: "hub", anyOf: ["integration.manage"] },
{ to: "/dashboard", label: "仪表盘", icon: "dashboard", anyOf: ["report.dashboard.view"] },
{ to: "/settings/profile", label: "个人设置", icon: "manage_accounts" },
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
];
const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
const ACCESSIBLE_LABELS: Record<string, string> = {
"/reimbursements": "我的报销",
"/workflow/tasks": "待我处理",
"/assets": "资产台账",
};
const WORK_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/todos"];
const BUSINESS_NAV_ORDER = [
"/projects",
"/contracts-registry",
"/purchase-requests",
"/assets",
"/weekly-reports",
"/team",
"/announcements",
"/leave-requests",
"/attendance",
"/wecom-approval-archive",
];
const FINANCE_NAV_ORDER = ["/payment-batches", "/ap-operations", "/invoice-pool", "/budgets", "/inventory-checks"];
const MANAGEMENT_NAV_ORDER = [
"/people",
"/organization",
const MANAGEMENT_ACTIVE_PATHS = [
"/system/settings",
"/system/workspace",
"/system/workflow-definitions",
"/system/settings",
"/system/feature-flags",
"/system/approval-rules",
"/system/expense-standards",
"/system/reimbursement-templates",
"/system/master-data",
"/system/notification-channels",
"/system/integrations/erp",
"/system/users",
"/system/departments",
"/system/cutover",
"/people",
"/people/stats",
"/organization",
"/audit-logs",
];
const MORE_NAV_ORDER = ["/travel-requests", "/dashboard", "/settings/profile", "/system/cutover", "/system/integrations/erp"];
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
type ResolvedNavItem = {
key?: string;
@@ -88,8 +74,16 @@ type ResolvedNavItem = {
icon: string;
};
function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
const accessibleLabel = LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label;
function isGroupedActive(item: ResolvedNavItem, pathname: string) {
if (item.href === "/system/settings") {
return MANAGEMENT_ACTIVE_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
}
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () => void) {
const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label;
return (
<NavLink
@@ -99,68 +93,65 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
title={accessibleLabel}
onClick={() => onNavigate?.()}
className={({ isActive }) =>
cn(
"group nav-link-item",
isActive ? "nav-link-item--active" : "nav-link-item--idle",
)
cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle")
}
>
{({ isActive }) => (
<>
<span
className={cn(
"nav-link-item__icon",
isActive ? "nav-link-item__icon--active" : "nav-link-item__icon--idle",
)}
>
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
</span>
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
</>
)}
{({ isActive }) => {
const active = isActive || isGroupedActive(item, pathname);
return (
<>
<span className={cn("nav-link-item__icon", active ? "nav-link-item__icon--active" : "nav-link-item__icon--idle")}>
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
</span>
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
</>
);
}}
</NavLink>
);
}
function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<section className="nav-section">
<div className="nav-section__label">{title}</div>
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
</section>
);
}
function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void) {
function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<details className="rounded-2xl border border-slate-200 bg-slate-50/80 px-3 py-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-[var(--color-text-primary)]">
<div>
<div>更多</div>
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">低频入口和历史模块</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
<details className="nav-more-section">
<summary className="nav-more-section__summary">
<span className="nav-link-item__icon nav-link-item__icon--idle">
<span className="material-symbols-outlined text-[20px]">more_horiz</span>
</span>
<span className="min-w-0 flex-1 truncate font-medium">更多</span>
<span className="material-symbols-outlined nav-more-section__chevron">expand_more</span>
</summary>
<div className="mt-3 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, onNavigate))}</div>
<div className="mt-1.5 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
</details>
);
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
const mapItems = (paths: string[]) =>
NAV_ITEMS.filter((item) => paths.includes(item.to))
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
.filter((item) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode)))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || roleCodes.includes("admin"))
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
.filter((item) => paths === BACKUP_NAV_ORDER || !backupOnlyPaths.has(item.to))
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
return {
@@ -170,77 +161,43 @@ function buildFallbackItems(permissions: string[], roleCodes: string[]) {
};
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
function orderItems(items: ResolvedNavItem[], order: string[]) {
const byHref = new Map<string, ResolvedNavItem>();
for (const item of allItems) {
for (const item of items) {
if (!byHref.has(item.href)) {
byHref.set(item.href, item);
}
}
const used = new Set<string>();
const take = (hrefs: string[]) =>
hrefs
.map((href) => {
const item = byHref.get(href);
if (item) {
used.add(href);
}
return item;
})
.filter((item): item is ResolvedNavItem => Boolean(item));
const ordered = order
.map((href) => {
const item = byHref.get(href);
if (item) {
used.add(href);
}
return item;
})
.filter((item): item is ResolvedNavItem => Boolean(item));
return {
main: take(MAIN_NAV_ORDER),
admin: take(ADMIN_NAV_ORDER),
backup: [
...take(BACKUP_NAV_ORDER),
...allItems
.filter((item) => !used.has(item.href))
.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
],
};
return [...ordered, ...items.filter((item) => !used.has(item.href))];
}
function regroupSections(sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }) {
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
const byHref = new Map<string, ResolvedNavItem>();
for (const item of allItems) {
if (!byHref.has(item.href)) {
byHref.set(item.href, item);
}
}
const used = new Set<string>();
const take = (hrefs: string[]) =>
hrefs
.map((href) => {
const item = byHref.get(href);
if (item) {
used.add(href);
}
return item;
})
.filter((item): item is ResolvedNavItem => Boolean(item));
function toResolved(items: NavigationItem[]) {
return items.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon }));
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
return {
work: take(WORK_NAV_ORDER),
business: take(BUSINESS_NAV_ORDER),
finance: take(FINANCE_NAV_ORDER),
management: take(MANAGEMENT_NAV_ORDER),
more: [
...take(MORE_NAV_ORDER),
...allItems
.filter((item) => !used.has(item.href))
.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
],
main: orderItems(toResolved(sections.main), MAIN_NAV_ORDER),
admin: orderItems(toResolved(sections.admin), ADMIN_NAV_ORDER),
backup: orderItems(toResolved(sections.backup), BACKUP_NAV_ORDER),
};
}
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const { me, permissions } = useAuth();
const location = useLocation();
const navigationQuery = useQuery({
queryKey: ["system-navigation"],
queryFn: fetchSystemNavigation,
@@ -250,15 +207,12 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const sections = navigationQuery.data?.sections
? normalizeSections(navigationQuery.data.sections)
: buildFallbackItems(permissions, me?.roleCodes ?? []);
const groupedSections = regroupSections(sections);
const primaryItems = [...sections.main, ...sections.admin];
return (
<nav className="space-y-5">
{renderSection("工作", groupedSections.work, onNavigate)}
{renderSection("业务", groupedSections.business, onNavigate)}
{renderSection("财务", groupedSections.finance, onNavigate)}
{renderSection("管理", groupedSections.management, onNavigate)}
{renderBackupSection(groupedSections.more, onNavigate)}
<nav className="space-y-3.5">
{renderPrimarySection(primaryItems, location.pathname, onNavigate)}
{renderBackupSection(sections.backup, location.pathname, onNavigate)}
</nav>
);
}
@@ -6,13 +6,6 @@ export function Sidebar() {
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 lg:flex lg:flex-col">
<div className="border-b border-slate-200 px-6 py-5">
<AppBrand />
<div className="sidebar-focus-card mt-4 text-[var(--color-text-secondary)]">
<div className="flex items-center gap-2 font-semibold text-[var(--color-text-primary)]">
<span className="size-2 rounded-full bg-primary" />
今日优先
</div>
<div className="mt-1.5 leading-6">待办、报销、项目和财务流程按优先级进入。</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-width:thin]">