refactor: 菜单栏角色化降噪(role-based-navigation-simplification)
这个提交包含在:
@@ -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]">
|
||||
|
||||
在新工单中引用
屏蔽一个用户