refactor: 菜单栏角色化降噪(role-based-navigation-simplification)

这个提交包含在:
2026-05-29 21:11:32 +08:00
父节点 325a7950c9
当前提交 541593a5b6
共修改 58 个文件,包含 796 行新增和 366 行删除
@@ -167,8 +167,9 @@ header baseline instead of recreating page-local wrappers:
- `AppShellLayout` owns the PC shell background and content offset through
`app-shell` and `app-shell-main`.
- `Sidebar` and `NavLinks` own primary navigation grouping. The mental model is
`工作` -> `业务` -> `财务` -> `管理` -> collapsed `更多`.
- `Sidebar` and `NavLinks` own role-aware task navigation. The mental model is
a short primary task list plus collapsed `更多`; management configuration is
represented by one `管理` entry instead of many sidebar rows.
- `Topbar` is a compact command surface: page title, group badge, user context,
global search and notifications. Do not expand it back into a large hero.
- `PageHeader` and `WorkspaceHeader` should use `page-header-shell`,
@@ -181,6 +182,26 @@ header baseline instead of recreating page-local wrappers:
**Why**: The shell/header baseline gives Wave3-5 pages a stable visual entry
point and prevents each route from inventing a different title/action layout.
**Navigation Rules**:
- Sidebar entries should be role-aware and task-first for domestic tech startups
under 10 people; do not expose all permission-allowed modules as a default
module catalog.
- Approvers see `待我处理`; ordinary employees should not see `待办` or
`审批待办` in the sidebar by default.
- PC leave navigation uses `请假记录` unless the current task explicitly opens OA
emergency leave submission.
- `个人设置` stays in the topbar account menu. Notifications stay in the topbar
bell and notification center.
- Finance labels should use work names such as `付款`, `应付账款` and `票据`;
internal platform words like `AP 运营`, `ERP 集成` and `功能开关` should be
hidden behind management wording or renamed for users.
- `更多` is a lightweight collapsed row for low-frequency entries; do not add a
sidebar explanation card, subtitle or onboarding copy around it.
- Playwright navigation helpers should use exact names or anchored regex for
short sidebar labels such as `管理`, otherwise fuzzy role-name matching can
click longer labels such as `预算管理`.
**Example**:
```tsx
@@ -0,0 +1,2 @@
{"file":"docs/产品设计规则.md","reason":"检查菜单职责、角色优先级、企微/OA 边界和新增功能进入一级导航的约束是否被遵守。"}
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"检查全局 shell、导航壳层、前端验证和截图覆盖要求是否满足。"}
@@ -0,0 +1,96 @@
# 菜单栏角色化降噪重构设计
## Design Direction
采用“安静的任务型工作栏”:一级导航不是模块目录,而是每类用户今天最可能要点击的任务入口。视觉上保持当前 OA 工具气质,降低解释性文案和卡片感,让菜单通过排序、角色显隐和短中文名表达优先级。
## Information Architecture
### 普通员工
- 工作台
- 报销
- 请假记录
- 我的进度
- 更多
### 审批人 / 负责人
- 工作台
- 待我处理
- 报销
- 项目
- 团队
- 更多
### 财务 / 管理员
- 工作台
- 待我处理
- 报销
- 付款
- 应付账款
- 票据
- 管理
- 更多
具体入口仍受权限和 feature flag 限制;上面是目标信息架构,不代表绕过权限展示。
## Proposed Implementation
1. 服务端导航
- 在 `server/src/services/workspaceSettings.js` 中统一用户可见文案:
- `workflowTasks`: `待我处理`
- `leaveRequests`: `请假记录`
- `paymentBatches`: `付款`
- `apOperations`: `应付账款`
- `invoicePool`: `票据`
- `featureFlags`: `功能管理`
- `erpIntegrations`: `外部系统`
- 调整 `DEFAULT_MODULE_VISIBILITY`:普通高频保留,低频配置、预算、集成、审计等默认后置。
- 保证 `workflowTasks` 只对业务审批人、负责人、财务等可处理角色出现。
- 避免普通员工看到财务、票据、管理、个人设置入口。
2. 前端导航分组
- 在 `client/src/components/navigation/NavLinks.tsx` 中让服务端 placement 和角色语义成为主导。
- 移除“按路径重新把 backup 提升到业务/财务”的行为,或改为只在同一后置层内排序。
- `更多` 改为轻量 details/summary 行,无副标题、无大卡片。
- 侧边栏一级分组减少到任务感更强的结构,避免展示过多 section label。
3. 侧边栏壳层
- 在 `client/src/components/navigation/Sidebar.tsx` 移除 `今日优先` 说明卡。
- 若需要轻量状态提示,后续只在有真实待处理数量时显示;本轮优先移除说明卡,不强制新增数据查询。
4. 顶部和账号入口
- `个人设置` 保留在 `Topbar` 账号菜单,不出现在侧边栏。
- 通知保留顶部铃铛,不加入侧边栏。
5. 移动底栏
- 在 `client/src/components/mobile/MobileBottomNav.tsx` 中把审批人文案改为 `待办`,非审批人继续显示 `进度`。
- 不新增移动底栏 `请假`。
6. 工作区设置页
- 在 `client/src/pages/WorkspaceSetupPage.tsx` 中调整文案:从“主导航/管理区/隐藏”的模块视角,转成“常用入口/管理与低频/隐藏”或等价表达。
- 保留现有能力,不在本轮开发复杂的拖拽或角色矩阵配置。
7. 文档和规则
- 更新 `docs/产品设计规则.md`:新增功能默认不进一级侧边栏,必须先判断角色、频次和真实待办价值。
- 更新 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`:固化导航壳层和菜单降噪规则。
## Risk And Mitigation
- 风险:前端重分组和服务端 placement 互相打架,导致后置模块又被抬到一级。
- 缓解:把服务端结果作为事实源,前端只排序和轻量归类;增加角色导航单测。
- 风险:文案变更影响既有测试断言。
- 缓解:同步更新导航、smoke 和截图断言。
- 风险:管理合并入口没有完整管理首页承接。
- 缓解:本轮可先指向现有工作区设置或现有管理承接页;不做管理页大改。
- 风险:普通员工入口过少导致找不到低频功能。
- 缓解:保留 `更多` 和全局搜索;低频能力隐藏/后置,不删除。
## Verification Plan
- 服务端角色导航单测覆盖 employee、manager、finance、admin、owner。
- 前端 typecheck/build。
- 截图检查 PC 1440 宽侧边栏和移动 390 宽底栏。
- 文档 diff 检查新规则是否只沉淀产品事实和工程约束,不加入视觉细节到 `docs/产品设计规则.md`。
@@ -0,0 +1,2 @@
{"file":"docs/产品设计规则.md","reason":"产品体验事实源,包含小团队目标用户、OA/企微边界、首页/通知/个人设置职责边界。"}
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"前端 shell、导航、A 类任务优先页面和验证要求的工程规约。"}
@@ -0,0 +1,110 @@
# 菜单栏角色化降噪重构
## Goal
把 PC 侧边栏和移动底部导航从“功能模块清单”改成“小团队任务入口”。默认菜单按角色、权限和使用频次显示,优先让用户看清今天要处理什么、要提交什么、要查什么进度;低频配置、后台管理、集成和审计能力后置到 `管理` 或 `更多`。
## What I Already Know
- 目标用户是国内、小于 10 人的科技初创公司,常见一人多岗,默认不是专职 OA 操作员。
- 已通过 `grill-me` 和 `frontend-design` 审查确认:本轮只重构导航信息架构和菜单壳层,不顺手重构业务页面内容。
- 当前前端主导航在 `client/src/components/navigation/NavLinks.tsx`,侧边栏在 `client/src/components/navigation/Sidebar.tsx`,顶部命令区在 `client/src/components/navigation/Topbar.tsx`。
- 当前服务端导航注册和默认显隐在 `server/src/services/workspaceSettings.js`,系统导航接口为 `/api/system/navigation`。
- 当前移动底部导航在 `client/src/features/mobile/navigation.ts` 和 `client/src/components/mobile/MobileBottomNav.tsx`。
- 当前工作区设置页会展示模块显隐表:`client/src/pages/WorkspaceSetupPage.tsx`。
- 现有文档已要求 `首页` 不承担常驻新手教学职责,`个人设置` 不承担权限维护默认面,`请假/考勤` 日常优先企业微信。
## Confirmed Decisions
- 侧边栏正式按角色显示不同首屏菜单,不再所有人共用一套模块清单。
- PC 侧边栏 `请假` 改为 `请假记录`,提交请假不作为默认 PC 主入口;只有 OA 应急接管或指定管理场景才考虑显示提交入口。
- 合并 `待办`、`审批待办`、通知、工作台之间的职责:审批人看到 `待我处理`,普通员工默认不显示 `待办`,通知只保留顶部铃铛,工作台只做摘要。
- 管理入口从侧边栏多项展开改为一个 `管理`,进入管理页后再分 `人员与组织 / 流程与规则 / 系统与审计 / 功能管理`。
- 财务入口改成真实工作名:`付款批次` -> `付款`,`AP 运营` -> `应付账款`,`ERP 集成` -> `外部系统`,`功能开关` -> `功能管理`。
- `票据中心` 只对财务/票据管理角色保留,普通员工不显示。
- `预算管理` 默认不放一级菜单,进入管理或更多。
- 移除侧边栏顶部 `今日优先` 说明卡;后续只保留真实轻量状态提示,且没有真实数据时不显示。
- `更多` 只作为低频入口折叠区,不放解释性副标题,不做大卡片。
- 移动端底栏同步语义:`首页 / 待办或进度 / 报销 / 我的`,审批人显示 `待办`,非审批人显示 `进度`,不加入 `请假`。
- 这套菜单规则要沉淀到 `docs/产品设计规则.md` 和 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`,作为后续功能开发硬约束。
## Requirements
- R1: PC 侧边栏一级可见入口应按角色收敛到高频任务,不展示权限允许但低频的后台模块清单。
- R2: 审批人/负责人/财务角色应看到 `待我处理`;普通员工默认不看到 `待办` 或 `审批待办` 入口。
- R3: `审批待办` 用户文案统一改为 `待我处理`,同时保留原有路由 `/workflow/tasks`。
- R4: PC `请假` 用户文案改为 `请假记录`;不得让 PC 默认菜单暗示 OA 是请假日常主办入口。
- R5: `个人设置` 不出现在 PC 侧边栏,只从右上角账号菜单进入。
- R6: 管理类入口在侧边栏合并为 `管理`;管理页内部再承接具体配置、人员、流程、审计和功能管理入口。
- R7: 财务类入口使用真实工作名,避免 `AP 运营`、`ERP 集成`、`功能开关` 这类偏内部平台词占据主菜单。
- R8: 前端不得把服务端 `backup` 或低频模块重新提升到 `业务` / `财务` 默认分组。
- R9: `更多` 应是轻量折叠入口;不得使用说明卡、副标题或大圆角卡片样式。
- R10: 移动底部导航对审批人显示 `待办`,非审批人显示 `进度`;移动底栏不加入 `请假`。
- R11: 工作区设置页的模块显隐表达要与新菜单规则一致,减少“主导航/管理区/隐藏”导致的误解。
- R12: 更新产品设计规则和前端 Trellis spec,明确新增功能默认不得直接进入一级侧边栏。
## Scope
- In scope: PC 侧边栏、系统导航 API、移动底部导航、管理聚合页、工作区设置文案、导航相关测试、产品设计规则和前端 Trellis spec。
- Out of scope: 业务页面主体重构、路由路径迁移、审批状态机或权限模型重做、企业微信请假主办流程、生产部署和真实数据写入。
## Acceptance Criteria
- [x] 普通员工 PC 侧边栏默认只显示少量高频入口,不出现 `待办`、`审批待办`、`票据中心`、`个人设置`、管理配置项。
- [x] 审批人/负责人 PC 侧边栏显示 `待我处理`,不再显示 `审批待办`。
- [x] 财务/管理员 PC 侧边栏显示真实工作入口,例如 `付款`、`应付账款`、`票据`、`管理`,低频配置不铺开。
- [x] `请假` 在 PC 主菜单中改为 `请假记录`,移动底栏不新增请假入口。
- [x] `更多` 是轻量折叠行,展开后只承载低频入口,无说明副标题和大卡片视觉。
- [x] 侧边栏顶部不再显示 `今日优先` 解释卡。
- [x] 移动底栏审批人显示 `待办`,非审批人显示 `进度`。
- [x] 工作区设置页文案与新导航分层一致,不再鼓励把低频模块直接放进主导航。
- [x] 导航相关单元测试覆盖普通员工、审批人、财务、管理员角色的菜单结果。
- [x] 前端 typecheck/build 通过,并保留 PC/移动菜单截图证据。
- [x] `docs/产品设计规则.md` 和 `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 已更新菜单规则。
## Out Of Scope
- 不重构报销列表、审批中心、财务台账、管理页面主体内容。
- 不改路由路径,除非现有路径无法承接新文案。
- 不重做权限模型、审批状态机、企业微信请假主办流程。
- 不新增真实生产数据写入、生产配置修改或企业微信写入。
- 不把所有历史模块删除;低频能力以隐藏、更多或管理承接为主。
## Technical Notes
- 需要重点处理服务端导航和前端重分组的职责边界,避免服务端已后置的模块被前端路径规则重新提升。
- 需要保持权限控制由服务端和现有权限工具兜底,前端只做展示和分组,不创造越权入口。
- 管理合并入口可以先指向现有 `工作区设置` 或新建轻量管理聚合页;本轮不做深层管理页面大改。
- 视觉方向按 `frontend-design`:安静、工具型、密集但可扫描;避免说明卡、营销式引导和模块堆砌。
## Test Plan
- `cd server && npm test -- --runInBand workspaceSettings.navigation.test.js` 或等价聚焦单测命令。
- `cd client && npm run typecheck`
- `cd client && npm run build`
- 聚焦 Playwright/截图验证:
- PC 普通员工侧边栏
- PC 审批人/负责人侧边栏
- PC 财务或管理员侧边栏
- 移动底部导航审批人/非审批人文案
- `git diff --check`
## Verification Results
- PASS: `cd server && node --check src/services/workspaceSettings.js`
- PASS: `cd server && npm test -- workspaceSettings.navigation.test.js`
- PASS: `cd server && npm run prisma:validate`
- PASS: `cd client && npm run typecheck`
- PASS: `cd client && npm run build`
- PASS: `cd client && E2E_BASE_URL=http://127.0.0.1:3001 npx playwright test tests/e2e/visual-shell.spec.ts --workers=1 --reporter=list`
- PASS: `cd client && E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test tests/e2e/settings-governance.spec.ts --workers=1 --reporter=list`
- PASS: `node scripts/qa/check-trellis-governance.mjs`
- PASS: `python3 ./.trellis/scripts/task.py validate 05-29-role-based-navigation-simplification`
- PASS: `git diff --check`
- Screenshot evidence: `output/playwright/role-based-navigation-simplification/employee-desktop.png`, `manager-desktop.png`, `finance-mgr-desktop.png`, `employee-mobile.png`, `manager-mobile.png`.
## Definition Of Done
- 需求、实现、验证、文档和截图证据在本 Trellis task 中可追溯。
- 代码提交使用中文 commit message,并引用 `role-based-navigation-simplification`。
- 面向用户可测试前,如涉及部署,必须部署并完成公网检查后再告知用户可测。
@@ -0,0 +1,26 @@
{
"id": "role-based-navigation-simplification",
"name": "role-based-navigation-simplification",
"title": "菜单栏角色化降噪重构",
"description": "按国内 10 人以下科技初创公司的小团队使用场景,重构 PC 侧边栏与移动底部导航的信息架构,让菜单按角色和高频任务降噪。",
"status": "in_progress",
"dev_type": null,
"scope": "frontend-backend-docs",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-29",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+1 -1
查看文件
@@ -207,7 +207,7 @@ export function hasBusinessApproverRole(roleCodes: string[] | undefined, permiss
}
export function hasFinanceRole(roleCodes: string[] | undefined, permissionCodes: string[]) {
const hasRole = (roleCodes ?? []).some((roleCode) => (FINANCE_ROLE_CODES as readonly string[]).includes(roleCode));
const hasRole = (roleCodes ?? []).some((roleCode) => (FINANCE_ROLE_CODES as readonly string[]).includes(roleCode) || roleCode === "admin");
return hasRole && hasAnyPermission(permissionCodes, [...FINANCE_PORTAL_PERMISSIONS]);
}
+87 -133
查看文件
@@ -1,4 +1,4 @@
import { NavLink } from "react-router-dom";
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";
@@ -19,67 +19,53 @@ export type NavItem = {
export const NAV_ITEMS: NavItem[] = [
{ to: "/portal", label: "工作台", icon: "home_app_logo" },
{ to: "/team", label: "团队", icon: "groups" },
{ to: "/leave-requests", label: "请假", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
{ 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: "/todos", label: "待办", icon: "check_circle" },
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] },
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["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"] },
{ 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"] },
{ to: "/ap-operations", label: "AP 运营", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/invoice-pool", label: "票据中心", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
{ to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] },
{ to: "/system/integrations/erp", label: "外部系统桥接", icon: "hub", anyOf: ["integration.manage"] },
{ to: "/dashboard", label: "仪表盘", icon: "dashboard", anyOf: ["report.dashboard.view"] },
{ to: "/settings/profile", label: "个人设置", icon: "manage_accounts" },
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
];
const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
const ACCESSIBLE_LABELS: Record<string, string> = {
"/reimbursements": "我的报销",
"/workflow/tasks": "待我处理",
"/assets": "资产台账",
};
const WORK_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/todos"];
const BUSINESS_NAV_ORDER = [
"/projects",
"/contracts-registry",
"/purchase-requests",
"/assets",
"/weekly-reports",
"/team",
"/announcements",
"/leave-requests",
"/attendance",
"/wecom-approval-archive",
];
const FINANCE_NAV_ORDER = ["/payment-batches", "/ap-operations", "/invoice-pool", "/budgets", "/inventory-checks"];
const MANAGEMENT_NAV_ORDER = [
"/people",
"/organization",
const MANAGEMENT_ACTIVE_PATHS = [
"/system/settings",
"/system/workspace",
"/system/workflow-definitions",
"/system/settings",
"/system/feature-flags",
"/system/approval-rules",
"/system/expense-standards",
"/system/reimbursement-templates",
"/system/master-data",
"/system/notification-channels",
"/system/integrations/erp",
"/system/users",
"/system/departments",
"/system/cutover",
"/people",
"/people/stats",
"/organization",
"/audit-logs",
];
const MORE_NAV_ORDER = ["/travel-requests", "/dashboard", "/settings/profile", "/system/cutover", "/system/integrations/erp"];
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
type ResolvedNavItem = {
key?: string;
@@ -88,8 +74,16 @@ type ResolvedNavItem = {
icon: string;
};
function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
const accessibleLabel = LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label;
function isGroupedActive(item: ResolvedNavItem, pathname: string) {
if (item.href === "/system/settings") {
return MANAGEMENT_ACTIVE_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
}
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () => void) {
const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label;
return (
<NavLink
@@ -99,68 +93,65 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
title={accessibleLabel}
onClick={() => onNavigate?.()}
className={({ isActive }) =>
cn(
"group nav-link-item",
isActive ? "nav-link-item--active" : "nav-link-item--idle",
)
cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle")
}
>
{({ isActive }) => (
<>
<span
className={cn(
"nav-link-item__icon",
isActive ? "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>
</>
)}
{({ isActive }) => {
const active = isActive || isGroupedActive(item, pathname);
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 renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<section className="nav-section">
<div className="nav-section__label">{title}</div>
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
</section>
);
}
function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void) {
function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<details className="rounded-2xl border border-slate-200 bg-slate-50/80 px-3 py-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-[var(--color-text-primary)]">
<div>
<div>更多</div>
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">低频入口和历史模块</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
<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-3 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, onNavigate))}</div>
<div className="mt-1.5 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
</details>
);
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/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) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
.filter((item) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode)))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || roleCodes.includes("admin"))
.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 {
@@ -170,77 +161,43 @@ function buildFallbackItems(permissions: string[], roleCodes: string[]) {
};
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
function orderItems(items: ResolvedNavItem[], order: string[]) {
const byHref = new Map<string, ResolvedNavItem>();
for (const item of allItems) {
for (const item of items) {
if (!byHref.has(item.href)) {
byHref.set(item.href, item);
}
}
const used = new Set<string>();
const take = (hrefs: string[]) =>
hrefs
.map((href) => {
const item = byHref.get(href);
if (item) {
used.add(href);
}
return item;
})
.filter((item): item is ResolvedNavItem => Boolean(item));
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 {
main: take(MAIN_NAV_ORDER),
admin: take(ADMIN_NAV_ORDER),
backup: [
...take(BACKUP_NAV_ORDER),
...allItems
.filter((item) => !used.has(item.href))
.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
],
};
return [...ordered, ...items.filter((item) => !used.has(item.href))];
}
function regroupSections(sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }) {
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
const byHref = new Map<string, ResolvedNavItem>();
for (const item of allItems) {
if (!byHref.has(item.href)) {
byHref.set(item.href, item);
}
}
const used = new Set<string>();
const take = (hrefs: string[]) =>
hrefs
.map((href) => {
const item = byHref.get(href);
if (item) {
used.add(href);
}
return item;
})
.filter((item): item is ResolvedNavItem => Boolean(item));
function toResolved(items: NavigationItem[]) {
return items.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon }));
}
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
return {
work: take(WORK_NAV_ORDER),
business: take(BUSINESS_NAV_ORDER),
finance: take(FINANCE_NAV_ORDER),
management: take(MANAGEMENT_NAV_ORDER),
more: [
...take(MORE_NAV_ORDER),
...allItems
.filter((item) => !used.has(item.href))
.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
],
main: orderItems(toResolved(sections.main), MAIN_NAV_ORDER),
admin: orderItems(toResolved(sections.admin), ADMIN_NAV_ORDER),
backup: orderItems(toResolved(sections.backup), BACKUP_NAV_ORDER),
};
}
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const { me, permissions } = useAuth();
const location = useLocation();
const navigationQuery = useQuery({
queryKey: ["system-navigation"],
queryFn: fetchSystemNavigation,
@@ -250,15 +207,12 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const sections = navigationQuery.data?.sections
? normalizeSections(navigationQuery.data.sections)
: buildFallbackItems(permissions, me?.roleCodes ?? []);
const groupedSections = regroupSections(sections);
const primaryItems = [...sections.main, ...sections.admin];
return (
<nav className="space-y-5">
{renderSection("工作", groupedSections.work, onNavigate)}
{renderSection("业务", groupedSections.business, onNavigate)}
{renderSection("财务", groupedSections.finance, onNavigate)}
{renderSection("管理", groupedSections.management, onNavigate)}
{renderBackupSection(groupedSections.more, onNavigate)}
<nav className="space-y-3.5">
{renderPrimarySection(primaryItems, location.pathname, onNavigate)}
{renderBackupSection(sections.backup, location.pathname, onNavigate)}
</nav>
);
}
@@ -6,13 +6,6 @@ export function Sidebar() {
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 lg:flex lg:flex-col">
<div className="border-b border-slate-200 px-6 py-5">
<AppBrand />
<div className="sidebar-focus-card mt-4 text-[var(--color-text-secondary)]">
<div className="flex items-center gap-2 font-semibold text-[var(--color-text-primary)]">
<span className="size-2 rounded-full bg-primary" />
今日优先
</div>
<div className="mt-1.5 leading-6">待办、报销、项目和财务流程按优先级进入。</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-width:thin]">
+1 -1
查看文件
@@ -42,7 +42,7 @@ export type MobileNavItem = {
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
{ key: "home", to: "/mobile", label: "首页", icon: "home" },
{ key: "approvals", to: "/mobile/approvals", label: "审批", icon: "fact_check" },
{ key: "approvals", to: "/mobile/approvals", label: "待办", icon: "fact_check" },
{ key: "reimbursements", to: "/mobile/reimbursements", label: "报销", icon: "request_quote", anyOf: MOBILE_REIMBURSEMENT_PERMISSIONS },
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
];
+11 -11
查看文件
@@ -1,6 +1,6 @@
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
export const BACKUP_NAV_ORDER = ["/attendance", "/wecom-approval-archive", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/payment-batches", "/ap-operations", "/invoice-pool", "/projects", "/team", "/leave-requests"];
export const ADMIN_NAV_ORDER = ["/system/settings"];
export const BACKUP_NAV_ORDER = ["/assets", "/weekly-reports", "/announcements", "/purchase-requests", "/contracts-registry", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/portal", title: "工作台", group: "工作台" },
@@ -13,18 +13,18 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/purchase-requests", title: "采购申请", group: "业务" },
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
{ path: "/budgets", title: "预算管理", group: "财务" },
{ path: "/payment-batches", title: "付款批次", group: "财务" },
{ path: "/ap-operations", title: "应付作业", group: "财务" },
{ path: "/invoice-pool", title: "票据中心", group: "财务" },
{ path: "/payment-batches", title: "付款", group: "财务" },
{ path: "/ap-operations", title: "应付账款", group: "财务" },
{ path: "/invoice-pool", title: "票据", group: "财务" },
{ path: "/inventory-checks", title: "盘点任务", group: "财务" },
{ path: "/weekly-reports", title: "周报", group: "业务" },
{ path: "/todos", title: "待办", group: "工作" },
{ path: "/reimbursements/", title: "报销详情", group: "工作" },
{ path: "/reimbursements", title: "报销", group: "工作" },
{ path: "/leave-requests", title: "请假", group: "业务" },
{ path: "/leave-requests", title: "请假记录", group: "业务" },
{ path: "/assets", title: "资产", group: "业务" },
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
{ path: "/workflow/tasks", title: "审批待办", group: "工作" },
{ path: "/workflow/tasks", title: "待我处理", group: "工作" },
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
@@ -41,10 +41,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ 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/settings", title: "管理", group: "管理" },
{ path: "/system/feature-flags", title: "功能管理", group: "管理" },
{ path: "/system/cutover", title: "切换控制台", group: "更多" },
{ path: "/system/integrations/erp", title: "ERP 集成", group: "更多" },
{ path: "/system/integrations/erp", title: "外部系统", group: "更多" },
{ path: "/dashboard", title: "数据看板", group: "更多" },
{ path: "/audit-logs", title: "审计日志", group: "管理" },
];
+22
查看文件
@@ -152,6 +152,28 @@
color: var(--color-accent-primary);
}
.nav-more-section {
@apply space-y-1.5;
}
.nav-more-section__summary {
color: var(--color-text-secondary);
@apply flex cursor-pointer list-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm transition duration-150 hover:bg-slate-50 hover:text-slate-950;
}
.nav-more-section__summary::-webkit-details-marker {
display: none;
}
.nav-more-section__chevron {
color: var(--color-text-tertiary);
@apply text-[18px] transition-transform;
}
.nav-more-section[open] .nav-more-section__chevron {
transform: rotate(180deg);
}
.auth-shell {
background:
radial-gradient(circle at 20% 12%, rgba(0, 57, 138, 0.10), transparent 26rem),
+1 -1
查看文件
@@ -262,7 +262,7 @@ export function ErpIntegrationPage() {
return (
<div className="space-y-6">
<PageHeader
title="ERP 集成"
title="外部系统"
subtitle="主数据同步、业务队列和 ID 映射。"
actions={
tab === "master-data" ? (
+3 -3
查看文件
@@ -81,9 +81,9 @@ function formatRolloutTypeLabel(value: FeatureFlagRolloutType) {
}
function formatSectionLabel(value: FeatureFlagSection) {
if (value === "main") return "主导航";
if (value === "main") return "常用入口";
if (value === "admin") return "管理入口";
if (value === "backup") return "备用入口";
if (value === "backup") return "更多";
return value;
}
@@ -324,7 +324,7 @@ export function FeatureFlagsPage() {
return (
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
<WorkspaceHeader
title="功能开关"
title="功能管理"
subtitle="外部桥接、验真、灰度和试运行边界。"
/>
<WorkspaceNotice title="安全摘要" tone="warning">
+1 -1
查看文件
@@ -1033,7 +1033,7 @@ export function MobileApprovalsPage() {
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "审批" : "审批进度"}
title={canAccessRemoteApprovals ? "待办" : "进度"}
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
/>
)}
+1 -1
查看文件
@@ -128,7 +128,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "待办" : "进度"} icon="fact_check" />
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与付款状态" icon="account_balance_wallet" /> : null}
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
@@ -75,7 +75,7 @@ const ROLE_OPTIONS = [
] as const;
const DOMAIN_SCOPE_OPTIONS = [
{ value: "workflow", label: "审批待办" },
{ value: "workflow", label: "待我处理" },
{ value: "reimbursement", label: "报销" },
{ value: "leave", label: "请假" },
{ value: "procurement", label: "采购" },
+1 -1
查看文件
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
{ label: "审批待办", value: "workflow.task_assigned" },
{ label: "待我处理", value: "workflow.task_assigned" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
+5 -5
查看文件
@@ -163,8 +163,8 @@ function buildTodoAggregateItems(payload: PortalHomePayload) {
const items: TodoAggregateItem[] = [];
if (payload.pendingTasks.count > 0) {
items.push({
title: `审批待办 ${payload.pendingTasks.count} 条`,
sourceLabel: "审批",
title: `待我处理 ${payload.pendingTasks.count} 条`,
sourceLabel: "待办",
severity: "warning",
href: payload.pendingTasks.href,
icon: "fact_check",
@@ -925,9 +925,9 @@ export function PortalHomePage() {
? {
key: "approval-pending",
href: payload.pendingTasks.href,
label: "审批待办",
label: "待我处理",
value: `${payload.pendingTasks.count} 条`,
note: payload.pendingTasks.count > 0 ? "进入审批中心处理。" : "暂无待处理审批。",
note: payload.pendingTasks.count > 0 ? "进入待我处理办理。" : "暂无待处理事项。",
tone: payload.pendingTasks.count > 0 ? "warning" as const : "normal" as const,
}
: null,
@@ -1051,7 +1051,7 @@ export function PortalHomePage() {
? { label: "报销进度", to: "/reimbursements" }
: null,
payload.pendingTasks.count > 0 && approvalAction?.to
? { label: "审批中心", to: approvalAction.to }
? { label: "待我处理", to: approvalAction.to }
: null,
todoAggregateItems.length > 1
? { label: "全部待办", to: "/todos" }
+76 -27
查看文件
@@ -1,41 +1,90 @@
import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
const ITEMS = [
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区设置", description: "导航与模块显隐。" },
{ to: "/people", icon: "badge", title: "人员管理", description: "入转调离与档案。" },
{ to: "/system/approval-rules", icon: "account_tree", title: "审批规则", description: "审批链与规则。" },
{ to: "/system/expense-standards", icon: "rule_settings", title: "费用标准", description: "费用上限与管控。" },
{ to: "/system/reimbursement-templates", icon: "receipt_long", title: "报销模板", description: "常见报销模板。" },
{ to: "/system/master-data", icon: "database", title: "主数据", description: "法体、成本中心、供应商。" },
{ to: "/system/notification-channels", icon: "notifications", title: "通知渠道", description: "IM / 邮件补充通知。" },
{ to: "/system/integrations/erp", icon: "hub", title: "外部系统桥接", description: "财务出入口与桥接。" },
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", "report.dashboard.view"] },
{ 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.users"] },
{ 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/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"] },
{ to: "/system/integrations/erp", icon: "hub", title: "外部系统", description: "财务、ERP 和外部系统连接。", group: "功能管理", anyOf: ["integration.manage"] },
];
const GROUPS: ManagementItem["group"][] = ["人员与组织", "流程与规则", "系统与审计", "功能管理"];
export function SystemSettingsPage() {
const { permissions } = useAuth();
const visibleItems = ITEMS.filter((item) => hasAnyPermission(permissions, item.anyOf));
const visibleGroups = GROUPS.map((group) => ({
group,
items: visibleItems.filter((item) => item.group === group),
})).filter((entry) => entry.items.length > 0);
return (
<ListWorkspace>
<WorkspaceHeader title="系统设置" subtitle="后台能力入口按管理职责收拢。" />
<WorkspaceHeader title="管理" subtitle="低频配置集中到这里,侧边栏只保留一个管理入口。" />
<div className="finance-status-strip" data-testid="system-settings-summary">
<div className="finance-metric-card"><div className="finance-metric-card__label">入口总数</div><div className="finance-metric-card__value">{ITEMS.length}</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">业务配置</div><div className="finance-metric-card__value">5</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">外部桥接</div><div className="finance-metric-card__value">2</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">人员相关</div><div className="finance-metric-card__value">1</div></div>
<div className="finance-metric-card"><div className="finance-metric-card__label">模式</div><div className="finance-metric-card__value text-base">小团队</div></div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">可用入口</div>
<div className="finance-metric-card__value">{visibleItems.length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">人员与组织</div>
<div className="finance-metric-card__value">{visibleItems.filter((item) => item.group === "人员与组织").length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">流程与规则</div>
<div className="finance-metric-card__value">{visibleItems.filter((item) => item.group === "流程与规则").length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">系统与审计</div>
<div className="finance-metric-card__value">{visibleItems.filter((item) => item.group === "系统与审计").length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">模式</div>
<div className="finance-metric-card__value text-base">小团队</div>
</div>
</div>
<div className="admin-tile-grid">
{ITEMS.map((item) => (
<Link key={item.to} to={item.to} 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>
</span>
<span className="min-w-0">
<span className="block text-base font-semibold text-zinc-900">{item.title}</span>
<span className="mt-1 block text-sm leading-5 text-zinc-600">{item.description}</span>
</span>
<div className="space-y-5">
{visibleGroups.map(({ group, items }) => (
<section key={group} className="admin-command-panel space-y-3">
<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">
<div className="flex items-start gap-3">
<span className="admin-tile-card__icon">
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
</span>
<span className="min-w-0">
<span className="block text-base font-semibold text-zinc-900">{item.title}</span>
<span className="mt-1 block text-sm leading-5 text-zinc-600">{item.description}</span>
</span>
</div>
</Link>
))}
</div>
</Link>
</section>
))}
</div>
</ListWorkspace>
+16 -16
查看文件
@@ -11,7 +11,7 @@ import { toast } from "sonner";
const MODULE_LABELS: Record<string, string> = {
portal: "工作台",
team: "团队",
leaveRequests: "请假",
leaveRequests: "请假记录",
attendance: "考勤",
reimbursements: "报销",
assets: "资产",
@@ -24,31 +24,31 @@ const MODULE_LABELS: Record<string, string> = {
organization: "组织架构",
contractsRegistry: "合同台账",
workflowDefinitions: "流程定义",
systemSettings: "系统设置",
featureFlags: "功能开关",
systemSettings: "管理",
featureFlags: "功能管理",
auditLogs: "审计日志",
approvalRules: "审批规则",
userManagement: "用户管理",
departments: "部门管理",
masterData: "主数据",
notificationChannels: "通知渠道",
workflowTasks: "审批待办",
workflowTasks: "待我处理",
dashboard: "仪表盘",
purchaseRequests: "采购申请",
travelRequests: "差旅申请",
budgets: "预算管理",
paymentBatches: "付款批次",
apOperations: "AP 运营",
invoicePool: "票据中心",
paymentBatches: "付款",
apOperations: "应付账款",
invoicePool: "票据",
inventoryChecks: "资产盘点",
cutover: "切换控制台",
erpIntegrations: "ERP 集成",
erpIntegrations: "外部系统",
profile: "个人设置",
};
const VISIBILITY_OPTIONS = [
{ value: "main", label: "主导航" },
{ value: "admin", label: "管理区" },
{ value: "main", label: "常用入口" },
{ value: "admin", label: "管理与更多" },
{ value: "hidden", label: "隐藏" },
] as const;
@@ -101,7 +101,7 @@ export function WorkspaceSetupPage() {
<WorkspaceHeader
eyebrow="管理"
title="工作区设置"
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
subtitle="按 10 人以下小团队模式收敛常用入口与低频模块。"
/>
<div className="admin-command-panel text-sm text-zinc-500">加载工作区设置中...</div>
</ListWorkspace>
@@ -113,7 +113,7 @@ export function WorkspaceSetupPage() {
<WorkspaceHeader
eyebrow="管理"
title="工作区设置"
subtitle="按小团队运营模式收敛主导航、管理入口和备用模块。"
subtitle="按小团队运营模式收敛常用入口、管理入口和低频模块。"
/>
<div className="finance-status-strip" data-testid="workspace-settings-summary">
@@ -122,11 +122,11 @@ export function WorkspaceSetupPage() {
<div className="finance-metric-card__value">{workspaceStats.total}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">主导航</div>
<div className="finance-metric-card__label">常用入口</div>
<div className="finance-metric-card__value">{workspaceStats.main}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">管理区</div>
<div className="finance-metric-card__label">管理与更多</div>
<div className="finance-metric-card__value">{workspaceStats.admin}</div>
</div>
<div className="finance-metric-card">
@@ -171,9 +171,9 @@ export function WorkspaceSetupPage() {
<section className="admin-command-panel space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">模块显隐</h3>
<h3 className="text-base font-semibold text-zinc-900">入口显隐</h3>
<p className="mt-1 text-sm leading-6 text-zinc-600">
主导航只保留高频入口;备用模块保留代码与数据,低频功能默认收进管理区或隐藏。
常用入口只保留高频任务;低频能力保留代码与数据,默认收进管理、更多或隐藏。
</p>
</div>
@@ -135,7 +135,7 @@ async function mockApis(page: Page, ledgerQueries: string[]) {
sections: {
main: [navigationItem("portal", "/portal", "工作台", "home_app_logo")],
admin: [],
backup: [navigationItem("ap", "/ap-operations", "AP 运营", "account_balance_wallet")],
backup: [navigationItem("ap", "/ap-operations", "应付账款", "account_balance_wallet")],
},
});
return;
@@ -508,7 +508,7 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
main: [
navigationItem("portal", "/portal", "工作台", "home"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
],
admin: [],
backup: [],
@@ -938,7 +938,7 @@ test.describe("MIF7 移动信息架构整改", () => {
await loginForMobile(mobile.page, "manager");
await mobile.page.goto("/mobile");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("待办");
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
await expect(mobile.page.getByTestId("mobile-action-approvals")).toHaveCount(0);
@@ -31,7 +31,7 @@ export class AppShell {
}
async assertMainNavVisible() {
await expect(this.page.getByRole("link", { name: "工作台" }).or(this.page.getByRole("link", { name: "Portal" })).first()).toBeVisible();
await expect(this.page.getByRole("link", { name: "工作台" }).first()).toBeVisible();
await expect(this.page.getByRole("link", { name: /项目/ }).first()).toBeVisible();
}
+2 -10
查看文件
@@ -23,11 +23,7 @@ type Envelope<T> = {
export async function assertPortalLoaded(page: Page) {
await expect(page).toHaveURL(/\/portal/);
await expect(
page.getByRole("link", { name: "工作台", exact: true })
.or(page.getByRole("link", { name: "Portal", exact: true }))
.first(),
).toBeVisible();
await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
}
@@ -37,11 +33,7 @@ export async function assertForbiddenPage(page: Page) {
}
export async function assertMainNavVisible(page: Page) {
await expect(
page.getByRole("link", { name: "工作台", exact: true })
.or(page.getByRole("link", { name: "Portal", exact: true }))
.first(),
).toBeVisible();
await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible();
await expect(page.getByRole("link", { name: "团队" }).first()).toBeVisible();
await expect(page.getByRole("link", { name: "项目" }).first()).toBeVisible();
}
+1 -1
查看文件
@@ -58,7 +58,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
await login(page, EMPLOYEE);
await (await openNavLink(page, "票据中心")).click();
await (await openNavLink(page, "票据")).click();
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByTestId("invoice-upload-file").setInputFiles({
+3 -3
查看文件
@@ -81,7 +81,7 @@ test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async (
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveId = await createLeaveDraft(page, startDate, startDate, `Playwright 编辑请假 ${Date.now()}`);
@@ -125,7 +125,7 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
@@ -177,7 +177,7 @@ test("E2E 请假退回说明:审批退回后员工可看到处理意见", asyn
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveId = await createLeaveDraft(page, leaveDate, leaveDate, `Playwright 退回请假 ${Date.now()}`);
+1 -1
查看文件
@@ -89,7 +89,7 @@ test.describe("live smoke", () => {
await expect(page.getByRole("heading", { name: "工作区设置", exact: true })).toBeVisible();
await page.goto("/system/feature-flags");
await expect(page.getByRole("heading", { name: "功能开关", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "功能管理", exact: true })).toBeVisible();
await page.goto("/audit-logs");
await expect(page.getByRole("heading", { name: "审计工作台", exact: true })).toBeVisible();
+17 -24
查看文件
@@ -181,43 +181,36 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
const sections = mode === "manager"
? {
main: [
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "todos", href: "/todos", label: "待办", icon: "check_circle", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
],
admin: [
{ key: "workspace", href: "/system/workspace", label: "工作区设置", icon: "tune", placement: "admin", permissionCodes: [], featureFlagKey: null },
{ key: "people", href: "/people", label: "人员管理", icon: "group", placement: "admin", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/system/workflow-definitions", label: "流程定义", icon: "schema", placement: "admin", permissionCodes: [], featureFlagKey: null },
{ key: "settings", href: "/system/settings", label: "系统设置", icon: "settings", placement: "admin", permissionCodes: [], featureFlagKey: null },
{ key: "feature-flags", href: "/system/feature-flags", label: "功能开关", icon: "toggle_on", placement: "admin", permissionCodes: [], featureFlagKey: null },
{ key: "audit", href: "/audit-logs", label: "审计日志", icon: "manage_search", placement: "admin", permissionCodes: [], featureFlagKey: null },
{ key: "settings", href: "/system/settings", label: "管理", icon: "settings", placement: "admin", permissionCodes: [], featureFlagKey: null },
],
backup: [
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "travel", href: "/travel-requests", label: "差旅申请", icon: "flight", placement: "main", permissionCodes: [], featureFlagKey: null },
],
}
: {
main: [
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "todos", href: "/todos", label: "待办", icon: "check_circle", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
],
admin: [],
backup: [],
backup: [
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "backup", permissionCodes: [], featureFlagKey: null },
],
};
await route.fulfill({
@@ -266,7 +259,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "Manager" },
cards: [
{ key: "pending_approvals", title: "审批待办", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助员工", value: 1, href: "/weekly-reports", severity: "high" },
@@ -281,7 +274,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
{ domain: "leave", count: 0, href: "/leave-requests" },
],
riskSummary: { openCount: 1, href: "/ap-operations" },
announcements: [{ id: "announcement-1", title: "试点公告", summary: "今天统一从 Portal 进入。", href: null }],
announcements: [{ id: "announcement-1", title: "试点公告", summary: "今天统一从工作台进入。", href: null }],
quickLinks: [{ key: "projects", title: "项目", href: "/projects" }],
todayPresence: null,
teamPresence: { date: "2026-03-22", href: "/team", office: 6, remote: 1, out: 0, travel: 1, leave: 1, unspecified: 1, total: 8 },
+1 -1
查看文件
@@ -28,7 +28,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await reimbursementDetail.assertLoaded();
// 2) employee: upload invoice and wait OCR
await (await openNavLink(page, "票据中心")).click();
await (await openNavLink(page, "票据")).click();
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const invoiceFileName = `e2e-${Date.now()}.pdf`;
const uploadResponsePromise = page.waitForResponse((response) => {
@@ -187,7 +187,7 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
});
await runStep("employee", "请假提交并生成经理待办", async () => {
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leave = await createAndSubmitLeaveForManager(page);
await page.goto("/leave-requests");
@@ -204,7 +204,7 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
await runStep("employee", "周报与待办入口", async () => {
await (await openNavLink(page, "周报")).click();
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
await (await openNavLink(page, "待办")).click();
await page.goto("/todos");
await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
});
@@ -245,18 +245,20 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
await runStep("admin", "管理入口与人员组织", async () => {
await logout(page, MANAGER.name);
await login(page, ADMIN);
await (await openNavLink(page, "人员管理")).click();
await (await openNavLink(page, /^管理$/)).click();
await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible();
await page.getByRole("link", { name: /人员/ }).first().click();
await expect(page.getByRole("heading", { name: "人员管理" })).toBeVisible();
await (await openNavLink(page, "组织架构")).click();
await page.goto("/organization");
await expect(page.getByRole("heading", { name: "组织架构" })).toBeVisible();
});
await runStep("admin", "系统设置、功能开关、审计日志", async () => {
await (await openNavLink(page, "系统设置")).click();
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
await (await openNavLink(page, "功能开关")).click();
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
await (await openNavLink(page, "审计日志")).click();
await runStep("admin", "管理、功能管理、审计日志", async () => {
await (await openNavLink(page, /^管理$/)).click();
await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible();
await page.getByRole("link", { name: /功能管理/ }).first().click();
await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible();
await page.goto("/audit-logs");
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
});
} finally {
@@ -173,7 +173,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
await submitResponse;
await expect(page.getByText("周报已提交")).toBeVisible();
await (await openNavLink(page, "待办")).click();
await page.goto("/todos");
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
@@ -778,7 +778,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const startDate = futureWeekday(uniqueFutureOffset(5200));
const employee = await loginRole(page, "employee");
try {
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveId = await createLeaveDraft(page, startDate, `${trialrunMarkerPrefix} 审批边界 ${marker}`);
@@ -952,7 +952,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const startDate = futureWeekday(uniqueFutureOffset(3200));
const employee = await loginRole(page, "employee");
try {
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveId = await createLeaveDraft(page, startDate, `${trialrunMarkerPrefix} 浏览器请假 ${marker}`);
@@ -178,7 +178,7 @@ async function mockWave2Apis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
],
admin: [],
@@ -206,7 +206,7 @@ async function mockWave2Apis(page: Page) {
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
greeting: { name: visualMe.name },
cards: [
{ key: "pending_approvals", title: "审批待办", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "pending_approvals", title: "待我处理", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "团队状态", value: "7/9 在岗", href: "/team", severity: "normal" },
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
@@ -583,7 +583,7 @@ async function mockWave3Apis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("notifications", "/notifications", "通知", "notifications"),
],
@@ -1120,7 +1120,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
await page.goto("/mobile/approvals");
await expect(page.getByRole("heading", { name: "审批", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
@@ -515,22 +515,12 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("payment-batches", "/payment-batches", "付款批次", "payments"),
navigationItem("ap-operations", "/ap-operations", "AP 运营", "account_balance_wallet"),
navigationItem("invoice-pool", "/invoice-pool", "票据中心", "receipt_long"),
navigationItem("payment-batches", "/payment-batches", "付款", "payments"),
navigationItem("ap-operations", "/ap-operations", "应付账款", "account_balance_wallet"),
navigationItem("invoice-pool", "/invoice-pool", "票据", "receipt_long"),
],
admin: [
navigationItem("people", "/people", "人员管理", "badge", "admin"),
navigationItem("organization", "/organization", "组织架构", "account_tree", "admin"),
navigationItem("workspace", "/system/workspace", "工作区设置", "dashboard_customize", "admin"),
navigationItem("users", "/system/users", "用户管理", "group", "admin"),
navigationItem("master-data", "/system/master-data", "主数据", "database", "admin"),
navigationItem("notification-channels", "/system/notification-channels", "通知渠道", "notifications", "admin"),
navigationItem("approval-rules", "/system/approval-rules", "审批规则", "account_tree", "admin"),
navigationItem("settings", "/system/settings", "系统设置", "settings", "admin"),
navigationItem("feature-flags", "/system/feature-flags", "功能开关", "toggle_on", "admin"),
navigationItem("workflow-definitions", "/system/workflow-definitions", "流程定义", "schema", "admin"),
navigationItem("audit-logs", "/audit-logs", "审计日志", "manage_search", "admin"),
navigationItem("settings", "/system/settings", "管理", "settings", "admin"),
],
backup: [],
},
@@ -1020,7 +1010,7 @@ const pages = [
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "08-users" },
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=法体列表", screenshot: "09-master-data" },
{ path: "/system/notification-channels", summary: "notification-channels-summary", proof: "data-testid=notification-channels-table", screenshot: "10-notification-channels" },
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=系统设置", screenshot: "11-system-settings" },
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=管理", screenshot: "11-system-settings" },
{ path: "/system/feature-flags", summary: "feature-flags-summary", proof: "data-testid=feature-flags-table", screenshot: "12-feature-flags" },
{ path: "/system/workflow-definitions", summary: "workflow-definitions-summary", proof: "text=版本列表", screenshot: "13-workflow-definitions" },
{ path: "/audit-logs", summary: "audit-summary", proof: "text=PAYMENT_BATCH_REVIEW", screenshot: "14-audit-logs" },
@@ -244,7 +244,7 @@ async function expectMobileApprovalEntry(browser: Browser, user: E2EUser, canApp
const approvalTab = page.getByTestId("mobile-nav-approvals");
if (canApprove) {
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
await expect(page.getByTestId("mobile-nav-approvals-label")).toHaveText("待办");
await expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
await page.goto("/mobile/approvals");
await expect(page).not.toHaveURL(/\/403/);
@@ -97,7 +97,7 @@ async function mockDesignApis(page: Page) {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
@@ -165,7 +165,7 @@ async function mockMobileApis(page: Page) {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
@@ -174,7 +174,7 @@ async function mockApis(page: Page) {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
],
admin: [],
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
@@ -224,11 +224,11 @@ async function mockApis(page: Page) {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"),
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
backup: [
navigationItem("payment", "/payment-batches", "付款批次", "payments"),
navigationItem("payment", "/payment-batches", "付款", "payments"),
navigationItem("dashboard", "/dashboard", "数据看板", "monitoring"),
],
},
@@ -524,7 +524,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
await mobile.page.getByTestId("mobile-nav-approvals").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
await expect(mobile.page.getByText("审批").first()).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
await expect(mobile.page.locator(".mobile-status-slot--approval").first()).toContainText("待处理");
await expectMobilePageContract(mobile.page);
@@ -538,7 +538,7 @@ test.describe("R6 移动响应式体系", () => {
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(2);
const bottomTabTexts = new Set(["首页", "审批", "进度", "报销", "我的"]);
const bottomTabTexts = new Set(["首页", "待办", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(bottomTabTexts.has(action.text), `${device.label} ${action.text} not duplicate bottom tab`).toBe(false);
expect(action.width, `${device.label} ${action.text} action width`).toBeGreaterThanOrEqual(44);
@@ -527,7 +527,7 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
const bottomTabLabels = new Set(["首页", "审批", "进度", "报销", "我的"]);
const bottomTabLabels = new Set(["首页", "待办", "进度", "报销", "我的"]);
for (const action of homeMetrics.actionItems) {
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false);
@@ -387,7 +387,7 @@ test.describe("R75 自动化补盲", () => {
await login(page, ADMIN);
await page.goto("/system/feature-flags");
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible();
const row = page.getByTestId(`feature-flag-row-${flagKey}`);
await expect(row).toBeVisible();
@@ -210,12 +210,12 @@ async function mockR89Apis(page: Page) {
},
sections: {
main: [
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("team", "/team", "团队", "groups"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
@@ -96,10 +96,10 @@ async function mockAuthenticatedShell(page: Page) {
},
sections: {
main: [
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "mobile", href: "/mobile", label: "移动工作台", icon: "phone_iphone", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "我的报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "invoice", href: "/invoice-pool", label: "票据中心", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "invoice", href: "/invoice-pool", label: "票据", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null },
],
admin: [],
backup: [],
+11 -11
查看文件
@@ -2,25 +2,25 @@ import { test, expect } from "./helpers/fixtures";
import { ADMIN, login } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page, shell }) => {
test("E2E 管理域 smoke:工作区设置、管理、功能管理、流程定义、审计日志", async ({ page, shell }) => {
await login(page, ADMIN);
await page.goto("/portal");
await expect(shell.topbarProfileTrigger).toBeVisible();
await shell.assertBackupModulesCollapsed();
await (await shell.navigate("工作区设置")).click();
await (await shell.navigate(/^管理$/)).click();
await expect(page).toHaveURL(/\/system\/settings/);
await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: /工作区/ }).first()).toBeVisible();
await page.goto("/system/workspace");
await expect(page).toHaveURL(/\/system\/workspace/);
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
await expect(page.getByText("产品模式")).toBeVisible();
await expect(page.getByText("工作区基线")).toBeVisible();
await (await shell.navigate("系统设置")).click();
await expect(page).toHaveURL(/\/system\/settings/);
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
await (await shell.navigate("功能开关")).click();
await page.goto("/system/feature-flags");
await expect(page).toHaveURL(/\/system\/feature-flags/);
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible();
await expect(page.getByTestId("feature-flags-table")).toBeVisible();
await page.goto("/system/workflow-definitions");
@@ -28,7 +28,7 @@ test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
await expect(page.getByText("版本列表")).toBeVisible();
await (await shell.navigate("审计日志")).click();
await page.goto("/audit-logs");
await expect(page).toHaveURL(/\/audit-logs/);
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
@@ -209,7 +209,7 @@ async function mockSystemActionApis(page: Page) {
items: [
navigationItem("portal", "/portal", "首页", "home"),
navigationItem("reimbursements", "/reimbursements", "报销", "receipt"),
navigationItem("payment-batches", "/payment-batches", "付款批次", "credit_card"),
navigationItem("payment-batches", "/payment-batches", "付款", "credit_card"),
],
});
return;
+2 -2
查看文件
@@ -107,8 +107,8 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
updatedBy: null,
},
sections: {
main: [{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }],
admin: [{ key: "users", href: "/system/users", label: "系统用户", icon: "manage_accounts", placement: "admin", permissionCodes: ["system.users"], featureFlagKey: null }],
main: [{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }],
admin: [{ key: "settings", href: "/system/settings", label: "管理", icon: "settings", placement: "admin", permissionCodes: ["system.users"], featureFlagKey: null }],
backup: [],
},
recommendedRolePresets: [],
+6 -7
查看文件
@@ -116,13 +116,12 @@ async function mockVisualApis(page: Page) {
},
sections: {
main: [
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
navigationItem("approvals", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"),
],
admin: [],
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
},
recommendedRolePresets: [],
});
@@ -323,7 +322,7 @@ test.describe("visual shell", () => {
await expect(page.locator("aside")).toBeVisible();
await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "Portal", exact: true })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "工作台", exact: true })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多" })).toBeVisible();
await attachScreenshot(page, testInfo, "app-shell.png");
@@ -364,7 +363,7 @@ test.describe("visual shell", () => {
await attachScreenshot(page, testInfo, "mobile-today.png");
});
test("审批待办手机卡片布局基线", async ({ page }, testInfo) => {
test("待我处理手机卡片布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 390, height: 844 });
await loginVisualUser(page);
@@ -372,7 +371,7 @@ test.describe("visual shell", () => {
await expect(page.getByTestId("workflow-tasks-mobile-list")).toBeVisible();
await expect(page.getByTestId("workflow-tasks-table")).toBeHidden();
await expect(page.getByRole("link", { name: "详情" })).toBeVisible();
await expect(page.getByRole("link", { name: "查看" })).toBeVisible();
await attachScreenshot(page, testInfo, "r97-workflow-tasks-mobile.png");
});
+1 -1
查看文件
@@ -123,7 +123,7 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
updatedBy: null,
},
sections: {
main: [{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }],
main: [{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }],
admin: [],
backup: [],
},
+1 -2
查看文件
@@ -1,7 +1,6 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
function formatWeekStart(offsetDays: number) {
@@ -53,7 +52,7 @@ test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待
await expect(page.getByText("周报已提交")).toBeVisible();
await expect(page.getByText("已提交", { exact: true })).toBeVisible();
await (await openNavLink(page, "待办")).click();
await page.goto("/todos");
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
@@ -187,8 +187,8 @@ async function mockWorkflowConfirmApis(page: Page) {
},
sections: {
main: [
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
],
admin: [],
backup: [],
@@ -73,7 +73,7 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假")).click();
await (await openNavLink(page, "请假记录")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
+6
查看文件
@@ -50,6 +50,12 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
- 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。
- 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。
- 个人设置默认只放账号信息、通知设置和企业微信绑定;组织归属、直属汇报和角色摘要可折叠展示,不默认暴露权限 code 或完整员工档案维护内容。
- PC 侧边栏是小团队任务入口,不是全量模块目录;一级入口必须按角色、权限、频次和真实待办价值收敛,新增功能默认不得直接进入一级侧边栏。
- 审批人、负责人和财务看到 `待我处理`;普通员工默认不显示 `待办` 或 `审批待办` 入口;通知只保留顶部铃铛或通知中心,不进入侧边栏。
- PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。
- 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和外部系统入口进入管理页后分组承接。
- 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`ERP 集成`、`功能开关` 等内部平台词不作为默认一级菜单文案。
- `更多` 只承载低频入口,不使用说明型副标题、教学卡片或历史模块解释文案。
## 业务体验事实
+128 -24
查看文件
@@ -14,28 +14,81 @@ const BUSINESS_APPROVE_PERMISSIONS = Object.freeze([
]);
const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]);
const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]);
const TEAM_NAVIGATION_ROLE_CODES = new Set(["manager", "ceo", "finance_mgr", "hr", "admin"]);
const FINANCE_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "invoicePool"]);
const MANAGEMENT_CHILD_MODULES = new Set([
"workspace",
"people",
"organization",
"workflowDefinitions",
"featureFlags",
"auditLogs",
"approvalRules",
"userManagement",
"departments",
"masterData",
"notificationChannels",
"cutover",
"erpIntegrations",
]);
const BACKUP_NAVIGATION_MODULES = new Set([
"assets",
"weeklyReports",
"announcements",
"contractsRegistry",
"purchaseRequests",
"travelRequests",
"budgets",
"inventoryChecks",
"dashboard",
]);
const NAVIGATION_SECTION_ORDER = Object.freeze({
main: [
"portal",
"workflowTasks",
"reimbursements",
"paymentBatches",
"apOperations",
"invoicePool",
"projects",
"team",
"leaveRequests",
],
admin: ["systemSettings"],
backup: [
"assets",
"weeklyReports",
"announcements",
"purchaseRequests",
"contractsRegistry",
"budgets",
"attendance",
"travelRequests",
"inventoryChecks",
"dashboard",
],
});
const NAVIGATION_REGISTRY = Object.freeze({
portal: { key: "portal", label: "工作台", icon: "home_app_logo", href: "/portal", anyOf: [] },
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
leaveRequests: { key: "leaveRequests", label: "请假", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: ["report.dashboard.view", "project.view_all", "project.manage", "system.users"], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
leaveRequests: { key: "leaveRequests", label: "请假记录", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
attendance: { key: "attendance", label: "考勤", icon: "schedule", href: "/attendance", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.attendanceUnified },
reimbursements: { key: "reimbursements", label: "报销", icon: "request_quote", href: "/reimbursements", anyOf: ["expense.view_own", "expense.view_all"] },
assets: { key: "assets", label: "资产", icon: "inventory_2", href: "/assets", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
todos: { key: "todos", label: "待办", icon: "check_circle", href: "/todos", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.todoBoard },
announcements: { key: "announcements", label: "公告", icon: "campaign", href: "/announcements", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.announcements },
projects: { key: "projects", label: "项目", icon: "workspaces", href: "/projects", anyOf: ["project.view_own", "project.view_all", "project.manage"], featureFlagKey: FEATURE_FLAG_KEYS.projectDomain },
projects: { key: "projects", label: "项目", icon: "workspaces", href: "/projects", anyOf: ["project.view_all", "project.manage"], featureFlagKey: FEATURE_FLAG_KEYS.projectDomain },
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
featureFlags: { key: "featureFlags", label: "功能开关", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
featureFlags: { key: "featureFlags", label: "功能管理", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
approvalRules: { key: "approvalRules", label: "审批规则", icon: "rule", href: "/system/approval-rules", anyOf: ["system.settings"] },
@@ -43,17 +96,17 @@ const NAVIGATION_REGISTRY = Object.freeze({
departments: { key: "departments", label: "部门管理", icon: "account_tree", href: "/system/departments", anyOf: ["system.users"] },
masterData: { key: "masterData", label: "主数据", icon: "dataset", href: "/system/master-data", anyOf: ["masterdata.view", "masterdata.manage"] },
notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] },
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
workflowTasks: { key: "workflowTasks", label: "待我处理", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
paymentBatches: { key: "paymentBatches", label: "付款批次", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
apOperations: { key: "apOperations", label: "AP 运营", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
invoicePool: { key: "invoicePool", label: "票据中心", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] },
paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
apOperations: { key: "apOperations", label: "应付账款", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] },
inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] },
cutover: { key: "cutover", label: "切换控制台", icon: "swap_calls", href: "/system/cutover", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.cutoverConsole },
erpIntegrations: { key: "erpIntegrations", label: "ERP 集成", icon: "hub", href: "/system/integrations/erp", anyOf: ["integration.manage"] },
erpIntegrations: { key: "erpIntegrations", label: "外部系统", icon: "hub", href: "/system/integrations/erp", anyOf: ["integration.manage"] },
profile: { key: "profile", label: "个人设置", icon: "manage_accounts", href: "/settings/profile", anyOf: [] },
});
@@ -63,10 +116,10 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
leaveRequests: "main",
attendance: "hidden",
reimbursements: "main",
assets: "main",
weeklyReports: "main",
todos: "main",
announcements: "main",
assets: "admin",
weeklyReports: "admin",
todos: "hidden",
announcements: "admin",
projects: "main",
workspace: "admin",
@@ -198,11 +251,53 @@ function hasBusinessApproverRole(hasPermission, employee) {
function hasFinanceRole(hasPermission, employee) {
const roleCodes = employee?.roleCodes ?? [];
return (
roleCodes.some((roleCode) => FINANCE_ROLE_CODES.has(roleCode)) &&
(roleCodes.some((roleCode) => FINANCE_ROLE_CODES.has(roleCode)) || roleCodes.includes("admin")) &&
hasAnyPermission(hasPermission, employee, FINANCE_PERMISSION_CODES)
);
}
function hasTeamNavigationRole(employee) {
const roleCodes = employee?.roleCodes ?? [];
return roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.has(roleCode));
}
function resolveNavigationSection({ moduleKey, placement, financeNavigation }) {
if (placement === "hidden") {
return null;
}
if (moduleKey === "systemSettings") {
return "admin";
}
if (MANAGEMENT_CHILD_MODULES.has(moduleKey)) {
return null;
}
if (moduleKey === "workflowTasks") {
return "main";
}
if (FINANCE_NAVIGATION_MODULES.has(moduleKey)) {
return financeNavigation ? "main" : null;
}
if (BACKUP_NAVIGATION_MODULES.has(moduleKey)) {
return "backup";
}
return placement === "main" ? "main" : "backup";
}
function orderNavigationItems(items, order) {
const rank = new Map(order.map((moduleKey, index) => [moduleKey, index]));
return [...items].sort((left, right) => {
const leftRank = rank.get(left.key) ?? Number.MAX_SAFE_INTEGER;
const rightRank = rank.get(right.key) ?? Number.MAX_SAFE_INTEGER;
return leftRank - rightRank;
});
}
async function resolveModuleGate({ prisma, config, employee, moduleKey, registryItem }) {
if (!registryItem?.featureFlagKey) {
return { enabled: true, reason: "no_feature_gate" };
@@ -235,6 +330,10 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
continue;
}
if (moduleKey === "team" && !hasTeamNavigationRole(employee)) {
continue;
}
if (FINANCE_NAVIGATION_MODULES.has(moduleKey) && !hasFinanceRole(hasPermission, employee)) {
continue;
}
@@ -254,25 +353,30 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
featureFlagKey: registryItem.featureFlagKey ?? null,
};
if (["purchaseRequests", "travelRequests", "budgets", "paymentBatches", "apOperations", "invoicePool", "inventoryChecks", "cutover", "erpIntegrations", "approvalRules", "userManagement", "departments", "masterData", "notificationChannels", "workflowTasks", "dashboard"].includes(moduleKey)) {
sections.backup.push(entry);
const section = resolveNavigationSection({
moduleKey,
placement,
financeNavigation: hasFinanceRole(hasPermission, employee),
});
if (!section) {
continue;
}
if (placement === "admin") {
sections.admin.push(entry);
} else {
sections.main.push(entry);
}
sections[section].push(entry);
}
return {
workspace,
sections,
sections: {
main: orderNavigationItems(sections.main, NAVIGATION_SECTION_ORDER.main),
admin: orderNavigationItems(sections.admin, NAVIGATION_SECTION_ORDER.admin),
backup: orderNavigationItems(sections.backup, NAVIGATION_SECTION_ORDER.backup),
},
recommendedRolePresets: [
{ key: "owner", label: "老板", roleCodes: ["ceo"], description: "查看团队状态、审批汇总与关键管理入口。" },
{ key: "admin", label: "管理员", roleCodes: ["admin"], description: "管理工作区、人员、流程、开关与审计。" },
{ key: "employee", label: "员工", roleCodes: ["employee"], description: "日常使用工作台、请假、报销、周报、待办。" },
{ key: "employee", label: "员工", roleCodes: ["employee"], description: "日常使用工作台、请假记录、报销和进度查询。" },
{ key: "manager", label: "主管", roleCodes: ["manager"], description: "处理审批、查看团队周报与在岗状态。" },
],
};
@@ -6,13 +6,31 @@ const {
} = require("../../src/services/workspaceSettings");
const { hasPermission, resolvePermissionsForRoles } = require("../../src/constants/permissions");
function buildPrisma() {
function buildPrisma(enabledFlagKeys = []) {
const enabled = new Set(enabledFlagKeys);
return {
workspaceSetting: {
findFirst: vi.fn().mockResolvedValue(null),
},
featureFlag: {
findUnique: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockImplementation(async ({ where }) => {
if (!enabled.has(where.flag_key)) {
return null;
}
return {
id: `flag-${where.flag_key}`,
flag_key: where.flag_key,
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: null,
created_by: null,
updated_at: null,
updated_by: null,
};
}),
},
};
}
@@ -37,7 +55,7 @@ function buildConfig() {
}
describe("workspace small-team navigation defaults", () => {
it("keeps only the R66 daily path in default main navigation", () => {
it("keeps only the small-team task path in default main navigation", () => {
const mainModules = Object.entries(DEFAULT_MODULE_VISIBILITY)
.filter(([, placement]) => placement === "main")
.map(([key]) => key);
@@ -47,12 +65,10 @@ describe("workspace small-team navigation defaults", () => {
"team",
"leaveRequests",
"reimbursements",
"assets",
"weeklyReports",
"todos",
"announcements",
"projects",
]);
expect(DEFAULT_MODULE_VISIBILITY.todos).toBe("hidden");
expect(DEFAULT_MODULE_VISIBILITY.assets).toBe("admin");
expect(DEFAULT_MODULE_VISIBILITY.attendance).toBe("hidden");
});
@@ -77,7 +93,7 @@ describe("workspace small-team navigation defaults", () => {
expect(workspace.moduleVisibility.attendance).toBe("hidden");
});
it("keeps P2 and finance-only backup modules out of employee navigation", async () => {
it("keeps low-frequency and finance-only modules out of employee primary navigation", async () => {
const employee = {
id: "employee-1",
department_id: "dept-1",
@@ -86,12 +102,18 @@ describe("workspace small-team navigation defaults", () => {
};
const navigation = await getSystemNavigation({
prisma: buildPrisma(),
prisma: buildPrisma(["presence_board_v1", "project_domain_v1"]),
config: buildConfig(),
employee,
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
});
const mainLabels = navigation.sections.main.map((item) => item.label);
expect(mainLabels).toEqual(["工作台", "报销", "请假记录"]);
expect(mainLabels).not.toContain("待我处理");
expect(mainLabels).not.toContain("票据");
expect(mainLabels).not.toContain("个人设置");
const backupKeys = navigation.sections.backup.map((item) => item.key);
expect(backupKeys).toContain("purchaseRequests");
expect(backupKeys).not.toContain("travelRequests");
@@ -104,7 +126,30 @@ describe("workspace small-team navigation defaults", () => {
expect(backupKeys).not.toContain("dashboard");
});
it("does not expose workflow task entry to admin-only navigation", async () => {
it("shows待我处理 for business approvers and keeps admin-only users out", async () => {
const manager = {
id: "manager-1",
department_id: "dept-1",
roleCodes: ["manager"],
permissionCodes: resolvePermissionsForRoles(["manager"]),
};
const managerNavigation = await getSystemNavigation({
prisma: buildPrisma(["presence_board_v1", "project_domain_v1"]),
config: { ...buildConfig(), projectDomainEnabled: true, presenceBoardEnabled: true },
employee: manager,
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
});
expect(managerNavigation.sections.main.map((item) => item.label)).toEqual([
"工作台",
"待我处理",
"报销",
"项目",
"团队",
"请假记录",
]);
const adminOnly = {
id: "admin-1",
department_id: "dept-1",
@@ -113,7 +158,7 @@ describe("workspace small-team navigation defaults", () => {
};
const navigation = await getSystemNavigation({
prisma: buildPrisma(),
prisma: buildPrisma(["payment_batch_v1"]),
config: { ...buildConfig(), workflowDefinitionAdminEnabled: true },
employee: adminOnly,
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
@@ -125,7 +170,32 @@ describe("workspace small-team navigation defaults", () => {
...navigation.sections.backup,
].map((item) => item.key);
expect(allKeys).toContain("auditLogs");
expect(allKeys).toContain("systemSettings");
expect(allKeys).not.toContain("auditLogs");
expect(allKeys).not.toContain("workflowTasks");
});
it("uses work names for finance navigation", async () => {
const finance = {
id: "finance-1",
department_id: "dept-1",
roleCodes: ["finance"],
permissionCodes: resolvePermissionsForRoles(["finance"]),
};
const navigation = await getSystemNavigation({
prisma: buildPrisma(["payment_batch_v1"]),
config: { ...buildConfig(), paymentBatchEnabled: true },
employee: finance,
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
});
const mainLabels = navigation.sections.main.map((item) => item.label);
expect(mainLabels).toContain("待我处理");
expect(mainLabels).toContain("付款");
expect(mainLabels).toContain("应付账款");
expect(mainLabels).toContain("票据");
expect(mainLabels).not.toContain("AP 运营");
expect(mainLabels).not.toContain("付款批次");
});
});