236 行
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|