文件
hyb-oa/client/src/components/navigation/NavLinks.tsx
T

236 行
9.8 KiB
TypeScript

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";
import { hasAnyPermission, hasBusinessApproverRole, hasFinanceRole } from "../../auth/permissions";
import {
ADMIN_NAV_ORDER,
BACKUP_NAV_ORDER,
MAIN_NAV_ORDER,
} from "../../features/navigation/schema";
import {
isManagementPath,
MANAGEMENT_ROOT_PERMISSIONS,
} from "../../features/navigation/managementRoutes";
import { cn } from "../../lib/utils";
export type NavItem = {
to: string;
label: string;
icon: string;
anyOf?: string[];
};
export const NAV_ITEMS: NavItem[] = [
{ to: "/portal", label: "工作台", icon: "home_app_logo" },
{ 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: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: MANAGEMENT_ROOT_PERMISSIONS },
{ 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", "payment.batch.review"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
];
const ACCESSIBLE_LABELS: Record<string, string> = {
"/reimbursements": "我的报销",
"/workflow/tasks": "待我审批",
"/assets": "资产",
};
const PRODUCT_LABELS: Record<string, string> = {
"/workflow/tasks": "待我审批",
};
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
function hasPaymentReviewRole(roleCodes: string[], permissions: string[]) {
return roleCodes.some((roleCode) => PAYMENT_REVIEWER_ROLE_CODES.includes(roleCode)) &&
hasAnyPermission(permissions, ["payment.batch.review"]);
}
type ResolvedNavItem = {
key?: string;
href: string;
label: string;
icon: string;
};
function isGroupedActive(item: ResolvedNavItem, pathname: string, permissions: string[]) {
if (item.href === "/system/settings") {
return isManagementPath(pathname, permissions);
}
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
function renderNavItem(item: ResolvedNavItem, pathname: string, permissions: string[], onNavigate?: () => void) {
const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label;
return (
<NavLink
key={item.key ?? item.href}
to={item.href}
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => onNavigate?.()}
className={({ isActive }) =>
cn("group nav-link-item", isActive || isGroupedActive(item, pathname, permissions) ? "nav-link-item--active" : "nav-link-item--idle")
}
>
{({ isActive }) => {
const active = isActive || isGroupedActive(item, pathname, permissions);
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 renderPrimarySection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<section className="nav-section">
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}</div>
</section>
);
}
function renderBackupSection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<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-1.5 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}</div>
</details>
);
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const paymentPaths = new Set(["/payment-batches", "/ap-operations"]);
const financeOnlyPaths = new Set(["/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) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode)))
.filter((item) => !paymentPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || hasPaymentReviewRole(roleCodes, permissions))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
.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 {
main: mapItems(MAIN_NAV_ORDER),
admin: mapItems(ADMIN_NAV_ORDER),
backup: mapItems(BACKUP_NAV_ORDER),
};
}
function orderItems(items: ResolvedNavItem[], order: string[]) {
const byHref = new Map<string, ResolvedNavItem>();
for (const item of items) {
if (!byHref.has(item.href)) {
byHref.set(item.href, item);
}
}
const used = new Set<string>();
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 [...ordered, ...items.filter((item) => !used.has(item.href))];
}
function toResolved(items: NavigationItem[]) {
return items.map((item) => ({
key: item.key,
href: item.href,
label: PRODUCT_LABELS[item.href] ?? item.label,
icon: item.icon,
}));
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
return {
main: orderItems(toResolved(sections.main), MAIN_NAV_ORDER),
admin: orderItems(toResolved(sections.admin), ADMIN_NAV_ORDER),
backup: orderItems(toResolved(sections.backup), BACKUP_NAV_ORDER),
};
}
function normalizeManagementRoot(
sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] },
permissions: string[],
) {
const canAccessManagement = hasAnyPermission(permissions, MANAGEMENT_ROOT_PERMISSIONS);
const withoutManagement = sections.admin.filter((item) => item.href !== "/system/settings");
return {
...sections,
admin: canAccessManagement
? [{ key: "/system/settings", href: "/system/settings", label: "管理", icon: "settings" }, ...withoutManagement]
: withoutManagement,
};
}
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const { me, permissions } = useAuth();
const location = useLocation();
const navigationQuery = useQuery({
queryKey: ["system-navigation"],
queryFn: fetchSystemNavigation,
staleTime: 60_000,
});
const baseSections = navigationQuery.data?.sections
? normalizeSections(navigationQuery.data.sections)
: buildFallbackItems(permissions, me?.roleCodes ?? []);
const sections = normalizeManagementRoot(baseSections, permissions);
const primaryItems = [...sections.main, ...sections.admin];
return (
<nav className="space-y-3.5">
{renderPrimarySection(primaryItems, location.pathname, permissions, onNavigate)}
{renderBackupSection(sections.backup, location.pathname, permissions, onNavigate)}
</nav>
);
}