feat(management-navigation-single-company-ux): 收敛管理导航与单公司配置体验

这个提交包含在:
2026-08-03 12:43:00 +08:00
父节点 f4ddc18f12
当前提交 4b0184c64b
共修改 36 个文件,包含 1249 行新增和 178 行删除
@@ -258,7 +258,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
value={legalEntityFilter}
onChange={(event) => setLegalEntityFilter(event.target.value)}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{legalEntities
.filter((item) => !item.archivedAt)
.map((item) => (
@@ -302,7 +302,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
/>
</div>
<div className="space-y-1 md:col-span-3">
<Label htmlFor="holidayCalendarLegalEntity">所属法体(可选)</Label>
<Label htmlFor="holidayCalendarLegalEntity">所属公司(可选)</Label>
<select
id="holidayCalendarLegalEntity"
className="select-std"
@@ -335,7 +335,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
</div>
</div>
<p className="text-xs text-zinc-500">
首个绑定到法体的日历会自动成为该法体默认日历;后续切换默认日历将放在后续阶段补齐。
首个绑定到公司的日历会自动成为该公司默认日历;后续切换默认日历将放在后续阶段补齐。
</p>
</div>
) : null}
@@ -356,7 +356,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
<TableRow>
<TableHead>编码</TableHead>
<TableHead>名称</TableHead>
<TableHead>法体</TableHead>
<TableHead>公司</TableHead>
<TableHead>日期数</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
@@ -377,7 +377,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }
<TableCell>
<div>{item.name}</div>
{item.isDefaultForLegalEntity ? (
<div className="text-xs text-emerald-600">默认法体日历</div>
<div className="text-xs text-emerald-600">默认公司日历</div>
) : null}
</TableCell>
<TableCell>{item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "全局"}</TableCell>
@@ -167,7 +167,7 @@ export function SupplierTab({ canManage, includeArchived, legalEntities }: Suppl
value={legalEntityFilter}
onChange={(event) => setLegalEntityFilter(event.target.value)}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{activeLegalEntities.map((item) => (
<option key={item.id} value={item.id}>
{item.code} / {item.name}
@@ -251,7 +251,7 @@ export function SupplierTab({ canManage, includeArchived, legalEntities }: Suppl
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="supplierLegalEntity">所属法体(可选)</Label>
<Label htmlFor="supplierLegalEntity">所属公司(可选)</Label>
<select
id="supplierLegalEntity"
className="select-std"
@@ -294,7 +294,7 @@ export function SupplierTab({ canManage, includeArchived, legalEntities }: Suppl
<TableHeader>
<TableRow>
<TableHead>编码 / 名称</TableHead>
<TableHead>法体</TableHead>
<TableHead>公司</TableHead>
<TableHead>联系人</TableHead>
<TableHead>税号 / 账期</TableHead>
<TableHead>状态</TableHead>
+32 -31
查看文件
@@ -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>
);
}
+27 -3
查看文件
@@ -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>
@@ -154,7 +154,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
const createMutation = useMutation({
mutationFn: async () => {
if (!createLegalEntityId || !createPeriodStart || !createPeriodEnd) {
throw new Error('请先填写法体与对账周期');
throw new Error('请先填写公司与对账周期');
}
return createTravelReconciliationBatch({
legalEntityId: createLegalEntityId,
@@ -272,7 +272,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
</div>
<div className="grid gap-3">
<div>
<Label htmlFor="travel-recon-create-legal-entity">法体</Label>
<Label htmlFor="travel-recon-create-legal-entity">公司</Label>
<select
id="travel-recon-create-legal-entity"
className="select-std mt-1"
@@ -282,7 +282,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
setCreateCostCenterId('');
}}
>
<option value="">请选择法体</option>
<option value="">请选择公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
@@ -343,7 +343,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
</select>
</div>
<div>
<Label htmlFor="travel-recon-filter-legal-entity">法体</Label>
<Label htmlFor="travel-recon-filter-legal-entity">公司</Label>
<select
id="travel-recon-filter-legal-entity"
className="select-std mt-1"
@@ -354,7 +354,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
setPage(1);
}}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}