feat(management-navigation-single-company-ux): 收敛管理导航与单公司配置体验
这个提交包含在:
@@ -8,6 +8,10 @@ import {
|
||||
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 = {
|
||||
@@ -27,7 +31,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ 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: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.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"] },
|
||||
@@ -46,24 +50,6 @@ const ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/assets": "资产",
|
||||
};
|
||||
|
||||
const MANAGEMENT_ACTIVE_PATHS = [
|
||||
"/system/settings",
|
||||
"/system/workspace",
|
||||
"/system/workflow-definitions",
|
||||
"/system/feature-flags",
|
||||
"/system/approval-rules",
|
||||
"/system/expense-standards",
|
||||
"/system/reimbursement-templates",
|
||||
"/system/master-data",
|
||||
"/system/notification-channels",
|
||||
"/system/users",
|
||||
"/system/departments",
|
||||
"/system/cutover",
|
||||
"/people",
|
||||
"/people/stats",
|
||||
"/organization",
|
||||
"/audit-logs",
|
||||
];
|
||||
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
|
||||
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
|
||||
|
||||
@@ -79,15 +65,15 @@ type ResolvedNavItem = {
|
||||
icon: string;
|
||||
};
|
||||
|
||||
function isGroupedActive(item: ResolvedNavItem, pathname: string) {
|
||||
function isGroupedActive(item: ResolvedNavItem, pathname: string, permissions: string[]) {
|
||||
if (item.href === "/system/settings") {
|
||||
return MANAGEMENT_ACTIVE_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
||||
return isManagementPath(pathname, permissions);
|
||||
}
|
||||
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () => void) {
|
||||
function renderNavItem(item: ResolvedNavItem, pathname: string, permissions: string[], onNavigate?: () => void) {
|
||||
const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label;
|
||||
|
||||
return (
|
||||
@@ -98,11 +84,11 @@ function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: ()
|
||||
title={accessibleLabel}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle")
|
||||
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);
|
||||
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")}>
|
||||
@@ -116,19 +102,19 @@ function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: ()
|
||||
);
|
||||
}
|
||||
|
||||
function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
|
||||
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, onNavigate))}</div>
|
||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
|
||||
function renderBackupSection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -142,7 +128,7 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavig
|
||||
<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, onNavigate))}</div>
|
||||
<div className="mt-1.5 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +188,20 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -211,15 +211,16 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const sections = navigationQuery.data?.sections
|
||||
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, onNavigate)}
|
||||
{renderBackupSection(sections.backup, location.pathname, onNavigate)}
|
||||
{renderPrimarySection(primaryItems, location.pathname, permissions, onNavigate)}
|
||||
{renderBackupSection(sections.backup, location.pathname, permissions, onNavigate)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NotificationItem } from "../../api/notifications";
|
||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../../api/notifications";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { resolvePageMeta } from "../../features/navigation/schema";
|
||||
import { resolveManagementBreadcrumbs } from "../../features/navigation/managementRoutes";
|
||||
import { resolveNotificationTargetLink, formatNotificationRelativeTime } from "../../lib/notificationLinks";
|
||||
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
|
||||
import { isWecomWebView } from "../../lib/runtimeContext";
|
||||
@@ -45,7 +46,7 @@ function BellIcon() {
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const { me, logout } = useAuth();
|
||||
const { me, logout, permissions } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -53,7 +54,9 @@ export function Topbar() {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const wecomEntry = isWecomWebView();
|
||||
const pageMeta = resolvePageMeta(location.pathname);
|
||||
const pageMeta = resolvePageMeta(location.pathname, permissions);
|
||||
const managementBreadcrumbs = resolveManagementBreadcrumbs(location.pathname, permissions);
|
||||
const showManagementBreadcrumbs = managementBreadcrumbs.length > 1;
|
||||
|
||||
const unreadQuery = useQuery({
|
||||
enabled: Boolean(me),
|
||||
@@ -165,7 +168,28 @@ export function Topbar() {
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-base font-semibold text-[var(--color-text-primary)] md:text-lg">{pageMeta.title}</p>
|
||||
{showManagementBreadcrumbs ? (
|
||||
<nav
|
||||
aria-label="管理路径"
|
||||
className="flex min-w-0 flex-wrap items-center gap-1 text-base font-semibold text-[var(--color-text-primary)] md:text-lg"
|
||||
data-testid="topbar-management-breadcrumbs"
|
||||
>
|
||||
{managementBreadcrumbs.map((item, index) => (
|
||||
<span key={item.key} className="flex min-w-0 items-center gap-1">
|
||||
{index > 0 ? <span aria-hidden="true" className="text-zinc-400">/</span> : null}
|
||||
{item.href ? (
|
||||
<Link className="rounded-sm text-zinc-600 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" to={item.href}>
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current="page" className="truncate">{item.title}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<p className="truncate text-base font-semibold text-[var(--color-text-primary)] md:text-lg">{pageMeta.title}</p>
|
||||
)}
|
||||
<span className="hidden rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-[var(--color-text-secondary)] sm:inline-flex">
|
||||
{pageMeta.group}
|
||||
</span>
|
||||
|
||||
在新工单中引用
屏蔽一个用户