refactor: 菜单栏角色化降噪(role-based-navigation-simplification)
这个提交包含在:
@@ -167,8 +167,9 @@ header baseline instead of recreating page-local wrappers:
|
|||||||
|
|
||||||
- `AppShellLayout` owns the PC shell background and content offset through
|
- `AppShellLayout` owns the PC shell background and content offset through
|
||||||
`app-shell` and `app-shell-main`.
|
`app-shell` and `app-shell-main`.
|
||||||
- `Sidebar` and `NavLinks` own primary navigation grouping. The mental model is
|
- `Sidebar` and `NavLinks` own role-aware task navigation. The mental model is
|
||||||
`工作` -> `业务` -> `财务` -> `管理` -> collapsed `更多`.
|
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,
|
- `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.
|
global search and notifications. Do not expand it back into a large hero.
|
||||||
- `PageHeader` and `WorkspaceHeader` should use `page-header-shell`,
|
- `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
|
**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.
|
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**:
|
**Example**:
|
||||||
|
|
||||||
```tsx
|
```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": {}
|
||||||
|
}
|
||||||
@@ -207,7 +207,7 @@ export function hasBusinessApproverRole(roleCodes: string[] | undefined, permiss
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasFinanceRole(roleCodes: string[] | undefined, permissionCodes: string[]) {
|
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]);
|
return hasRole && hasAnyPermission(permissionCodes, [...FINANCE_PORTAL_PERMISSIONS]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavLink } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
@@ -19,67 +19,53 @@ export type NavItem = {
|
|||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: "/portal", label: "工作台", icon: "home_app_logo" },
|
{ to: "/portal", label: "工作台", icon: "home_app_logo" },
|
||||||
{ to: "/team", label: "团队", icon: "groups" },
|
{ 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: "/leave-requests", label: "请假记录", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
|
||||||
{ to: "/attendance", label: "考勤", icon: "schedule" },
|
{ to: "/attendance", label: "考勤", icon: "schedule" },
|
||||||
{ to: "/reimbursements", label: "报销", icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"] },
|
{ 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: "/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: "/weekly-reports", label: "周报", icon: "article" },
|
||||||
{ to: "/todos", label: "待办", icon: "check_circle" },
|
|
||||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_all", "project.manage"] },
|
||||||
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
|
||||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||||
{ 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: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
{ 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: "/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: "/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: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
|
||||||
{ to: "/payment-batches", label: "付款批次", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.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: "/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: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
|
||||||
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
|
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
|
||||||
{ to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] },
|
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
|
||||||
{ 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" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
|
const ACCESSIBLE_LABELS: Record<string, string> = {
|
||||||
"/reimbursements": "我的报销",
|
"/reimbursements": "我的报销",
|
||||||
"/workflow/tasks": "待我处理",
|
"/workflow/tasks": "待我处理",
|
||||||
"/assets": "资产台账",
|
"/assets": "资产台账",
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORK_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/todos"];
|
const MANAGEMENT_ACTIVE_PATHS = [
|
||||||
const BUSINESS_NAV_ORDER = [
|
"/system/settings",
|
||||||
"/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",
|
|
||||||
"/system/workspace",
|
"/system/workspace",
|
||||||
"/system/workflow-definitions",
|
"/system/workflow-definitions",
|
||||||
"/system/settings",
|
|
||||||
"/system/feature-flags",
|
"/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",
|
"/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 = {
|
type ResolvedNavItem = {
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -88,8 +74,16 @@ type ResolvedNavItem = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
function isGroupedActive(item: ResolvedNavItem, pathname: string) {
|
||||||
const accessibleLabel = LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label;
|
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 (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -99,68 +93,65 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
|||||||
title={accessibleLabel}
|
title={accessibleLabel}
|
||||||
onClick={() => onNavigate?.()}
|
onClick={() => onNavigate?.()}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle")
|
||||||
"group nav-link-item",
|
|
||||||
isActive ? "nav-link-item--active" : "nav-link-item--idle",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => {
|
||||||
<>
|
const active = isActive || isGroupedActive(item, pathname);
|
||||||
<span
|
return (
|
||||||
className={cn(
|
<>
|
||||||
"nav-link-item__icon",
|
<span className={cn("nav-link-item__icon", active ? "nav-link-item__icon--active" : "nav-link-item__icon--idle")}>
|
||||||
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>
|
||||||
<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>
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
|
function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="nav-section">
|
<section className="nav-section">
|
||||||
<div className="nav-section__label">{title}</div>
|
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, pathname, onNavigate))}</div>
|
||||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void) {
|
function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="rounded-2xl border border-slate-200 bg-slate-50/80 px-3 py-3">
|
<details className="nav-more-section">
|
||||||
<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)]">
|
<summary className="nav-more-section__summary">
|
||||||
<div>
|
<span className="nav-link-item__icon nav-link-item__icon--idle">
|
||||||
<div>更多</div>
|
<span className="material-symbols-outlined text-[20px]">more_horiz</span>
|
||||||
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">低频入口和历史模块</div>
|
</span>
|
||||||
</div>
|
<span className="min-w-0 flex-1 truncate font-medium">更多</span>
|
||||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
|
<span className="material-symbols-outlined nav-more-section__chevron">expand_more</span>
|
||||||
</summary>
|
</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>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
|
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
|
||||||
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
|
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
|
||||||
|
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
|
||||||
const mapItems = (paths: string[]) =>
|
const mapItems = (paths: string[]) =>
|
||||||
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
||||||
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
|
.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) => !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 }));
|
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,77 +161,43 @@ function buildFallbackItems(permissions: string[], roleCodes: string[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
|
function orderItems(items: ResolvedNavItem[], order: string[]) {
|
||||||
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
|
|
||||||
const byHref = new Map<string, ResolvedNavItem>();
|
const byHref = new Map<string, ResolvedNavItem>();
|
||||||
|
for (const item of items) {
|
||||||
for (const item of allItems) {
|
|
||||||
if (!byHref.has(item.href)) {
|
if (!byHref.has(item.href)) {
|
||||||
byHref.set(item.href, item);
|
byHref.set(item.href, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
const take = (hrefs: string[]) =>
|
const ordered = order
|
||||||
hrefs
|
.map((href) => {
|
||||||
.map((href) => {
|
const item = byHref.get(href);
|
||||||
const item = byHref.get(href);
|
if (item) {
|
||||||
if (item) {
|
used.add(href);
|
||||||
used.add(href);
|
}
|
||||||
}
|
return item;
|
||||||
return item;
|
})
|
||||||
})
|
.filter((item): item is ResolvedNavItem => Boolean(item));
|
||||||
.filter((item): item is ResolvedNavItem => Boolean(item));
|
|
||||||
|
|
||||||
return {
|
return [...ordered, ...items.filter((item) => !used.has(item.href))];
|
||||||
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 })),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function regroupSections(sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }) {
|
function toResolved(items: NavigationItem[]) {
|
||||||
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
|
return items.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon }));
|
||||||
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 normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
|
||||||
return {
|
return {
|
||||||
work: take(WORK_NAV_ORDER),
|
main: orderItems(toResolved(sections.main), MAIN_NAV_ORDER),
|
||||||
business: take(BUSINESS_NAV_ORDER),
|
admin: orderItems(toResolved(sections.admin), ADMIN_NAV_ORDER),
|
||||||
finance: take(FINANCE_NAV_ORDER),
|
backup: orderItems(toResolved(sections.backup), BACKUP_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 })),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { me, permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
const navigationQuery = useQuery({
|
const navigationQuery = useQuery({
|
||||||
queryKey: ["system-navigation"],
|
queryKey: ["system-navigation"],
|
||||||
queryFn: fetchSystemNavigation,
|
queryFn: fetchSystemNavigation,
|
||||||
@@ -250,15 +207,12 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
const sections = navigationQuery.data?.sections
|
const sections = navigationQuery.data?.sections
|
||||||
? normalizeSections(navigationQuery.data.sections)
|
? normalizeSections(navigationQuery.data.sections)
|
||||||
: buildFallbackItems(permissions, me?.roleCodes ?? []);
|
: buildFallbackItems(permissions, me?.roleCodes ?? []);
|
||||||
const groupedSections = regroupSections(sections);
|
const primaryItems = [...sections.main, ...sections.admin];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="space-y-5">
|
<nav className="space-y-3.5">
|
||||||
{renderSection("工作", groupedSections.work, onNavigate)}
|
{renderPrimarySection(primaryItems, location.pathname, onNavigate)}
|
||||||
{renderSection("业务", groupedSections.business, onNavigate)}
|
{renderBackupSection(sections.backup, location.pathname, onNavigate)}
|
||||||
{renderSection("财务", groupedSections.finance, onNavigate)}
|
|
||||||
{renderSection("管理", groupedSections.management, onNavigate)}
|
|
||||||
{renderBackupSection(groupedSections.more, onNavigate)}
|
|
||||||
</nav>
|
</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">
|
<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">
|
<div className="border-b border-slate-200 px-6 py-5">
|
||||||
<AppBrand />
|
<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>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-width:thin]">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-width:thin]">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export type MobileNavItem = {
|
|||||||
|
|
||||||
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
|
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
|
||||||
{ key: "home", to: "/mobile", label: "首页", icon: "home" },
|
{ 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: "reimbursements", to: "/mobile/reimbursements", label: "报销", icon: "request_quote", anyOf: MOBILE_REIMBURSEMENT_PERMISSIONS },
|
||||||
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
|
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
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/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
export const ADMIN_NAV_ORDER = ["/system/settings"];
|
||||||
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 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 }> = [
|
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||||
{ path: "/portal", title: "工作台", group: "工作台" },
|
{ path: "/portal", title: "工作台", group: "工作台" },
|
||||||
@@ -13,18 +13,18 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
|||||||
{ path: "/purchase-requests", title: "采购申请", group: "业务" },
|
{ path: "/purchase-requests", title: "采购申请", group: "业务" },
|
||||||
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
|
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
|
||||||
{ path: "/budgets", title: "预算管理", group: "财务" },
|
{ path: "/budgets", title: "预算管理", group: "财务" },
|
||||||
{ path: "/payment-batches", title: "付款批次", group: "财务" },
|
{ path: "/payment-batches", title: "付款", group: "财务" },
|
||||||
{ path: "/ap-operations", title: "应付作业", group: "财务" },
|
{ path: "/ap-operations", title: "应付账款", group: "财务" },
|
||||||
{ path: "/invoice-pool", title: "票据中心", group: "财务" },
|
{ path: "/invoice-pool", title: "票据", group: "财务" },
|
||||||
{ path: "/inventory-checks", title: "盘点任务", group: "财务" },
|
{ path: "/inventory-checks", title: "盘点任务", group: "财务" },
|
||||||
{ path: "/weekly-reports", title: "周报", group: "业务" },
|
{ path: "/weekly-reports", title: "周报", group: "业务" },
|
||||||
{ path: "/todos", title: "待办", group: "工作" },
|
{ path: "/todos", title: "待办", group: "工作" },
|
||||||
{ path: "/reimbursements/", title: "报销详情", group: "工作" },
|
{ path: "/reimbursements/", 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: "/assets", title: "资产", group: "业务" },
|
||||||
{ path: "/workflow/tasks/", 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/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||||
{ path: "/mobile/wecom-approval-archive", 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: "/organization", title: "组织架构", group: "管理" },
|
||||||
{ path: "/system/approval-rules", title: "审批规则", group: "管理" },
|
{ path: "/system/approval-rules", title: "审批规则", group: "管理" },
|
||||||
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理" },
|
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理" },
|
||||||
{ path: "/system/settings", title: "系统设置", group: "管理" },
|
{ path: "/system/settings", title: "管理", group: "管理" },
|
||||||
{ path: "/system/feature-flags", title: "功能开关", group: "管理" },
|
{ path: "/system/feature-flags", title: "功能管理", group: "管理" },
|
||||||
{ path: "/system/cutover", 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: "/dashboard", title: "数据看板", group: "更多" },
|
||||||
{ path: "/audit-logs", title: "审计日志", group: "管理" },
|
{ path: "/audit-logs", title: "审计日志", group: "管理" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -152,6 +152,28 @@
|
|||||||
color: var(--color-accent-primary);
|
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 {
|
.auth-shell {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 12%, rgba(0, 57, 138, 0.10), transparent 26rem),
|
radial-gradient(circle at 20% 12%, rgba(0, 57, 138, 0.10), transparent 26rem),
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function ErpIntegrationPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="ERP 集成"
|
title="外部系统"
|
||||||
subtitle="主数据同步、业务队列和 ID 映射。"
|
subtitle="主数据同步、业务队列和 ID 映射。"
|
||||||
actions={
|
actions={
|
||||||
tab === "master-data" ? (
|
tab === "master-data" ? (
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ function formatRolloutTypeLabel(value: FeatureFlagRolloutType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSectionLabel(value: FeatureFlagSection) {
|
function formatSectionLabel(value: FeatureFlagSection) {
|
||||||
if (value === "main") return "主导航";
|
if (value === "main") return "常用入口";
|
||||||
if (value === "admin") return "管理入口";
|
if (value === "admin") return "管理入口";
|
||||||
if (value === "backup") return "备用入口";
|
if (value === "backup") return "更多";
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ export function FeatureFlagsPage() {
|
|||||||
return (
|
return (
|
||||||
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
|
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="功能开关"
|
title="功能管理"
|
||||||
subtitle="外部桥接、验真、灰度和试运行边界。"
|
subtitle="外部桥接、验真、灰度和试运行边界。"
|
||||||
/>
|
/>
|
||||||
<WorkspaceNotice title="安全摘要" tone="warning">
|
<WorkspaceNotice title="安全摘要" tone="warning">
|
||||||
|
|||||||
@@ -1033,7 +1033,7 @@ export function MobileApprovalsPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={canAccessRemoteApprovals ? "审批" : "审批进度"}
|
title={canAccessRemoteApprovals ? "待办" : "进度"}
|
||||||
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
|
subtitle={canAccessRemoteApprovals ? "处理待办,查看已处理。" : "查看我发起的申请进度。"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function MobileMePage() {
|
|||||||
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
||||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||||
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
|
{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}
|
{canAccessFinance ? <MobileActionLink to="/mobile#finance" label="财务" description="审批与付款状态" icon="account_balance_wallet" /> : null}
|
||||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const ROLE_OPTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DOMAIN_SCOPE_OPTIONS = [
|
const DOMAIN_SCOPE_OPTIONS = [
|
||||||
{ value: "workflow", label: "审批待办" },
|
{ value: "workflow", label: "待我处理" },
|
||||||
{ value: "reimbursement", label: "报销" },
|
{ value: "reimbursement", label: "报销" },
|
||||||
{ value: "leave", label: "请假" },
|
{ value: "leave", label: "请假" },
|
||||||
{ value: "procurement", label: "采购" },
|
{ value: "procurement", label: "采购" },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
|
|||||||
|
|
||||||
const NOTIFICATION_TYPE_OPTIONS = [
|
const NOTIFICATION_TYPE_OPTIONS = [
|
||||||
{ label: "全部", value: "" },
|
{ label: "全部", value: "" },
|
||||||
{ label: "审批待办", value: "workflow.task_assigned" },
|
{ label: "待我处理", value: "workflow.task_assigned" },
|
||||||
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
||||||
{ label: "请假", value: "leave.approved,leave.rejected" },
|
{ label: "请假", value: "leave.approved,leave.rejected" },
|
||||||
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ function buildTodoAggregateItems(payload: PortalHomePayload) {
|
|||||||
const items: TodoAggregateItem[] = [];
|
const items: TodoAggregateItem[] = [];
|
||||||
if (payload.pendingTasks.count > 0) {
|
if (payload.pendingTasks.count > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
title: `审批待办 ${payload.pendingTasks.count} 条`,
|
title: `待我处理 ${payload.pendingTasks.count} 条`,
|
||||||
sourceLabel: "审批",
|
sourceLabel: "待办",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
href: payload.pendingTasks.href,
|
href: payload.pendingTasks.href,
|
||||||
icon: "fact_check",
|
icon: "fact_check",
|
||||||
@@ -925,9 +925,9 @@ export function PortalHomePage() {
|
|||||||
? {
|
? {
|
||||||
key: "approval-pending",
|
key: "approval-pending",
|
||||||
href: payload.pendingTasks.href,
|
href: payload.pendingTasks.href,
|
||||||
label: "审批待办",
|
label: "待我处理",
|
||||||
value: `${payload.pendingTasks.count} 条`,
|
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,
|
tone: payload.pendingTasks.count > 0 ? "warning" as const : "normal" as const,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@@ -1051,7 +1051,7 @@ export function PortalHomePage() {
|
|||||||
? { label: "报销进度", to: "/reimbursements" }
|
? { label: "报销进度", to: "/reimbursements" }
|
||||||
: null,
|
: null,
|
||||||
payload.pendingTasks.count > 0 && approvalAction?.to
|
payload.pendingTasks.count > 0 && approvalAction?.to
|
||||||
? { label: "审批中心", to: approvalAction.to }
|
? { label: "待我处理", to: approvalAction.to }
|
||||||
: null,
|
: null,
|
||||||
todoAggregateItems.length > 1
|
todoAggregateItems.length > 1
|
||||||
? { label: "全部待办", to: "/todos" }
|
? { label: "全部待办", to: "/todos" }
|
||||||
|
|||||||
@@ -1,41 +1,90 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasAnyPermission } from "../auth/permissions";
|
||||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||||
|
|
||||||
const ITEMS = [
|
type ManagementItem = {
|
||||||
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区设置", description: "导航与模块显隐。" },
|
to: string;
|
||||||
{ to: "/people", icon: "badge", title: "人员管理", description: "入转调离与档案。" },
|
icon: string;
|
||||||
{ to: "/system/approval-rules", icon: "account_tree", title: "审批规则", description: "审批链与规则。" },
|
title: string;
|
||||||
{ to: "/system/expense-standards", icon: "rule_settings", title: "费用标准", description: "费用上限与管控。" },
|
description: string;
|
||||||
{ to: "/system/reimbursement-templates", icon: "receipt_long", title: "报销模板", description: "常见报销模板。" },
|
group: "人员与组织" | "流程与规则" | "系统与审计" | "功能管理";
|
||||||
{ to: "/system/master-data", icon: "database", title: "主数据", description: "法体、成本中心、供应商。" },
|
anyOf: string[];
|
||||||
{ to: "/system/notification-channels", icon: "notifications", title: "通知渠道", description: "IM / 邮件补充通知。" },
|
};
|
||||||
{ to: "/system/integrations/erp", icon: "hub", title: "外部系统桥接", description: "财务出入口与桥接。" },
|
|
||||||
|
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() {
|
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 (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader title="系统设置" subtitle="后台能力入口按管理职责收拢。" />
|
<WorkspaceHeader title="管理" subtitle="低频配置集中到这里,侧边栏只保留一个管理入口。" />
|
||||||
<div className="finance-status-strip" data-testid="system-settings-summary">
|
<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"><div className="finance-metric-card__label">业务配置</div><div className="finance-metric-card__value">5</div></div>
|
<div className="finance-metric-card__label">可用入口</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__value">{visibleItems.length}</div>
|
||||||
<div className="finance-metric-card"><div className="finance-metric-card__label">人员相关</div><div className="finance-metric-card__value">1</div></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.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>
|
||||||
<div className="admin-tile-grid">
|
|
||||||
{ITEMS.map((item) => (
|
<div className="space-y-5">
|
||||||
<Link key={item.to} to={item.to} className="admin-tile-card">
|
{visibleGroups.map(({ group, items }) => (
|
||||||
<div className="flex items-start gap-3">
|
<section key={group} className="admin-command-panel space-y-3">
|
||||||
<span className="admin-tile-card__icon">
|
<h2 className="text-base font-semibold text-zinc-900">{group}</h2>
|
||||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
<div className="admin-tile-grid">
|
||||||
</span>
|
{items.map((item) => (
|
||||||
<span className="min-w-0">
|
<Link key={item.to} to={item.to} className="admin-tile-card">
|
||||||
<span className="block text-base font-semibold text-zinc-900">{item.title}</span>
|
<div className="flex items-start gap-3">
|
||||||
<span className="mt-1 block text-sm leading-5 text-zinc-600">{item.description}</span>
|
<span className="admin-tile-card__icon">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</Link>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ListWorkspace>
|
</ListWorkspace>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { toast } from "sonner";
|
|||||||
const MODULE_LABELS: Record<string, string> = {
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
portal: "工作台",
|
portal: "工作台",
|
||||||
team: "团队",
|
team: "团队",
|
||||||
leaveRequests: "请假",
|
leaveRequests: "请假记录",
|
||||||
attendance: "考勤",
|
attendance: "考勤",
|
||||||
reimbursements: "报销",
|
reimbursements: "报销",
|
||||||
assets: "资产",
|
assets: "资产",
|
||||||
@@ -24,31 +24,31 @@ const MODULE_LABELS: Record<string, string> = {
|
|||||||
organization: "组织架构",
|
organization: "组织架构",
|
||||||
contractsRegistry: "合同台账",
|
contractsRegistry: "合同台账",
|
||||||
workflowDefinitions: "流程定义",
|
workflowDefinitions: "流程定义",
|
||||||
systemSettings: "系统设置",
|
systemSettings: "管理",
|
||||||
featureFlags: "功能开关",
|
featureFlags: "功能管理",
|
||||||
auditLogs: "审计日志",
|
auditLogs: "审计日志",
|
||||||
approvalRules: "审批规则",
|
approvalRules: "审批规则",
|
||||||
userManagement: "用户管理",
|
userManagement: "用户管理",
|
||||||
departments: "部门管理",
|
departments: "部门管理",
|
||||||
masterData: "主数据",
|
masterData: "主数据",
|
||||||
notificationChannels: "通知渠道",
|
notificationChannels: "通知渠道",
|
||||||
workflowTasks: "审批待办",
|
workflowTasks: "待我处理",
|
||||||
dashboard: "仪表盘",
|
dashboard: "仪表盘",
|
||||||
purchaseRequests: "采购申请",
|
purchaseRequests: "采购申请",
|
||||||
travelRequests: "差旅申请",
|
travelRequests: "差旅申请",
|
||||||
budgets: "预算管理",
|
budgets: "预算管理",
|
||||||
paymentBatches: "付款批次",
|
paymentBatches: "付款",
|
||||||
apOperations: "AP 运营",
|
apOperations: "应付账款",
|
||||||
invoicePool: "票据中心",
|
invoicePool: "票据",
|
||||||
inventoryChecks: "资产盘点",
|
inventoryChecks: "资产盘点",
|
||||||
cutover: "切换控制台",
|
cutover: "切换控制台",
|
||||||
erpIntegrations: "ERP 集成",
|
erpIntegrations: "外部系统",
|
||||||
profile: "个人设置",
|
profile: "个人设置",
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBILITY_OPTIONS = [
|
const VISIBILITY_OPTIONS = [
|
||||||
{ value: "main", label: "主导航" },
|
{ value: "main", label: "常用入口" },
|
||||||
{ value: "admin", label: "管理区" },
|
{ value: "admin", label: "管理与更多" },
|
||||||
{ value: "hidden", label: "隐藏" },
|
{ value: "hidden", label: "隐藏" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export function WorkspaceSetupPage() {
|
|||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
eyebrow="管理"
|
eyebrow="管理"
|
||||||
title="工作区设置"
|
title="工作区设置"
|
||||||
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
|
subtitle="按 10 人以下小团队模式收敛常用入口与低频模块。"
|
||||||
/>
|
/>
|
||||||
<div className="admin-command-panel text-sm text-zinc-500">加载工作区设置中...</div>
|
<div className="admin-command-panel text-sm text-zinc-500">加载工作区设置中...</div>
|
||||||
</ListWorkspace>
|
</ListWorkspace>
|
||||||
@@ -113,7 +113,7 @@ export function WorkspaceSetupPage() {
|
|||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
eyebrow="管理"
|
eyebrow="管理"
|
||||||
title="工作区设置"
|
title="工作区设置"
|
||||||
subtitle="按小团队运营模式收敛主导航、管理入口和备用模块。"
|
subtitle="按小团队运营模式收敛常用入口、管理入口和低频模块。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="finance-status-strip" data-testid="workspace-settings-summary">
|
<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 className="finance-metric-card__value">{workspaceStats.total}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="finance-metric-card">
|
<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 className="finance-metric-card__value">{workspaceStats.main}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="finance-metric-card">
|
<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 className="finance-metric-card__value">{workspaceStats.admin}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="finance-metric-card">
|
<div className="finance-metric-card">
|
||||||
@@ -171,9 +171,9 @@ export function WorkspaceSetupPage() {
|
|||||||
|
|
||||||
<section className="admin-command-panel space-y-4">
|
<section className="admin-command-panel space-y-4">
|
||||||
<div>
|
<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 className="mt-1 text-sm leading-6 text-zinc-600">
|
||||||
主导航只保留高频入口;备用模块保留代码与数据,低频功能默认收进管理区或隐藏。
|
常用入口只保留高频任务;低频能力保留代码与数据,默认收进管理、更多或隐藏。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async function mockApis(page: Page, ledgerQueries: string[]) {
|
|||||||
sections: {
|
sections: {
|
||||||
main: [navigationItem("portal", "/portal", "工作台", "home_app_logo")],
|
main: [navigationItem("portal", "/portal", "工作台", "home_app_logo")],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [navigationItem("ap", "/ap-operations", "AP 运营", "account_balance_wallet")],
|
backup: [navigationItem("ap", "/ap-operations", "应付账款", "account_balance_wallet")],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific
|
|||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home"),
|
navigationItem("portal", "/portal", "工作台", "home"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [],
|
backup: [],
|
||||||
@@ -938,7 +938,7 @@ test.describe("MIF7 移动信息架构整改", () => {
|
|||||||
await loginForMobile(mobile.page, "manager");
|
await loginForMobile(mobile.page, "manager");
|
||||||
|
|
||||||
await mobile.page.goto("/mobile");
|
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-pending-summary")).toHaveCount(1);
|
||||||
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
|
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
|
||||||
await expect(mobile.page.getByTestId("mobile-action-approvals")).toHaveCount(0);
|
await expect(mobile.page.getByTestId("mobile-action-approvals")).toHaveCount(0);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class AppShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async assertMainNavVisible() {
|
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();
|
await expect(this.page.getByRole("link", { name: /项目/ }).first()).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ type Envelope<T> = {
|
|||||||
|
|
||||||
export async function assertPortalLoaded(page: Page) {
|
export async function assertPortalLoaded(page: Page) {
|
||||||
await expect(page).toHaveURL(/\/portal/);
|
await expect(page).toHaveURL(/\/portal/);
|
||||||
await expect(
|
await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible();
|
||||||
page.getByRole("link", { name: "工作台", exact: true })
|
|
||||||
.or(page.getByRole("link", { name: "Portal", exact: true }))
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,11 +33,7 @@ export async function assertForbiddenPage(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function assertMainNavVisible(page: Page) {
|
export async function assertMainNavVisible(page: Page) {
|
||||||
await expect(
|
await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible();
|
||||||
page.getByRole("link", { name: "工作台", exact: true })
|
|
||||||
.or(page.getByRole("link", { name: "Portal", exact: true }))
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByRole("link", { name: "团队" }).first()).toBeVisible();
|
await expect(page.getByRole("link", { name: "团队" }).first()).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "项目" }).first()).toBeVisible();
|
await expect(page.getByRole("link", { name: "项目" }).first()).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
|||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
await (await openNavLink(page, "票据中心")).click();
|
await (await openNavLink(page, "票据")).click();
|
||||||
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const leaveId = await createLeaveDraft(page, startDate, startDate, `Playwright 编辑请假 ${Date.now()}`);
|
const leaveId = await createLeaveDraft(page, startDate, startDate, `Playwright 编辑请假 ${Date.now()}`);
|
||||||
@@ -125,7 +125,7 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
|
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
|
||||||
@@ -177,7 +177,7 @@ test("E2E 请假退回说明:审批退回后员工可看到处理意见", asyn
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const leaveId = await createLeaveDraft(page, leaveDate, leaveDate, `Playwright 退回请假 ${Date.now()}`);
|
const leaveId = await createLeaveDraft(page, leaveDate, leaveDate, `Playwright 退回请假 ${Date.now()}`);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ test.describe("live smoke", () => {
|
|||||||
await expect(page.getByRole("heading", { name: "工作区设置", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "工作区设置", exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/system/feature-flags");
|
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 page.goto("/audit-logs");
|
||||||
await expect(page.getByRole("heading", { name: "审计工作台", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "审计工作台", exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -181,43 +181,36 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
|
|||||||
const sections = mode === "manager"
|
const sections = mode === "manager"
|
||||||
? {
|
? {
|
||||||
main: [
|
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: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
|
{ key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||||
{ key: "leave", href: "/leave-requests", label: "请假", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
|
|
||||||
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", 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: "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: [
|
admin: [
|
||||||
{ key: "workspace", href: "/system/workspace", label: "工作区设置", icon: "tune", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
{ key: "settings", href: "/system/settings", label: "管理", icon: "settings", 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 },
|
|
||||||
],
|
],
|
||||||
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 },
|
||||||
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", 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 },
|
{ key: "travel", href: "/travel-requests", label: "差旅申请", icon: "flight", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
main: [
|
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: "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: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", 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: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", 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 },
|
|
||||||
],
|
],
|
||||||
admin: [],
|
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({
|
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" },
|
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||||
greeting: { name: "Manager" },
|
greeting: { name: "Manager" },
|
||||||
cards: [
|
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: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
|
||||||
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
|
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
|
||||||
{ key: "weekly_help", title: "需协助员工", value: 1, href: "/weekly-reports", severity: "high" },
|
{ 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" },
|
{ domain: "leave", count: 0, href: "/leave-requests" },
|
||||||
],
|
],
|
||||||
riskSummary: { openCount: 1, href: "/ap-operations" },
|
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" }],
|
quickLinks: [{ key: "projects", title: "项目", href: "/projects" }],
|
||||||
todayPresence: null,
|
todayPresence: null,
|
||||||
teamPresence: { date: "2026-03-22", href: "/team", office: 6, remote: 1, out: 0, travel: 1, leave: 1, unspecified: 1, total: 8 },
|
teamPresence: { date: "2026-03-22", href: "/team", office: 6, remote: 1, out: 0, travel: 1, leave: 1, unspecified: 1, total: 8 },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
await reimbursementDetail.assertLoaded();
|
await reimbursementDetail.assertLoaded();
|
||||||
|
|
||||||
// 2) employee: upload invoice and wait OCR
|
// 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 marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
const invoiceFileName = `e2e-${Date.now()}.pdf`;
|
const invoiceFileName = `e2e-${Date.now()}.pdf`;
|
||||||
const uploadResponsePromise = page.waitForResponse((response) => {
|
const uploadResponsePromise = page.waitForResponse((response) => {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
await runStep("employee", "请假提交并生成经理待办", async () => {
|
await runStep("employee", "请假提交并生成经理待办", async () => {
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
const leave = await createAndSubmitLeaveForManager(page);
|
const leave = await createAndSubmitLeaveForManager(page);
|
||||||
await page.goto("/leave-requests");
|
await page.goto("/leave-requests");
|
||||||
@@ -204,7 +204,7 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
|
|||||||
await runStep("employee", "周报与待办入口", async () => {
|
await runStep("employee", "周报与待办入口", async () => {
|
||||||
await (await openNavLink(page, "周报")).click();
|
await (await openNavLink(page, "周报")).click();
|
||||||
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
|
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();
|
await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,18 +245,20 @@ test("R70 自动辅助试运行:员工、经理、管理员路径巡检", asyn
|
|||||||
await runStep("admin", "管理入口与人员组织", async () => {
|
await runStep("admin", "管理入口与人员组织", async () => {
|
||||||
await logout(page, MANAGER.name);
|
await logout(page, MANAGER.name);
|
||||||
await login(page, ADMIN);
|
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 expect(page.getByRole("heading", { name: "人员管理" })).toBeVisible();
|
||||||
await (await openNavLink(page, "组织架构")).click();
|
await page.goto("/organization");
|
||||||
await expect(page.getByRole("heading", { name: "组织架构" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "组织架构" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await runStep("admin", "系统设置、功能开关、审计日志", async () => {
|
await runStep("admin", "管理、功能管理、审计日志", async () => {
|
||||||
await (await openNavLink(page, "系统设置")).click();
|
await (await openNavLink(page, /^管理$/)).click();
|
||||||
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "管理", exact: true })).toBeVisible();
|
||||||
await (await openNavLink(page, "功能开关")).click();
|
await page.getByRole("link", { name: /功能管理/ }).first().click();
|
||||||
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible();
|
||||||
await (await openNavLink(page, "审计日志")).click();
|
await page.goto("/audit-logs");
|
||||||
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
|
|||||||
await submitResponse;
|
await submitResponse;
|
||||||
await expect(page.getByText("周报已提交")).toBeVisible();
|
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();
|
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
|
||||||
await expect(todoRow).toBeVisible({ timeout: 30_000 });
|
await expect(todoRow).toBeVisible({ timeout: 30_000 });
|
||||||
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
|
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
|
||||||
|
|||||||
@@ -778,7 +778,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
|||||||
const startDate = futureWeekday(uniqueFutureOffset(5200));
|
const startDate = futureWeekday(uniqueFutureOffset(5200));
|
||||||
const employee = await loginRole(page, "employee");
|
const employee = await loginRole(page, "employee");
|
||||||
try {
|
try {
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const leaveId = await createLeaveDraft(page, startDate, `${trialrunMarkerPrefix} 审批边界 ${marker}`);
|
const leaveId = await createLeaveDraft(page, startDate, `${trialrunMarkerPrefix} 审批边界 ${marker}`);
|
||||||
@@ -952,7 +952,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
|||||||
const startDate = futureWeekday(uniqueFutureOffset(3200));
|
const startDate = futureWeekday(uniqueFutureOffset(3200));
|
||||||
const employee = await loginRole(page, "employee");
|
const employee = await loginRole(page, "employee");
|
||||||
try {
|
try {
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const leaveId = await createLeaveDraft(page, startDate, `${trialrunMarkerPrefix} 浏览器请假 ${marker}`);
|
const leaveId = await createLeaveDraft(page, startDate, `${trialrunMarkerPrefix} 浏览器请假 ${marker}`);
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ async function mockWave2Apis(page: Page) {
|
|||||||
sections: {
|
sections: {
|
||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
@@ -206,7 +206,7 @@ async function mockWave2Apis(page: Page) {
|
|||||||
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
|
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
|
||||||
greeting: { name: visualMe.name },
|
greeting: { name: visualMe.name },
|
||||||
cards: [
|
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: "team_presence", title: "团队状态", value: "7/9 在岗", href: "/team", severity: "normal" },
|
||||||
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
|
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
|
||||||
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
|
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ async function mockWave3Apis(page: Page) {
|
|||||||
sections: {
|
sections: {
|
||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("notifications", "/notifications", "通知", "notifications"),
|
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.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
|
||||||
|
|
||||||
await page.goto("/mobile/approvals");
|
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-focus")).toBeVisible();
|
||||||
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||||
await expect(page.locator("table")).toHaveCount(0);
|
await expect(page.locator("table")).toHaveCount(0);
|
||||||
|
|||||||
@@ -515,22 +515,12 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
|
|||||||
sections: {
|
sections: {
|
||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("payment-batches", "/payment-batches", "付款批次", "payments"),
|
navigationItem("payment-batches", "/payment-batches", "付款", "payments"),
|
||||||
navigationItem("ap-operations", "/ap-operations", "AP 运营", "account_balance_wallet"),
|
navigationItem("ap-operations", "/ap-operations", "应付账款", "account_balance_wallet"),
|
||||||
navigationItem("invoice-pool", "/invoice-pool", "票据中心", "receipt_long"),
|
navigationItem("invoice-pool", "/invoice-pool", "票据", "receipt_long"),
|
||||||
],
|
],
|
||||||
admin: [
|
admin: [
|
||||||
navigationItem("people", "/people", "人员管理", "badge", "admin"),
|
navigationItem("settings", "/system/settings", "管理", "settings", "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"),
|
|
||||||
],
|
],
|
||||||
backup: [],
|
backup: [],
|
||||||
},
|
},
|
||||||
@@ -1020,7 +1010,7 @@ const pages = [
|
|||||||
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "08-users" },
|
{ 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/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/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/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: "/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" },
|
{ 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");
|
const approvalTab = page.getByTestId("mobile-nav-approvals");
|
||||||
if (canApprove) {
|
if (canApprove) {
|
||||||
await expect(approvalTab).toHaveAttribute("href", "/mobile/approvals");
|
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 expect(page.getByTestId("mobile-action-grid").locator('a[href="/mobile/approvals"]')).toHaveCount(0);
|
||||||
await page.goto("/mobile/approvals");
|
await page.goto("/mobile/approvals");
|
||||||
await expect(page).not.toHaveURL(/\/403/);
|
await expect(page).not.toHaveURL(/\/403/);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ async function mockDesignApis(page: Page) {
|
|||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
||||||
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ async function mockMobileApis(page: Page) {
|
|||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
||||||
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ async function mockApis(page: Page) {
|
|||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
|
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ async function mockApis(page: Page) {
|
|||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"),
|
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"),
|
||||||
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
|
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [
|
backup: [
|
||||||
navigationItem("payment", "/payment-batches", "付款批次", "payments"),
|
navigationItem("payment", "/payment-batches", "付款", "payments"),
|
||||||
navigationItem("dashboard", "/dashboard", "数据看板", "monitoring"),
|
navigationItem("dashboard", "/dashboard", "数据看板", "monitoring"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
|||||||
|
|
||||||
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
||||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
|
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 expect(mobile.page.locator(".mobile-status-slot--approval").first()).toContainText("待处理");
|
||||||
await expectMobilePageContract(mobile.page);
|
await expectMobilePageContract(mobile.page);
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ test.describe("R6 移动响应式体系", () => {
|
|||||||
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
|
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
|
||||||
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
|
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
|
||||||
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(2);
|
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) {
|
for (const action of homeMetrics.actionItems) {
|
||||||
expect(bottomTabTexts.has(action.text), `${device.label} ${action.text} not duplicate bottom tab`).toBe(false);
|
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);
|
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();
|
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
|
||||||
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
|
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
|
||||||
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
|
expect(homeMetrics.actionItems.length, `${label} action count`).toBeGreaterThanOrEqual(2);
|
||||||
const bottomTabLabels = new Set(["首页", "审批", "进度", "报销", "我的"]);
|
const bottomTabLabels = new Set(["首页", "待办", "进度", "报销", "我的"]);
|
||||||
for (const action of homeMetrics.actionItems) {
|
for (const action of homeMetrics.actionItems) {
|
||||||
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
|
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
|
||||||
expect(bottomTabLabels.has(action.key), `${label} ${action.key} not bottom tab key`).toBe(false);
|
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 login(page, ADMIN);
|
||||||
await page.goto("/system/feature-flags");
|
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}`);
|
const row = page.getByTestId(`feature-flag-row-${flagKey}`);
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|||||||
@@ -210,12 +210,12 @@ async function mockR89Apis(page: Page) {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("team", "/team", "团队", "groups"),
|
navigationItem("team", "/team", "团队", "groups"),
|
||||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
||||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
|
||||||
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
|
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
|
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ async function mockAuthenticatedShell(page: Page) {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
main: [
|
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: "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: "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: [],
|
admin: [],
|
||||||
backup: [],
|
backup: [],
|
||||||
|
|||||||
@@ -2,25 +2,25 @@ import { test, expect } from "./helpers/fixtures";
|
|||||||
import { ADMIN, login } from "./helpers/auth";
|
import { ADMIN, login } from "./helpers/auth";
|
||||||
import { TEST_IDS } from "./helpers/testIds";
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
|
||||||
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page, shell }) => {
|
test("E2E 管理域 smoke:工作区设置、管理、功能管理、流程定义、审计日志", async ({ page, shell }) => {
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
await expect(shell.topbarProfileTrigger).toBeVisible();
|
await expect(shell.topbarProfileTrigger).toBeVisible();
|
||||||
await shell.assertBackupModulesCollapsed();
|
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).toHaveURL(/\/system\/workspace/);
|
||||||
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
|
||||||
await expect(page.getByText("产品模式")).toBeVisible();
|
await expect(page.getByText("工作区基线")).toBeVisible();
|
||||||
|
|
||||||
await (await shell.navigate("系统设置")).click();
|
await page.goto("/system/feature-flags");
|
||||||
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 expect(page).toHaveURL(/\/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 expect(page.getByTestId("feature-flags-table")).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/system/workflow-definitions");
|
await page.goto("/system/workflow-definitions");
|
||||||
@@ -28,7 +28,7 @@ test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流
|
|||||||
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
|
||||||
await expect(page.getByText("版本列表")).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).toHaveURL(/\/audit-logs/);
|
||||||
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
||||||
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
|
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ async function mockSystemActionApis(page: Page) {
|
|||||||
items: [
|
items: [
|
||||||
navigationItem("portal", "/portal", "首页", "home"),
|
navigationItem("portal", "/portal", "首页", "home"),
|
||||||
navigationItem("reimbursements", "/reimbursements", "报销", "receipt"),
|
navigationItem("reimbursements", "/reimbursements", "报销", "receipt"),
|
||||||
navigationItem("payment-batches", "/payment-batches", "付款批次", "credit_card"),
|
navigationItem("payment-batches", "/payment-batches", "付款", "credit_card"),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ function mockCommonAuth(page: import("@playwright/test").Page, options: {
|
|||||||
updatedBy: null,
|
updatedBy: null,
|
||||||
},
|
},
|
||||||
sections: {
|
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: [{ key: "users", href: "/system/users", label: "系统用户", icon: "manage_accounts", placement: "admin", permissionCodes: ["system.users"], featureFlagKey: null }],
|
admin: [{ key: "settings", href: "/system/settings", label: "管理", icon: "settings", placement: "admin", permissionCodes: ["system.users"], featureFlagKey: null }],
|
||||||
backup: [],
|
backup: [],
|
||||||
},
|
},
|
||||||
recommendedRolePresets: [],
|
recommendedRolePresets: [],
|
||||||
|
|||||||
@@ -116,13 +116,12 @@ async function mockVisualApis(page: Page) {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
|
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
|
||||||
navigationItem("approvals", "/workflow/tasks", "审批待办", "fact_check"),
|
navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"),
|
||||||
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
|
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
|
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
|
||||||
},
|
},
|
||||||
recommendedRolePresets: [],
|
recommendedRolePresets: [],
|
||||||
});
|
});
|
||||||
@@ -323,7 +322,7 @@ test.describe("visual shell", () => {
|
|||||||
|
|
||||||
await expect(page.locator("aside")).toBeVisible();
|
await expect(page.locator("aside")).toBeVisible();
|
||||||
await expect(page.getByTestId("topbar-profile-trigger")).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 expect(page.locator("summary").filter({ hasText: "更多" })).toBeVisible();
|
||||||
|
|
||||||
await attachScreenshot(page, testInfo, "app-shell.png");
|
await attachScreenshot(page, testInfo, "app-shell.png");
|
||||||
@@ -364,7 +363,7 @@ test.describe("visual shell", () => {
|
|||||||
await attachScreenshot(page, testInfo, "mobile-today.png");
|
await attachScreenshot(page, testInfo, "mobile-today.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("审批待办手机卡片布局基线", async ({ page }, testInfo) => {
|
test("待我处理手机卡片布局基线", async ({ page }, testInfo) => {
|
||||||
await mockVisualApis(page);
|
await mockVisualApis(page);
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await loginVisualUser(page);
|
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-mobile-list")).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-tasks-table")).toBeHidden();
|
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");
|
await attachScreenshot(page, testInfo, "r97-workflow-tasks-mobile.png");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入
|
|||||||
updatedBy: null,
|
updatedBy: null,
|
||||||
},
|
},
|
||||||
sections: {
|
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: [],
|
admin: [],
|
||||||
backup: [],
|
backup: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { test, expect } from "./helpers/fixtures";
|
import { test, expect } from "./helpers/fixtures";
|
||||||
|
|
||||||
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||||
import { openNavLink } from "./helpers/navigation";
|
|
||||||
import { TEST_IDS } from "./helpers/testIds";
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
|
||||||
function formatWeekStart(offsetDays: number) {
|
function formatWeekStart(offsetDays: number) {
|
||||||
@@ -53,7 +52,7 @@ test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待
|
|||||||
await expect(page.getByText("周报已提交")).toBeVisible();
|
await expect(page.getByText("周报已提交")).toBeVisible();
|
||||||
await expect(page.getByText("已提交", { exact: true })).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();
|
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
|
||||||
await expect(todoRow).toBeVisible({ timeout: 30_000 });
|
await expect(todoRow).toBeVisible({ timeout: 30_000 });
|
||||||
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
|
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ async function mockWorkflowConfirmApis(page: Page) {
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
main: [
|
main: [
|
||||||
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
|
||||||
],
|
],
|
||||||
admin: [],
|
admin: [],
|
||||||
backup: [],
|
backup: [],
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假记录")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
|
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
|
|||||||
- 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。
|
- 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。
|
||||||
- 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。
|
- 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。
|
||||||
- 个人设置默认只放账号信息、通知设置和企业微信绑定;组织归属、直属汇报和角色摘要可折叠展示,不默认暴露权限 code 或完整员工档案维护内容。
|
- 个人设置默认只放账号信息、通知设置和企业微信绑定;组织归属、直属汇报和角色摘要可折叠展示,不默认暴露权限 code 或完整员工档案维护内容。
|
||||||
|
- PC 侧边栏是小团队任务入口,不是全量模块目录;一级入口必须按角色、权限、频次和真实待办价值收敛,新增功能默认不得直接进入一级侧边栏。
|
||||||
|
- 审批人、负责人和财务看到 `待我处理`;普通员工默认不显示 `待办` 或 `审批待办` 入口;通知只保留顶部铃铛或通知中心,不进入侧边栏。
|
||||||
|
- PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。
|
||||||
|
- 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和外部系统入口进入管理页后分组承接。
|
||||||
|
- 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`ERP 集成`、`功能开关` 等内部平台词不作为默认一级菜单文案。
|
||||||
|
- `更多` 只承载低频入口,不使用说明型副标题、教学卡片或历史模块解释文案。
|
||||||
|
|
||||||
## 业务体验事实
|
## 业务体验事实
|
||||||
|
|
||||||
|
|||||||
@@ -14,28 +14,81 @@ const BUSINESS_APPROVE_PERMISSIONS = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]);
|
const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]);
|
||||||
const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]);
|
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_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
|
||||||
const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "invoicePool"]);
|
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({
|
const NAVIGATION_REGISTRY = Object.freeze({
|
||||||
portal: { key: "portal", label: "工作台", icon: "home_app_logo", href: "/portal", anyOf: [] },
|
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 },
|
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"] },
|
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 },
|
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"] },
|
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"] },
|
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 },
|
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 },
|
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 },
|
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"] },
|
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 },
|
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 },
|
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 },
|
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 },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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"] },
|
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 },
|
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 },
|
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"] },
|
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"] },
|
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 },
|
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: [] },
|
profile: { key: "profile", label: "个人设置", icon: "manage_accounts", href: "/settings/profile", anyOf: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,10 +116,10 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
|
|||||||
leaveRequests: "main",
|
leaveRequests: "main",
|
||||||
attendance: "hidden",
|
attendance: "hidden",
|
||||||
reimbursements: "main",
|
reimbursements: "main",
|
||||||
assets: "main",
|
assets: "admin",
|
||||||
weeklyReports: "main",
|
weeklyReports: "admin",
|
||||||
todos: "main",
|
todos: "hidden",
|
||||||
announcements: "main",
|
announcements: "admin",
|
||||||
projects: "main",
|
projects: "main",
|
||||||
|
|
||||||
workspace: "admin",
|
workspace: "admin",
|
||||||
@@ -198,11 +251,53 @@ function hasBusinessApproverRole(hasPermission, employee) {
|
|||||||
function hasFinanceRole(hasPermission, employee) {
|
function hasFinanceRole(hasPermission, employee) {
|
||||||
const roleCodes = employee?.roleCodes ?? [];
|
const roleCodes = employee?.roleCodes ?? [];
|
||||||
return (
|
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)
|
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 }) {
|
async function resolveModuleGate({ prisma, config, employee, moduleKey, registryItem }) {
|
||||||
if (!registryItem?.featureFlagKey) {
|
if (!registryItem?.featureFlagKey) {
|
||||||
return { enabled: true, reason: "no_feature_gate" };
|
return { enabled: true, reason: "no_feature_gate" };
|
||||||
@@ -235,6 +330,10 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (moduleKey === "team" && !hasTeamNavigationRole(employee)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (FINANCE_NAVIGATION_MODULES.has(moduleKey) && !hasFinanceRole(hasPermission, employee)) {
|
if (FINANCE_NAVIGATION_MODULES.has(moduleKey) && !hasFinanceRole(hasPermission, employee)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -254,25 +353,30 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
|
|||||||
featureFlagKey: registryItem.featureFlagKey ?? null,
|
featureFlagKey: registryItem.featureFlagKey ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (["purchaseRequests", "travelRequests", "budgets", "paymentBatches", "apOperations", "invoicePool", "inventoryChecks", "cutover", "erpIntegrations", "approvalRules", "userManagement", "departments", "masterData", "notificationChannels", "workflowTasks", "dashboard"].includes(moduleKey)) {
|
const section = resolveNavigationSection({
|
||||||
sections.backup.push(entry);
|
moduleKey,
|
||||||
|
placement,
|
||||||
|
financeNavigation: hasFinanceRole(hasPermission, employee),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placement === "admin") {
|
sections[section].push(entry);
|
||||||
sections.admin.push(entry);
|
|
||||||
} else {
|
|
||||||
sections.main.push(entry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspace,
|
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: [
|
recommendedRolePresets: [
|
||||||
{ key: "owner", label: "老板", roleCodes: ["ceo"], description: "查看团队状态、审批汇总与关键管理入口。" },
|
{ key: "owner", label: "老板", roleCodes: ["ceo"], description: "查看团队状态、审批汇总与关键管理入口。" },
|
||||||
{ key: "admin", label: "管理员", roleCodes: ["admin"], 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: "处理审批、查看团队周报与在岗状态。" },
|
{ key: "manager", label: "主管", roleCodes: ["manager"], description: "处理审批、查看团队周报与在岗状态。" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,13 +6,31 @@ const {
|
|||||||
} = require("../../src/services/workspaceSettings");
|
} = require("../../src/services/workspaceSettings");
|
||||||
const { hasPermission, resolvePermissionsForRoles } = require("../../src/constants/permissions");
|
const { hasPermission, resolvePermissionsForRoles } = require("../../src/constants/permissions");
|
||||||
|
|
||||||
function buildPrisma() {
|
function buildPrisma(enabledFlagKeys = []) {
|
||||||
|
const enabled = new Set(enabledFlagKeys);
|
||||||
return {
|
return {
|
||||||
workspaceSetting: {
|
workspaceSetting: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
featureFlag: {
|
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", () => {
|
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)
|
const mainModules = Object.entries(DEFAULT_MODULE_VISIBILITY)
|
||||||
.filter(([, placement]) => placement === "main")
|
.filter(([, placement]) => placement === "main")
|
||||||
.map(([key]) => key);
|
.map(([key]) => key);
|
||||||
@@ -47,12 +65,10 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
"team",
|
"team",
|
||||||
"leaveRequests",
|
"leaveRequests",
|
||||||
"reimbursements",
|
"reimbursements",
|
||||||
"assets",
|
|
||||||
"weeklyReports",
|
|
||||||
"todos",
|
|
||||||
"announcements",
|
|
||||||
"projects",
|
"projects",
|
||||||
]);
|
]);
|
||||||
|
expect(DEFAULT_MODULE_VISIBILITY.todos).toBe("hidden");
|
||||||
|
expect(DEFAULT_MODULE_VISIBILITY.assets).toBe("admin");
|
||||||
expect(DEFAULT_MODULE_VISIBILITY.attendance).toBe("hidden");
|
expect(DEFAULT_MODULE_VISIBILITY.attendance).toBe("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +93,7 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
expect(workspace.moduleVisibility.attendance).toBe("hidden");
|
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 = {
|
const employee = {
|
||||||
id: "employee-1",
|
id: "employee-1",
|
||||||
department_id: "dept-1",
|
department_id: "dept-1",
|
||||||
@@ -86,12 +102,18 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigation = await getSystemNavigation({
|
const navigation = await getSystemNavigation({
|
||||||
prisma: buildPrisma(),
|
prisma: buildPrisma(["presence_board_v1", "project_domain_v1"]),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
employee,
|
employee,
|
||||||
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
|
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);
|
const backupKeys = navigation.sections.backup.map((item) => item.key);
|
||||||
expect(backupKeys).toContain("purchaseRequests");
|
expect(backupKeys).toContain("purchaseRequests");
|
||||||
expect(backupKeys).not.toContain("travelRequests");
|
expect(backupKeys).not.toContain("travelRequests");
|
||||||
@@ -104,7 +126,30 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
expect(backupKeys).not.toContain("dashboard");
|
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 = {
|
const adminOnly = {
|
||||||
id: "admin-1",
|
id: "admin-1",
|
||||||
department_id: "dept-1",
|
department_id: "dept-1",
|
||||||
@@ -113,7 +158,7 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigation = await getSystemNavigation({
|
const navigation = await getSystemNavigation({
|
||||||
prisma: buildPrisma(),
|
prisma: buildPrisma(["payment_batch_v1"]),
|
||||||
config: { ...buildConfig(), workflowDefinitionAdminEnabled: true },
|
config: { ...buildConfig(), workflowDefinitionAdminEnabled: true },
|
||||||
employee: adminOnly,
|
employee: adminOnly,
|
||||||
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
|
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
|
||||||
@@ -125,7 +170,32 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
...navigation.sections.backup,
|
...navigation.sections.backup,
|
||||||
].map((item) => item.key);
|
].map((item) => item.key);
|
||||||
|
|
||||||
expect(allKeys).toContain("auditLogs");
|
expect(allKeys).toContain("systemSettings");
|
||||||
|
expect(allKeys).not.toContain("auditLogs");
|
||||||
expect(allKeys).not.toContain("workflowTasks");
|
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("付款批次");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户