diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index e283d453..884efeda 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -167,8 +167,9 @@ header baseline instead of recreating page-local wrappers: - `AppShellLayout` owns the PC shell background and content offset through `app-shell` and `app-shell-main`. -- `Sidebar` and `NavLinks` own primary navigation grouping. The mental model is - `工作` -> `业务` -> `财务` -> `管理` -> collapsed `更多`. +- `Sidebar` and `NavLinks` own role-aware task navigation. The mental model is + a short primary task list plus collapsed `更多`; management configuration is + represented by one `管理` entry instead of many sidebar rows. - `Topbar` is a compact command surface: page title, group badge, user context, global search and notifications. Do not expand it back into a large hero. - `PageHeader` and `WorkspaceHeader` should use `page-header-shell`, @@ -181,6 +182,26 @@ header baseline instead of recreating page-local wrappers: **Why**: The shell/header baseline gives Wave3-5 pages a stable visual entry point and prevents each route from inventing a different title/action layout. +**Navigation Rules**: + +- Sidebar entries should be role-aware and task-first for domestic tech startups + under 10 people; do not expose all permission-allowed modules as a default + module catalog. +- Approvers see `待我处理`; ordinary employees should not see `待办` or + `审批待办` in the sidebar by default. +- PC leave navigation uses `请假记录` unless the current task explicitly opens OA + emergency leave submission. +- `个人设置` stays in the topbar account menu. Notifications stay in the topbar + bell and notification center. +- Finance labels should use work names such as `付款`, `应付账款` and `票据`; + internal platform words like `AP 运营`, `ERP 集成` and `功能开关` should be + hidden behind management wording or renamed for users. +- `更多` is a lightweight collapsed row for low-frequency entries; do not add a + sidebar explanation card, subtitle or onboarding copy around it. +- Playwright navigation helpers should use exact names or anchored regex for + short sidebar labels such as `管理`, otherwise fuzzy role-name matching can + click longer labels such as `预算管理`. + **Example**: ```tsx diff --git a/.trellis/tasks/05-29-role-based-navigation-simplification/check.jsonl b/.trellis/tasks/05-29-role-based-navigation-simplification/check.jsonl new file mode 100644 index 00000000..d35a1726 --- /dev/null +++ b/.trellis/tasks/05-29-role-based-navigation-simplification/check.jsonl @@ -0,0 +1,2 @@ +{"file":"docs/产品设计规则.md","reason":"检查菜单职责、角色优先级、企微/OA 边界和新增功能进入一级导航的约束是否被遵守。"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"检查全局 shell、导航壳层、前端验证和截图覆盖要求是否满足。"} diff --git a/.trellis/tasks/05-29-role-based-navigation-simplification/design.md b/.trellis/tasks/05-29-role-based-navigation-simplification/design.md new file mode 100644 index 00000000..38cb0e9d --- /dev/null +++ b/.trellis/tasks/05-29-role-based-navigation-simplification/design.md @@ -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`。 diff --git a/.trellis/tasks/05-29-role-based-navigation-simplification/implement.jsonl b/.trellis/tasks/05-29-role-based-navigation-simplification/implement.jsonl new file mode 100644 index 00000000..fb25f139 --- /dev/null +++ b/.trellis/tasks/05-29-role-based-navigation-simplification/implement.jsonl @@ -0,0 +1,2 @@ +{"file":"docs/产品设计规则.md","reason":"产品体验事实源,包含小团队目标用户、OA/企微边界、首页/通知/个人设置职责边界。"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"前端 shell、导航、A 类任务优先页面和验证要求的工程规约。"} diff --git a/.trellis/tasks/05-29-role-based-navigation-simplification/prd.md b/.trellis/tasks/05-29-role-based-navigation-simplification/prd.md new file mode 100644 index 00000000..c9e8dded --- /dev/null +++ b/.trellis/tasks/05-29-role-based-navigation-simplification/prd.md @@ -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`。 +- 面向用户可测试前,如涉及部署,必须部署并完成公网检查后再告知用户可测。 diff --git a/.trellis/tasks/05-29-role-based-navigation-simplification/task.json b/.trellis/tasks/05-29-role-based-navigation-simplification/task.json new file mode 100644 index 00000000..f65fbe1a --- /dev/null +++ b/.trellis/tasks/05-29-role-based-navigation-simplification/task.json @@ -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": {} +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 4a3d8890..5dcd80ab 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -207,7 +207,7 @@ export function hasBusinessApproverRole(roleCodes: string[] | undefined, permiss } export function hasFinanceRole(roleCodes: string[] | undefined, permissionCodes: string[]) { - const hasRole = (roleCodes ?? []).some((roleCode) => (FINANCE_ROLE_CODES as readonly string[]).includes(roleCode)); + const hasRole = (roleCodes ?? []).some((roleCode) => (FINANCE_ROLE_CODES as readonly string[]).includes(roleCode) || roleCode === "admin"); return hasRole && hasAnyPermission(permissionCodes, [...FINANCE_PORTAL_PERMISSIONS]); } diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index effe7b07..bac7e915 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -1,4 +1,4 @@ -import { NavLink } from "react-router-dom"; +import { NavLink, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace"; import { useAuth } from "../../auth/AuthContext"; @@ -19,67 +19,53 @@ export type NavItem = { export const NAV_ITEMS: NavItem[] = [ { to: "/portal", label: "工作台", icon: "home_app_logo" }, - { to: "/team", label: "团队", icon: "groups" }, - { to: "/leave-requests", label: "请假", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] }, + { to: "/team", label: "团队", icon: "groups", anyOf: ["report.dashboard.view", "project.view_all", "project.manage", "system.users"] }, + { to: "/leave-requests", label: "请假记录", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] }, { to: "/attendance", label: "考勤", icon: "schedule" }, { to: "/reimbursements", label: "报销", icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"] }, { to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] }, { to: "/weekly-reports", label: "周报", icon: "article" }, - { to: "/todos", label: "待办", icon: "check_circle" }, { to: "/announcements", label: "公告", icon: "campaign" }, - { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] }, - { to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] }, - { to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] }, - { to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] }, - { to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] }, - { to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] }, - { to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] }, - { to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] }, - { to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, + { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_all", "project.manage"] }, + { to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] }, + { to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, { to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] }, { to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, { to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] }, - { to: "/payment-batches", label: "付款批次", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] }, - { to: "/ap-operations", label: "AP 运营", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] }, - { to: "/invoice-pool", label: "票据中心", icon: "receipt_long", anyOf: ["invoice.manage"] }, + { to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] }, + { to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] }, + { to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] }, { to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] }, - { to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] }, - { to: "/system/integrations/erp", label: "外部系统桥接", icon: "hub", anyOf: ["integration.manage"] }, - { to: "/dashboard", label: "仪表盘", icon: "dashboard", anyOf: ["report.dashboard.view"] }, - { to: "/settings/profile", label: "个人设置", icon: "manage_accounts" }, + { to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] }, ]; -const LEGACY_ACCESSIBLE_LABELS: Record = { +const ACCESSIBLE_LABELS: Record = { "/reimbursements": "我的报销", "/workflow/tasks": "待我处理", "/assets": "资产台账", }; -const WORK_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/todos"]; -const BUSINESS_NAV_ORDER = [ - "/projects", - "/contracts-registry", - "/purchase-requests", - "/assets", - "/weekly-reports", - "/team", - "/announcements", - "/leave-requests", - "/attendance", - "/wecom-approval-archive", -]; -const FINANCE_NAV_ORDER = ["/payment-batches", "/ap-operations", "/invoice-pool", "/budgets", "/inventory-checks"]; -const MANAGEMENT_NAV_ORDER = [ - "/people", - "/organization", +const MANAGEMENT_ACTIVE_PATHS = [ + "/system/settings", "/system/workspace", "/system/workflow-definitions", - "/system/settings", "/system/feature-flags", + "/system/approval-rules", + "/system/expense-standards", + "/system/reimbursement-templates", + "/system/master-data", + "/system/notification-channels", + "/system/integrations/erp", + "/system/users", + "/system/departments", + "/system/cutover", + "/people", + "/people/stats", + "/organization", "/audit-logs", ]; -const MORE_NAV_ORDER = ["/travel-requests", "/dashboard", "/settings/profile", "/system/cutover", "/system/integrations/erp"]; +const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"]; type ResolvedNavItem = { key?: string; @@ -88,8 +74,16 @@ type ResolvedNavItem = { icon: string; }; -function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) { - const accessibleLabel = LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label; +function isGroupedActive(item: ResolvedNavItem, pathname: string) { + if (item.href === "/system/settings") { + return MANAGEMENT_ACTIVE_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); + } + + return pathname === item.href || pathname.startsWith(`${item.href}/`); +} + +function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () => void) { + const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label; return ( void) { title={accessibleLabel} onClick={() => onNavigate?.()} className={({ isActive }) => - cn( - "group nav-link-item", - isActive ? "nav-link-item--active" : "nav-link-item--idle", - ) + cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle") } > - {({ isActive }) => ( - <> - - {item.icon} - - {item.label} - - )} + {({ isActive }) => { + const active = isActive || isGroupedActive(item, pathname); + return ( + <> + + {item.icon} + + {item.label} + + ); + }} ); } -function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) { +function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) { if (!items.length) { return null; } return (
-
{title}
-
{items.map((item) => renderNavItem(item, onNavigate))}
+
{items.map((item) => renderNavItem(item, pathname, onNavigate))}
); } -function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void) { +function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) { if (!items.length) { return null; } return ( -
- -
-
更多
-
低频入口和历史模块
-
- expand_more +
+ + + more_horiz + + 更多 + expand_more -
{items.map((item) => renderNavItem(item, onNavigate))}
+
{items.map((item) => renderNavItem(item, pathname, onNavigate))}
); } function buildFallbackItems(permissions: string[], roleCodes: string[]) { const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]); + const backupOnlyPaths = new Set(BACKUP_NAV_ORDER); const mapItems = (paths: string[]) => NAV_ITEMS.filter((item) => paths.includes(item.to)) .filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions)) - .filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions)) + .filter((item) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode))) + .filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || roleCodes.includes("admin")) .filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf)) + .filter((item) => paths === BACKUP_NAV_ORDER || !backupOnlyPaths.has(item.to)) .map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon })); return { @@ -170,77 +161,43 @@ function buildFallbackItems(permissions: string[], roleCodes: string[]) { }; } -function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) { - const allItems = [...sections.main, ...sections.admin, ...sections.backup]; +function orderItems(items: ResolvedNavItem[], order: string[]) { const byHref = new Map(); - - for (const item of allItems) { + for (const item of items) { if (!byHref.has(item.href)) { byHref.set(item.href, item); } } const used = new Set(); - const take = (hrefs: string[]) => - hrefs - .map((href) => { - const item = byHref.get(href); - if (item) { - used.add(href); - } - return item; - }) - .filter((item): item is ResolvedNavItem => Boolean(item)); + const ordered = order + .map((href) => { + const item = byHref.get(href); + if (item) { + used.add(href); + } + return item; + }) + .filter((item): item is ResolvedNavItem => Boolean(item)); - return { - main: take(MAIN_NAV_ORDER), - admin: take(ADMIN_NAV_ORDER), - backup: [ - ...take(BACKUP_NAV_ORDER), - ...allItems - .filter((item) => !used.has(item.href)) - .map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })), - ], - }; + return [...ordered, ...items.filter((item) => !used.has(item.href))]; } -function regroupSections(sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }) { - const allItems = [...sections.main, ...sections.admin, ...sections.backup]; - const byHref = new Map(); - for (const item of allItems) { - if (!byHref.has(item.href)) { - byHref.set(item.href, item); - } - } - - const used = new Set(); - const take = (hrefs: string[]) => - hrefs - .map((href) => { - const item = byHref.get(href); - if (item) { - used.add(href); - } - return item; - }) - .filter((item): item is ResolvedNavItem => Boolean(item)); +function toResolved(items: NavigationItem[]) { + return items.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })); +} +function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) { return { - work: take(WORK_NAV_ORDER), - business: take(BUSINESS_NAV_ORDER), - finance: take(FINANCE_NAV_ORDER), - management: take(MANAGEMENT_NAV_ORDER), - more: [ - ...take(MORE_NAV_ORDER), - ...allItems - .filter((item) => !used.has(item.href)) - .map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })), - ], + main: orderItems(toResolved(sections.main), MAIN_NAV_ORDER), + admin: orderItems(toResolved(sections.admin), ADMIN_NAV_ORDER), + backup: orderItems(toResolved(sections.backup), BACKUP_NAV_ORDER), }; } export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { const { me, permissions } = useAuth(); + const location = useLocation(); const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation, @@ -250,15 +207,12 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { const sections = navigationQuery.data?.sections ? normalizeSections(navigationQuery.data.sections) : buildFallbackItems(permissions, me?.roleCodes ?? []); - const groupedSections = regroupSections(sections); + const primaryItems = [...sections.main, ...sections.admin]; return ( -