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

这个提交包含在:
2026-08-03 12:43:00 +08:00
父节点 f4ddc18f12
当前提交 4b0184c64b
共修改 36 个文件,包含 1249 行新增和 178 行删除
+5 -5
查看文件
@@ -261,11 +261,11 @@ export function mapApiErrorMessage(
}
if (details?.legalEntityId === "not_found") {
return "所选法体不存在。";
return "所选公司不存在。";
}
if (details?.legalEntityId === "archived") {
return "所选法体已归档,无法继续使用。";
return "所选公司已归档,无法继续使用。";
}
if (details?.ownerEmployeeId === "not_found") {
@@ -289,11 +289,11 @@ export function mapApiErrorMessage(
}
if (details?.parentId === "cross_legal_entity_not_allowed") {
return "上级成本中心必须与当前法体一致。";
return "上级成本中心必须与当前公司一致。";
}
if (details?.legalEntity === "has_cost_centers") {
return "当前法体下仍存在未归档的成本中心,无法归档。";
return "当前公司下仍存在未归档的成本中心,无法归档。";
}
if (details?.costCenter === "has_children") {
@@ -301,7 +301,7 @@ export function mapApiErrorMessage(
}
if (details?.holidayCalendar === "bound_as_default") {
return `当前日历仍被法体 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`;
return `当前日历仍被公司 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`;
}
if (details?.date === "duplicate") {
@@ -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>
))}
@@ -0,0 +1,14 @@
export type SingleCompanyState<T> =
| { kind: "empty" }
| { kind: "single"; company: T }
| { kind: "multiple"; companies: T[] };
export function resolveSingleCompanyState<T>(companies: T[]): SingleCompanyState<T> {
if (companies.length === 0) {
return { kind: "empty" };
}
if (companies.length === 1) {
return { kind: "single", company: companies[0] };
}
return { kind: "multiple", companies };
}
@@ -0,0 +1,360 @@
import { hasAnyPermission } from "../../auth/permissions";
export type ManagementGroup = "人员与组织" | "流程与规则" | "系统与审计" | "功能管理";
type ManagementRouteMatch =
| { type: "exact"; path: string }
| { type: "prefix"; path: string }
| { type: "pattern"; pattern: RegExp };
export type ManagementRouteDefinition = {
key: string;
path: string;
title: string;
parentKey: string | null;
group: ManagementGroup | null;
icon: string;
description: string;
anyOf: readonly string[];
showOnLanding: boolean;
match: ManagementRouteMatch;
};
const MANAGEMENT_CHILD_ROUTES: ManagementRouteDefinition[] = [
{
key: "people",
path: "/people",
title: "人员",
parentKey: "management",
group: "人员与组织",
icon: "badge",
description: "员工档案、入转调离和人员状态。",
anyOf: ["system.users"],
showOnLanding: true,
match: { type: "exact", path: "/people" },
},
{
key: "people_stats",
path: "/people/stats",
title: "人员统计",
parentKey: "people",
group: null,
icon: "query_stats",
description: "",
anyOf: ["system.users"],
showOnLanding: false,
match: { type: "exact", path: "/people/stats" },
},
{
key: "employee_profile",
path: "/people/:employeeId",
title: "员工档案",
parentKey: "people",
group: null,
icon: "badge",
description: "",
anyOf: ["system.users"],
showOnLanding: false,
match: { type: "pattern", pattern: /^\/people\/[^/]+$/ },
},
{
key: "organization",
path: "/organization",
title: "组织",
parentKey: "management",
group: "人员与组织",
icon: "account_tree",
description: "组织结构、部门和汇报关系。",
anyOf: ["system.users", "report.dashboard.view"],
showOnLanding: true,
match: { type: "prefix", path: "/organization" },
},
{
key: "user_accounts",
path: "/system/users",
title: "账号",
parentKey: "management",
group: "人员与组织",
icon: "manage_accounts",
description: "账号、角色和登录权限。",
anyOf: ["system.accounts.manage", "system.roles.manage"],
showOnLanding: true,
match: { type: "prefix", path: "/system/users" },
},
{
key: "departments",
path: "/system/departments",
title: "部门",
parentKey: "management",
group: "人员与组织",
icon: "account_tree",
description: "部门基础数据维护。",
anyOf: ["system.users"],
showOnLanding: true,
match: { type: "prefix", path: "/system/departments" },
},
{
key: "approval_rules",
path: "/system/approval-rules",
title: "审批规则",
parentKey: "management",
group: "流程与规则",
icon: "rule",
description: "金额段、审批层级和处理规则。",
anyOf: ["system.settings"],
showOnLanding: true,
match: { type: "prefix", path: "/system/approval-rules" },
},
{
key: "workflow_definitions",
path: "/system/workflow-definitions",
title: "流程定义",
parentKey: "management",
group: "流程与规则",
icon: "schema",
description: "流程模板和节点治理。",
anyOf: ["system.settings"],
showOnLanding: true,
match: { type: "prefix", path: "/system/workflow-definitions" },
},
{
key: "expense_standards",
path: "/system/expense-standards",
title: "费用标准",
parentKey: "management",
group: "流程与规则",
icon: "rule_settings",
description: "费用上限、类别和管控口径。",
anyOf: ["system.settings"],
showOnLanding: true,
match: { type: "prefix", path: "/system/expense-standards" },
},
{
key: "reimbursement_templates",
path: "/system/reimbursement-templates",
title: "报销模板",
parentKey: "management",
group: "流程与规则",
icon: "receipt_long",
description: "常见报销场景模板。",
anyOf: ["system.settings"],
showOnLanding: true,
match: { type: "prefix", path: "/system/reimbursement-templates" },
},
{
key: "workspace",
path: "/system/workspace",
title: "工作区",
parentKey: "management",
group: "系统与审计",
icon: "dashboard_customize",
description: "菜单入口、模块显隐和小团队基线。",
anyOf: ["system.settings"],
showOnLanding: true,
match: { type: "prefix", path: "/system/workspace" },
},
{
key: "audit_logs",
path: "/audit-logs",
title: "审计日志",
parentKey: "management",
group: "系统与审计",
icon: "manage_search",
description: "关键操作留痕和排障。",
anyOf: ["system.audit.view"],
showOnLanding: true,
match: { type: "prefix", path: "/audit-logs" },
},
{
key: "company_information",
path: "/system/master-data",
title: "主数据",
parentKey: "management",
group: "功能管理",
icon: "dataset",
description: "公司信息、成本中心、供应商等基础数据。",
anyOf: ["masterdata.view", "masterdata.manage"],
showOnLanding: true,
match: { type: "prefix", path: "/system/master-data" },
},
{
key: "payment_accounts",
path: "/system/payment-accounts",
title: "企业银行账户",
parentKey: "management",
group: "功能管理",
icon: "account_balance",
description: "维护付款来源账户、脱敏查看与变更留痕。",
anyOf: ["payment.account.read", "payment.account.manage"],
showOnLanding: true,
match: { type: "prefix", path: "/system/payment-accounts" },
},
{
key: "notification_channels",
path: "/system/notification-channels",
title: "通知渠道",
parentKey: "management",
group: "功能管理",
icon: "notifications_active",
description: "企业微信、邮件等补充通知。",
anyOf: ["notification.channel.manage"],
showOnLanding: true,
match: { type: "prefix", path: "/system/notification-channels" },
},
{
key: "feature_flags",
path: "/system/feature-flags",
title: "功能管理",
parentKey: "management",
group: "功能管理",
icon: "toggle_on",
description: "功能开关、灰度和试运行边界。",
anyOf: ["system.settings"],
showOnLanding: true,
match: { type: "prefix", path: "/system/feature-flags" },
},
{
key: "system_announcements",
path: "/system/announcements",
title: "公告管理",
parentKey: "management",
group: null,
icon: "campaign",
description: "",
anyOf: ["system.settings"],
showOnLanding: false,
match: { type: "prefix", path: "/system/announcements" },
},
{
key: "cutover",
path: "/system/cutover",
title: "切换控制台",
parentKey: "management",
group: null,
icon: "swap_calls",
description: "",
anyOf: ["system.settings"],
showOnLanding: false,
match: { type: "prefix", path: "/system/cutover" },
},
{
key: "performance",
path: "/performance",
title: "绩效管理",
parentKey: "management",
group: null,
icon: "monitoring",
description: "",
anyOf: ["system.settings"],
showOnLanding: false,
match: { type: "prefix", path: "/performance" },
},
];
function uniquePermissions(routes: readonly ManagementRouteDefinition[]) {
return Array.from(new Set(routes.flatMap((route) => route.anyOf)));
}
export const MANAGEMENT_ROOT_PERMISSIONS = uniquePermissions(
MANAGEMENT_CHILD_ROUTES.filter((route) => route.showOnLanding),
);
const MANAGEMENT_ROOT_ROUTE: ManagementRouteDefinition = {
key: "management",
path: "/system/settings",
title: "管理",
parentKey: null,
group: null,
icon: "settings",
description: "人员、组织、流程、基础资料和系统审计。",
anyOf: MANAGEMENT_ROOT_PERMISSIONS,
showOnLanding: false,
match: { type: "exact", path: "/system/settings" },
};
export const MANAGEMENT_ROUTES: readonly ManagementRouteDefinition[] = [
MANAGEMENT_ROOT_ROUTE,
...MANAGEMENT_CHILD_ROUTES,
];
export const MANAGEMENT_GROUPS: readonly ManagementGroup[] = [
"人员与组织",
"流程与规则",
"系统与审计",
"功能管理",
];
const ROUTES_BY_KEY = new Map(MANAGEMENT_ROUTES.map((route) => [route.key, route]));
function matchesRoute(route: ManagementRouteDefinition, pathname: string) {
if (route.match.type === "pattern") {
return route.match.pattern.test(pathname);
}
if (route.match.type === "exact") {
return pathname === route.match.path;
}
return pathname === route.match.path || pathname.startsWith(`${route.match.path}/`);
}
export function canAccessManagementRoute(route: ManagementRouteDefinition, permissions: string[]) {
return hasAnyPermission(permissions, [...route.anyOf]);
}
export function resolveManagementRouteDefinition(pathname: string) {
return MANAGEMENT_ROUTES.find((route) => matchesRoute(route, pathname)) ?? null;
}
export function resolveManagementRoute(pathname: string, permissions: string[]) {
const route = resolveManagementRouteDefinition(pathname);
return route && canAccessManagementRoute(route, permissions) ? route : null;
}
export function isKnownManagementPath(pathname: string) {
return Boolean(resolveManagementRouteDefinition(pathname));
}
export function isManagementPath(pathname: string, permissions: string[]) {
return Boolean(resolveManagementRoute(pathname, permissions));
}
export function getVisibleManagementItems(permissions: string[]) {
return MANAGEMENT_ROUTES.filter(
(route) => route.showOnLanding && canAccessManagementRoute(route, permissions),
);
}
export type ManagementBreadcrumb = {
key: string;
title: string;
href: string | null;
};
export function resolveManagementBreadcrumbs(pathname: string, permissions: string[]) {
const current = resolveManagementRoute(pathname, permissions);
if (!current) {
return [];
}
const chain: ManagementRouteDefinition[] = [];
const visited = new Set<string>();
let cursor: ManagementRouteDefinition | undefined = current;
while (cursor && !visited.has(cursor.key)) {
chain.unshift(cursor);
visited.add(cursor.key);
cursor = cursor.parentKey ? ROUTES_BY_KEY.get(cursor.parentKey) : undefined;
}
return chain.map((route, index) => ({
key: route.key,
title: route.title,
href: index < chain.length - 1 ? route.path : null,
}));
}
export function getManagementRoutePermissions(key: string) {
const route = ROUTES_BY_KEY.get(key);
if (!route) {
throw new Error(`Unknown management route key: ${key}`);
}
return [...route.anyOf];
}
+15 -10
查看文件
@@ -1,3 +1,8 @@
import {
isKnownManagementPath,
resolveManagementRoute,
} from "./managementRoutes";
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
export const ADMIN_NAV_ORDER = ["/system/settings"];
export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
@@ -38,19 +43,19 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
{ path: "/system/workspace", title: "工作区设置", group: "管理" },
{ path: "/people", title: "人员管理", group: "管理" },
{ path: "/organization", title: "组织架构", group: "管理" },
{ path: "/system/approval-rules", title: "审批规则", group: "管理" },
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理" },
{ path: "/system/settings", title: "管理", group: "管理" },
{ path: "/system/feature-flags", title: "功能管理", group: "管理" },
{ path: "/system/cutover", title: "切换控制台", group: "更多" },
{ path: "/dashboard", title: "数据看板", group: "更多" },
{ path: "/audit-logs", title: "审计日志", group: "管理" },
];
export function resolvePageMeta(pathname: string) {
export function resolvePageMeta(pathname: string, permissions: string[] = []) {
const managementRoute = resolveManagementRoute(pathname, permissions);
if (managementRoute) {
return { title: managementRoute.title, group: "管理" };
}
if (isKnownManagementPath(pathname)) {
return { title: "管理", group: "管理" };
}
const matched = [...PAGE_TITLES].sort((left, right) => right.path.length - left.path.length)
.find((item) => pathname === item.path || pathname.startsWith(item.path));
+4 -2
查看文件
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { AppPwaUpdateNotice } from "../components/common/AppPwaUpdateNotice";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { MobileBottomNav } from "../components/mobile/MobileBottomNav";
@@ -10,6 +11,7 @@ import { writeRecentVisit } from "../lib/recentVisits";
import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib/runtimeContext";
export function AppShellLayout() {
const { permissions } = useAuth();
const location = useLocation();
const isMobileRoute = location.pathname.startsWith("/mobile");
const mobileSearchParams = new URLSearchParams(location.search);
@@ -52,8 +54,8 @@ export function AppShellLayout() {
if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) {
return;
}
writeRecentVisit(pathname, resolvePageMeta(pathname).title);
}, [location.pathname]);
writeRecentVisit(pathname, resolvePageMeta(pathname, permissions).title, permissions);
}, [location.pathname, permissions]);
if (mobileEquivalentPath && mobileEquivalentPath !== `${location.pathname}${location.search}`) {
return <Navigate to={mobileEquivalentPath} replace />;
+28 -3
查看文件
@@ -1,3 +1,8 @@
import {
canAccessManagementRoute,
resolveManagementRouteDefinition,
} from "../features/navigation/managementRoutes";
export const RECENT_VISITS_STORAGE_KEY = "hyb_oa_recent_visits";
export type RecentVisit = {
@@ -10,7 +15,7 @@ function isWindowAvailable() {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
export function readRecentVisits(): RecentVisit[] {
function readStoredRecentVisits(): RecentVisit[] {
if (!isWindowAvailable()) return [];
try {
const raw = window.localStorage.getItem(RECENT_VISITS_STORAGE_KEY);
@@ -22,9 +27,29 @@ export function readRecentVisits(): RecentVisit[] {
}
}
export function writeRecentVisit(path: string, title: string) {
export function filterRecentVisitsByPermissions(visits: RecentVisit[], permissions: string[]) {
return visits.filter((item) => {
const managementRoute = resolveManagementRouteDefinition(item.path);
return !managementRoute || canAccessManagementRoute(managementRoute, permissions);
});
}
export function readRecentVisits(permissions: string[]): RecentVisit[] {
const stored = readStoredRecentVisits();
const visible = filterRecentVisitsByPermissions(stored, permissions);
if (isWindowAvailable() && visible.length !== stored.length) {
window.localStorage.setItem(RECENT_VISITS_STORAGE_KEY, JSON.stringify(visible));
}
return visible;
}
export function writeRecentVisit(path: string, title: string, permissions: string[]) {
if (!isWindowAvailable()) return;
const current = readRecentVisits().filter((item) => item.path !== path);
const current = readRecentVisits(permissions).filter((item) => item.path !== path);
const managementRoute = resolveManagementRouteDefinition(path);
if (managementRoute && !canAccessManagementRoute(managementRoute, permissions)) {
return;
}
current.unshift({ path, title, visitedAt: new Date().toISOString() });
window.localStorage.setItem(RECENT_VISITS_STORAGE_KEY, JSON.stringify(current.slice(0, 10)));
}
+4 -4
查看文件
@@ -495,7 +495,7 @@ export function AssetRegistryPage() {
throw new Error("请选择资产分类");
}
if (!assetForm.legalEntityId) {
throw new Error("请选择法体");
throw new Error("请选择公司");
}
if (!assetForm.costCenterId) {
throw new Error("请选择成本中心");
@@ -935,7 +935,7 @@ export function AssetRegistryPage() {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
}}>
<option value="">全部法体</option>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
@@ -1217,7 +1217,7 @@ export function AssetRegistryPage() {
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">法体</label>
<label className="text-sm font-medium text-zinc-700">公司</label>
<select data-testid="asset-legal-entity" className="select-std" value={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
const legalEntityId = event.target.value;
setAssetForm((current) => ({
@@ -1228,7 +1228,7 @@ export function AssetRegistryPage() {
: "",
}));
}}>
<option value="">请选择法体</option>
<option value="">请选择公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
+7 -7
查看文件
@@ -74,9 +74,9 @@ function describeScope(item: BudgetPool) {
}
if (item.scopeType === "cost_center_scoped") {
const legalEntityName = item.legalEntity?.name ?? "未知法体";
const legalEntityName = item.legalEntity?.name ?? "未知公司";
const costCenterName = item.costCenter?.name ?? "未知成本中心";
return `法体:${legalEntityName} / 成本中心:${costCenterName}`;
return `公司:${legalEntityName} / 成本中心:${costCenterName}`;
}
if (item.scopeType === "global") {
@@ -339,7 +339,7 @@ export function BudgetsPage() {
function describeRuleScope(rule: BudgetControlRule) {
const parts: string[] = [];
if (rule.legalEntityId) {
parts.push(`法体:${legalEntityMap.get(rule.legalEntityId) ?? rule.legalEntityId}`);
parts.push(`公司:${legalEntityMap.get(rule.legalEntityId) ?? rule.legalEntityId}`);
}
if (rule.departmentId) {
parts.push(`部门:${departmentMap.get(rule.departmentId) ?? rule.departmentId}`);
@@ -441,7 +441,7 @@ export function BudgetsPage() {
setPage(1);
}}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => (
<option key={legalEntity.id} value={legalEntity.id}>
{legalEntity.name}
@@ -519,7 +519,7 @@ export function BudgetsPage() {
>
<option value="global">全局预算</option>
<option value="department_legacy">部门预算</option>
<option value="cost_center_scoped">法体 + 成本中心预算</option>
<option value="cost_center_scoped">公司 + 成本中心预算</option>
</select>
{formScopeType === "department_legacy" ? (
@@ -547,7 +547,7 @@ export function BudgetsPage() {
setFormCostCenterId("");
}}
>
<option value="">选择法体</option>
<option value="">选择公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => (
<option key={legalEntity.id} value={legalEntity.id}>
{legalEntity.name}
@@ -763,7 +763,7 @@ export function BudgetsPage() {
value={ruleForm.legalEntityId}
onChange={(event) => setRuleForm((current) => ({ ...current, legalEntityId: event.target.value }))}
>
<option value="">不限法体</option>
<option value="">不限公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
+1 -1
查看文件
@@ -566,7 +566,7 @@ export function CutoverConsolePage() {
scopeKey: event.target.value,
}))
}
placeholder="部门 ID / 法体 ID / 业务域"
placeholder="部门 ID / 公司 ID / 业务域"
/>
</div>
+2 -2
查看文件
@@ -38,7 +38,7 @@ function scopeLabel(scopeType: InventoryCheckScopeType) {
case "all":
return "全量盘点";
case "legal_entity":
return "法体盘点";
return "公司盘点";
case "cost_center":
return "成本中心盘点";
case "location":
@@ -278,7 +278,7 @@ export function InventoryChecksPage() {
setScopeRefId("");
}}>
<option value="all">全量盘点</option>
<option value="legal_entity">法体盘点</option>
<option value="legal_entity">公司盘点</option>
<option value="cost_center">成本中心盘点</option>
<option value="location">位置盘点</option>
</select>
+46 -14
查看文件
@@ -30,6 +30,7 @@ import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolveSingleCompanyState } from "../features/company/singleCompany";
const DOCUMENT_ENTITY_TYPE_OPTIONS: Array<{ value: DocumentCategoryEntityType; label: string }> = [
{ value: "reimbursement", label: "报销" },
@@ -132,6 +133,12 @@ export function MasterDataPage() {
}),
});
const activeCompaniesQuery = useQuery({
queryKey: ["master-data", "legal-entities", "active-company-state"],
enabled: canView,
queryFn: () => fetchLegalEntities({ enabled: true }),
});
const costCentersQuery = useQuery({
queryKey: [
"master-data",
@@ -167,6 +174,8 @@ export function MasterDataPage() {
});
const legalEntities = legalEntitiesQuery.data?.items ?? [];
const activeCompanies = activeCompaniesQuery.data?.items ?? [];
const companyState = resolveSingleCompanyState(activeCompanies);
const costCenters = costCentersQuery.data?.items ?? [];
const documentCategories = documentCategoriesQuery.data?.items ?? [];
const masterDataStats = {
@@ -211,7 +220,7 @@ export function MasterDataPage() {
setLegalEntityCurrency("CNY");
setLegalEntityTaxNo("");
setError("");
toast.success("法体已创建");
toast.success("公司信息已创建");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
@@ -312,7 +321,7 @@ export function MasterDataPage() {
<ListWorkspace>
<WorkspaceHeader
title="主数据"
subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。"
subtitle="集中维护公司信息、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一基础资料。"
actions={
<Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}>
{includeArchived ? "隐藏归档" : "显示归档"}
@@ -325,7 +334,7 @@ export function MasterDataPage() {
) : null}
<div className="finance-status-strip" data-testid="master-data-summary">
<div className="finance-metric-card"><div className="finance-metric-card__label">法体</div><div className="finance-metric-card__value">{masterDataStats.legalEntities}</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">公司信息</div><div className="finance-metric-card__value">{masterDataStats.legalEntities}</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">成本中心</div><div className="finance-metric-card__value">{masterDataStats.costCenters}</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">单据分类</div><div className="finance-metric-card__value">{masterDataStats.documentCategories}</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">归档项</div><div className="finance-metric-card__value">{masterDataStats.archived}</div></div>
@@ -334,7 +343,7 @@ export function MasterDataPage() {
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
<TabsTrigger value="legal-entities">法体</TabsTrigger>
<TabsTrigger value="legal-entities">公司信息</TabsTrigger>
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
<TabsTrigger value="holiday-calendars">日历</TabsTrigger>
@@ -344,7 +353,7 @@ export function MasterDataPage() {
<TabsContent value="legal-entities" className="space-y-4">
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
<h3 className="text-base font-semibold text-zinc-900">公司信息筛选</h3>
<select
className="select-std max-w-xs"
value={legalEntityEnabledFilter}
@@ -357,9 +366,18 @@ export function MasterDataPage() {
</div>
</FilterBar>
{canManage ? (
{activeCompaniesQuery.isError ? (
<div className="admin-command-panel border-red-200 bg-red-50/70 text-sm text-red-600">
公司信息读取失败,请刷新后重试。
</div>
) : null}
{canManage && !activeCompaniesQuery.isLoading && !activeCompaniesQuery.isError && companyState.kind === "empty" ? (
<div className="admin-command-panel space-y-4">
<h3 className="text-base font-semibold text-zinc-900">新建法体</h3>
<div>
<h3 className="text-base font-semibold text-zinc-900">完善公司信息</h3>
<p className="mt-1 text-sm text-zinc-600">系统仅服务当前公司。完成后,企业银行账户等配置会自动关联这家公司。</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1">
<Label htmlFor="legalEntityCode">编码</Label>
@@ -387,15 +405,29 @@ export function MasterDataPage() {
disabled={legalEntityCreateMutation.isPending || !legalEntityCode.trim() || !legalEntityName.trim() || !legalEntityCurrency.trim()}
onClick={() => legalEntityCreateMutation.mutate()}
>
{legalEntityCreateMutation.isPending ? "创建中..." : "创建法体"}
{legalEntityCreateMutation.isPending ? "创建中..." : "保存公司信息"}
</Button>
</div>
</div>
</div>
) : null}
{!activeCompaniesQuery.isLoading && !activeCompaniesQuery.isError && companyState.kind === "single" ? (
<div className="admin-command-panel">
<div className="text-sm font-medium text-zinc-900">当前公司:{companyState.company.name}</div>
<div className="mt-1 text-sm text-zinc-600">系统按单公司运行,不需要选择或切换公司。</div>
</div>
) : null}
{!activeCompaniesQuery.isLoading && !activeCompaniesQuery.isError && companyState.kind === "multiple" ? (
<div className="admin-command-panel border-red-200 bg-red-50/70">
<div className="text-sm font-medium text-red-700">检测到多条启用的公司信息</div>
<div className="mt-1 text-sm text-red-600">系统不会自动选择或合并,请先停用多余记录后再继续关联配置。</div>
</div>
) : null}
<div className="admin-command-panel space-y-4">
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
<h3 className="text-base font-semibold text-zinc-900">公司信息</h3>
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
{!legalEntitiesQuery.isLoading ? (
<PrimaryTable className="ops-table-focus">
@@ -443,7 +475,7 @@ export function MasterDataPage() {
) : (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-zinc-500">
暂无法体数据
暂无公司信息
</TableCell>
</TableRow>
)}
@@ -471,7 +503,7 @@ export function MasterDataPage() {
value={costCenterLegalEntityFilter}
onChange={(event) => setCostCenterLegalEntityFilter(event.target.value)}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{legalEntities
.filter((item) => !item.archivedAt)
.map((item) => (
@@ -488,7 +520,7 @@ export function MasterDataPage() {
<h3 className="text-base font-semibold text-zinc-900">新建成本中心</h3>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1 md:col-span-2">
<Label htmlFor="costCenterLegalEntity">所属法体</Label>
<Label htmlFor="costCenterLegalEntity">所属公司</Label>
<select
id="costCenterLegalEntity"
className="select-std"
@@ -498,7 +530,7 @@ export function MasterDataPage() {
setCostCenterParentId("");
}}
>
<option value="">请选择法体</option>
<option value="">请选择公司</option>
{legalEntities
.filter((item) => !item.archivedAt)
.map((item) => (
@@ -561,7 +593,7 @@ export function MasterDataPage() {
<TableRow>
<TableHead>编码</TableHead>
<TableHead>名称</TableHead>
<TableHead>所属法体</TableHead>
<TableHead>所属公司</TableHead>
<TableHead>上级</TableHead>
<TableHead>负责人</TableHead>
<TableHead>状态</TableHead>
+2 -2
查看文件
@@ -102,7 +102,7 @@ function assetTypeLabel(assetType: AssetItem["assetType"]) {
function inventoryScopeLabel(inventoryCheck: InventoryCheck) {
if (inventoryCheck.scopeType === "all") return "全量盘点";
if (inventoryCheck.scopeType === "legal_entity") return "法体盘点";
if (inventoryCheck.scopeType === "legal_entity") return "公司盘点";
if (inventoryCheck.scopeType === "cost_center") return "成本中心盘点";
return "位置盘点";
}
@@ -559,7 +559,7 @@ export function MobileAssetScanPage() {
<div className="mobile-info-grid">
<div className="mobile-subcard text-sm">类型:{assetTypeLabel(selectedAsset.assetType)}</div>
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
<div className="mobile-subcard text-sm">公司:{selectedAsset.legalEntity?.name ?? "-"}</div>
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
@@ -52,7 +52,7 @@ function resolveMobilePurchaseEvidence(detail: {
projectId: string | null;
}) {
if (!detail.supplierId) return "待补供应商";
if (!detail.legalEntityId || !detail.costCenterId) return "待补法体 / 成本中心";
if (!detail.legalEntityId || !detail.costCenterId) return "待补公司 / 成本中心";
if (!detail.documentCategoryId) return "待补单据分类";
if (!detail.budgetPoolId) return "预算池未匹配";
if (!detail.projectId) return "未关联项目";
@@ -167,7 +167,7 @@ export function MobilePurchaseRequestDetailPage() {
<div className="mobile-field-value">{formatDate(detail.expectedDeliveryDate)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">法体</div>
<div className="mobile-field-label">公司</div>
<div className="mobile-field-value">{detail.legalEntity?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
@@ -49,7 +49,7 @@ function resolvePurchaseListNextStep(status: PurchaseRequestStatus) {
function resolvePurchaseListEvidence(item: PurchaseRequestSummary) {
if (!item.supplierId) return "待补供应商";
if (!item.legalEntityId || !item.costCenterId) return "待补法体 / 成本中心";
if (!item.legalEntityId || !item.costCenterId) return "待补公司 / 成本中心";
if (!item.documentCategoryId) return "待补单据分类";
if (!item.budgetPoolId) return "预算池未匹配";
if (!item.projectId) return "未关联项目";
+63 -11
查看文件
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -14,8 +15,9 @@ import {
import { fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { resolveSingleCompanyState } from "../features/company/singleCompany";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -33,6 +35,7 @@ export function PaymentAccountsPage() {
const { permissions } = useAuth();
const canManage = hasPermission(permissions, "payment.account.manage");
const canReveal = hasPermission(permissions, "payment.account.read_full");
const canManageCompanyInformation = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
const [editing, setEditing] = useState<PaymentAccount | null>(null);
const [legalEntityId, setLegalEntityId] = useState("");
const [bankInstitutionId, setBankInstitutionId] = useState("");
@@ -51,9 +54,19 @@ export function PaymentAccountsPage() {
enabled: canManage,
});
const activeCompanies = legalEntitiesQuery.data?.items ?? [];
const companyState = resolveSingleCompanyState(activeCompanies);
const singleCompany = companyState.kind === "single" ? companyState.company : null;
useEffect(() => {
if (!editing && singleCompany) {
setLegalEntityId(singleCompany.id);
}
}, [editing, singleCompany]);
const resetForm = () => {
setEditing(null);
setLegalEntityId("");
setLegalEntityId(singleCompany?.id ?? "");
setBankInstitutionId("");
setAccountName("");
setAccountNumber("");
@@ -93,17 +106,56 @@ export function PaymentAccountsPage() {
const accounts = accountsQuery.data?.items ?? [];
return (
<PageScaffold>
<PageHeader title="企业银行账户" subtitle="维护法人主体的付款来源账户;完整账号仅在填写原因后临时显示。" />
<PageHeader title="企业银行账户" subtitle="维护当前公司的付款来源账户;完整账号仅在填写原因后临时显示。" />
<div className="mx-auto max-w-6xl space-y-5">
{canManage ? (
{canManage && legalEntitiesQuery.isLoading ? (
<SectionPanel title="企业银行账户">
<div className="text-sm text-zinc-500">正在读取公司信息...</div>
</SectionPanel>
) : null}
{canManage && legalEntitiesQuery.isError ? (
<SectionPanel title="企业银行账户">
<div className="text-sm text-red-600">公司信息读取失败,请刷新后重试。</div>
</SectionPanel>
) : null}
{canManage && !legalEntitiesQuery.isLoading && !legalEntitiesQuery.isError && companyState.kind === "empty" ? (
<SectionPanel title="先完善公司信息">
<div className="space-y-3 text-sm text-zinc-600">
<p>企业银行账户必须关联当前公司。请先完善公司信息,再回来录入付款账户。</p>
{canManageCompanyInformation ? (
<Button asChild type="button" variant="secondary">
<Link to="/system/master-data">完善公司信息</Link>
</Button>
) : (
<p>请联系管理员先完善公司信息。</p>
)}
</div>
</SectionPanel>
) : null}
{canManage && companyState.kind === "multiple" ? (
<SectionPanel title="公司信息需要处理">
<div className="space-y-3 text-sm text-red-600">
<p>检测到多条启用的公司信息,系统不会猜测付款账户应关联哪一条。请先处理公司信息。</p>
{canManageCompanyInformation ? (
<Button asChild type="button" variant="secondary">
<Link to="/system/master-data">查看公司信息</Link>
</Button>
) : null}
</div>
</SectionPanel>
) : null}
{canManage && singleCompany ? (
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="payment-account-legal-entity">法人主体</Label>
<select id="payment-account-legal-entity" className="select-std w-full" value={legalEntityId} disabled={Boolean(editing)} onChange={(event) => setLegalEntityId(event.target.value)}>
<option value="">请选择</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Label>当前公司</Label>
<div className="input-std flex min-h-10 items-center bg-zinc-50 text-sm text-zinc-700" data-testid="payment-account-company">
{editing?.legalEntity.name ?? singleCompany.name}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-bank">开户银行</Label>
@@ -158,7 +210,7 @@ export function PaymentAccountsPage() {
</Dialog>
<Dialog open={Boolean(disableTarget)} onOpenChange={(open) => { if (!open) setDisableTarget(null); }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗?</DialogDescription></DialogHeader>
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "当前公司"} 的当前账户吗?</DialogDescription></DialogHeader>
<DialogFooter><Button type="button" variant="secondary" onClick={() => setDisableTarget(null)}>返回</Button><Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => { if (disableTarget) disableMutation.mutate(disableTarget.id); }}>{disableMutation.isPending ? "停用中..." : "确认停用"}</Button></DialogFooter>
</DialogContent>
</Dialog>
+5 -5
查看文件
@@ -178,7 +178,7 @@ function resolvePurchaseEvidenceHint(item: {
}) {
const missingFields = [
item.supplierId ? null : "供应商",
item.legalEntityId ? null : "法体",
item.legalEntityId ? null : "公司",
item.costCenterId ? null : "成本中心",
item.documentCategoryId ? null : "单据分类",
].filter(Boolean);
@@ -590,7 +590,7 @@ export function PurchaseRequestsPage() {
throw new Error("提交前请选择供应商");
}
if (!legalEntityId) {
throw new Error("提交前请选择法体");
throw new Error("提交前请选择公司");
}
if (!costCenterId) {
throw new Error("提交前请选择成本中心");
@@ -991,7 +991,7 @@ export function PurchaseRequestsPage() {
setPage(1);
}}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -1344,7 +1344,7 @@ export function PurchaseRequestsPage() {
<Input value={currencyCode} onChange={(event) => setCurrencyCode(event.target.value.toUpperCase())} disabled={!canEditDraft} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">法体</label>
<label className="text-sm font-medium text-zinc-700">公司</label>
<select
data-testid="purchase-legal-entity"
className="select-std"
@@ -1358,7 +1358,7 @@ export function PurchaseRequestsPage() {
}
}}
>
<option value="">请选择法体</option>
<option value="">请选择公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
+4 -32
查看文件
@@ -1,40 +1,12 @@
import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
type ManagementItem = {
to: string;
icon: string;
title: string;
description: string;
group: "人员与组织" | "流程与规则" | "系统与审计" | "功能管理";
anyOf: string[];
};
const ITEMS: ManagementItem[] = [
{ to: "/people", icon: "badge", title: "人员", description: "员工档案、入转调离和人员状态。", group: "人员与组织", anyOf: ["system.users"] },
{ to: "/organization", icon: "account_tree", title: "组织", description: "组织结构、部门和汇报关系。", group: "人员与组织", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/system/users", icon: "manage_accounts", title: "账号", description: "账号、角色和登录权限。", group: "人员与组织", anyOf: ["system.accounts.manage", "system.roles.manage"] },
{ to: "/system/departments", icon: "account_tree", title: "部门", description: "部门基础数据维护。", group: "人员与组织", anyOf: ["system.users"] },
{ to: "/system/approval-rules", icon: "rule", title: "审批规则", description: "金额段、审批层级和处理规则。", group: "流程与规则", anyOf: ["system.settings"] },
{ to: "/system/workflow-definitions", icon: "schema", title: "流程定义", description: "流程模板和节点治理。", group: "流程与规则", anyOf: ["system.settings"] },
{ to: "/system/expense-standards", icon: "rule_settings", title: "费用标准", description: "费用上限、类别和管控口径。", group: "流程与规则", anyOf: ["system.settings"] },
{ to: "/system/reimbursement-templates", icon: "receipt_long", title: "报销模板", description: "常见报销场景模板。", group: "流程与规则", anyOf: ["system.settings"] },
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区", description: "菜单入口、模块显隐和小团队基线。", group: "系统与审计", anyOf: ["system.settings"] },
{ to: "/audit-logs", icon: "manage_search", title: "审计日志", description: "关键操作留痕和排障。", group: "系统与审计", anyOf: ["system.audit.view"] },
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
{ to: "/system/payment-accounts", icon: "account_balance", title: "企业银行账户", description: "维护付款来源账户、脱敏查看与变更留痕。", group: "功能管理", anyOf: ["payment.account.read", "payment.account.manage"] },
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
];
const GROUPS: ManagementItem["group"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"];
import { getVisibleManagementItems, MANAGEMENT_GROUPS } from "../features/navigation/managementRoutes";
export function SystemSettingsPage() {
const { permissions } = useAuth();
const visibleItems = ITEMS.filter((item) => hasAnyPermission(permissions, item.anyOf));
const visibleGroups = GROUPS.map((group) => ({
const visibleItems = getVisibleManagementItems(permissions);
const visibleGroups = MANAGEMENT_GROUPS.map((group) => ({
group,
items: visibleItems.filter((item) => item.group === group),
})).filter((entry) => entry.items.length > 0);
@@ -74,7 +46,7 @@ export function SystemSettingsPage() {
<h2 className="text-base font-semibold text-zinc-900">{group}</h2>
<div className="admin-tile-grid">
{items.map((item) => (
<Link key={item.to} to={item.to} className="admin-tile-card">
<Link key={item.key} to={item.path} className="admin-tile-card">
<div className="flex items-start gap-3">
<span className="admin-tile-card__icon">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
+6 -6
查看文件
@@ -407,7 +407,7 @@ export function TravelRequestsPage() {
throw new Error("请选择出差起止日期");
}
if (!legalEntityId || !costCenterId) {
throw new Error("请选择法体与成本中心");
throw new Error("请选择公司与成本中心");
}
if (!estimatedAmount || Number(estimatedAmount) < 0) {
throw new Error("请填写有效的预估金额");
@@ -614,7 +614,7 @@ export function TravelRequestsPage() {
</div>
<div>
<Label htmlFor="travel-filter-legal-entity">法体</Label>
<Label htmlFor="travel-filter-legal-entity">公司</Label>
<select
id="travel-filter-legal-entity"
className="select-std mt-1"
@@ -625,7 +625,7 @@ export function TravelRequestsPage() {
setPage(1);
}}
>
<option value="">全部法体</option>
<option value="">全部公司</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
@@ -828,7 +828,7 @@ export function TravelRequestsPage() {
<p className="font-medium text-zinc-900">{detail.budgetPool?.name ?? "待提交时自动解析"}</p>
</div>
<div>
<p className="text-zinc-500">法体</p>
<p className="text-zinc-500">公司</p>
<p className="font-medium text-zinc-900">{detail.legalEntity?.name ?? "-"}</p>
</div>
<div>
@@ -1030,7 +1030,7 @@ function TravelRequestForm(props: TravelRequestFormProps) {
<Input id="travel-end-date" className="mt-1" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} readOnly={readOnly} />
</div>
<div>
<Label htmlFor="travel-legal-entity">法体</Label>
<Label htmlFor="travel-legal-entity">公司</Label>
<select
id="travel-legal-entity"
className="select-std mt-1"
@@ -1041,7 +1041,7 @@ function TravelRequestForm(props: TravelRequestFormProps) {
}}
disabled={readOnly}
>
<option value="">请选择法体</option>
<option value="">请选择公司</option>
{legalEntities.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
+2 -2
查看文件
@@ -351,7 +351,7 @@ export function WorkflowTaskDetailPage() {
<DetailField label="标题" value={detail.purchaseRequest.title || "-"} />
<DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} />
<DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} />
<DetailField label="法体" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
<DetailField label="公司" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} />
<DetailField label="单据分类" value={detail.purchaseRequest.documentCategory?.name ?? "-"} />
<DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} />
@@ -364,7 +364,7 @@ export function WorkflowTaskDetailPage() {
<DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} />
<DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} />
<DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} />
<DetailField label="法体" value={detail.travelRequest.legalEntity?.name ?? "-"} />
<DetailField label="公司" value={detail.travelRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} />
<DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} />
<DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} />
+28 -20
查看文件
@@ -1,6 +1,7 @@
import { Suspense, type ReactNode } from "react";
import { createBrowserRouter, createRoutesFromElements, Navigate, Outlet, Route } from "react-router-dom";
import { AuthProvider } from "../auth/AuthContext";
import { getManagementRoutePermissions } from "../features/navigation/managementRoutes";
import { RoutePageFallback } from "../components/common/RoutePageFallback";
import { UnsavedChangesProvider } from "../components/common/UnsavedChangesGuard";
import { AppShellLayout } from "../layouts/AppShellLayout";
@@ -209,7 +210,7 @@ const routeElements = (
<Route
path="system/reimbursement-templates"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("reimbursement_templates")}>
{withRouteFallback(<ReimbursementTemplatesPage />)}
</RequirePermission>
}
@@ -494,7 +495,7 @@ const routeElements = (
<Route
path="audit-logs"
element={
<RequirePermission anyOf={["system.audit.view"]}>
<RequirePermission anyOf={getManagementRoutePermissions("audit_logs")}>
{withRouteFallback(<AuditLogsPage />)}
</RequirePermission>
}
@@ -521,7 +522,7 @@ const routeElements = (
<Route
path="performance"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("performance")}>
{withRouteFallback(<PerformancePage />)}
</RequirePermission>
}
@@ -530,7 +531,7 @@ const routeElements = (
<Route
path="organization"
element={
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
<RequirePermission anyOf={getManagementRoutePermissions("organization")}>
{withRouteFallback(<OrganizationPage />)}
</RequirePermission>
}
@@ -539,7 +540,7 @@ const routeElements = (
<Route
path="people"
element={
<RequirePermission anyOf={["system.users"]}>
<RequirePermission anyOf={getManagementRoutePermissions("people")}>
{withRouteFallback(<PeoplePage />)}
</RequirePermission>
}
@@ -548,18 +549,25 @@ const routeElements = (
<Route
path="people/stats"
element={
<RequirePermission anyOf={["system.users"]}>
<RequirePermission anyOf={getManagementRoutePermissions("people_stats")}>
{withRouteFallback(<PeopleStatsPage />)}
</RequirePermission>
}
/>
<Route path="people/:employeeId" element={withRouteFallback(<EmployeeProfilePage />)} />
<Route
path="people/:employeeId"
element={
<RequirePermission anyOf={getManagementRoutePermissions("employee_profile")}>
{withRouteFallback(<EmployeeProfilePage />)}
</RequirePermission>
}
/>
<Route
path="system/users"
element={
<RequirePermission anyOf={["system.accounts.manage", "system.roles.manage"]}>
<RequirePermission anyOf={getManagementRoutePermissions("user_accounts")}>
{withRouteFallback(<UserManagementPage />)}
</RequirePermission>
}
@@ -568,7 +576,7 @@ const routeElements = (
<Route
path="system/departments"
element={
<RequirePermission anyOf={["system.users"]}>
<RequirePermission anyOf={getManagementRoutePermissions("departments")}>
{withRouteFallback(<DepartmentsPage />)}
</RequirePermission>
}
@@ -577,7 +585,7 @@ const routeElements = (
<Route
path="system/master-data"
element={
<RequirePermission anyOf={["masterdata.view", "masterdata.manage"]}>
<RequirePermission anyOf={getManagementRoutePermissions("company_information")}>
{withRouteFallback(<MasterDataPage />)}
</RequirePermission>
}
@@ -586,7 +594,7 @@ const routeElements = (
<Route
path="system/payment-accounts"
element={
<RequirePermission anyOf={["payment.account.read", "payment.account.manage"]}>
<RequirePermission anyOf={getManagementRoutePermissions("payment_accounts")}>
{withRouteFallback(<PaymentAccountsPage />)}
</RequirePermission>
}
@@ -595,7 +603,7 @@ const routeElements = (
<Route
path="system/notification-channels"
element={
<RequirePermission anyOf={["notification.channel.manage"]}>
<RequirePermission anyOf={getManagementRoutePermissions("notification_channels")}>
{withRouteFallback(<NotificationChannelsPage />)}
</RequirePermission>
}
@@ -604,7 +612,7 @@ const routeElements = (
<Route
path="system/workspace"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("workspace")}>
{withRouteFallback(<WorkspaceSetupPage />)}
</RequirePermission>
}
@@ -613,7 +621,7 @@ const routeElements = (
<Route
path="system/settings"
element={
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "payment.account.read", "payment.account.manage"]}>
<RequirePermission anyOf={getManagementRoutePermissions("management")}>
{withRouteFallback(<SystemSettingsPage />)}
</RequirePermission>
}
@@ -622,7 +630,7 @@ const routeElements = (
<Route
path="system/approval-rules"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("approval_rules")}>
{withRouteFallback(<ApprovalRulesPage />)}
</RequirePermission>
}
@@ -631,7 +639,7 @@ const routeElements = (
<Route
path="system/expense-standards"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("expense_standards")}>
{withRouteFallback(<ExpenseStandardsPage />)}
</RequirePermission>
}
@@ -640,7 +648,7 @@ const routeElements = (
<Route
path="system/feature-flags"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("feature_flags")}>
{withRouteFallback(<FeatureFlagsPage />)}
</RequirePermission>
}
@@ -649,7 +657,7 @@ const routeElements = (
<Route
path="system/announcements"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("system_announcements")}>
{withRouteFallback(<PortalAnnouncementsPage />)}
</RequirePermission>
}
@@ -658,7 +666,7 @@ const routeElements = (
<Route
path="system/workflow-definitions"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("workflow_definitions")}>
{withRouteFallback(<WorkflowDefinitionsPage />)}
</RequirePermission>
}
@@ -667,7 +675,7 @@ const routeElements = (
<Route
path="system/cutover"
element={
<RequirePermission anyOf={["system.settings"]}>
<RequirePermission anyOf={getManagementRoutePermissions("cutover")}>
{withRouteFallback(<CutoverConsolePage />)}
</RequirePermission>
}