From 4b0184c64b516e6f768b3afbb4344570cbfc6afc Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 3 Aug 2026 12:43:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(management-navigation-single-company-ux):?= =?UTF-8?q?=20=E6=94=B6=E6=95=9B=E7=AE=A1=E7=90=86=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E4=B8=8E=E5=8D=95=E5=85=AC=E5=8F=B8=E9=85=8D=E7=BD=AE=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 79 ++++ .../check.jsonl | 1 + .../design.md | 96 +++++ .../implement.jsonl | 1 + .../implement.md | 64 ++++ .../prd.md | 118 ++++++ .../task.json | 26 ++ client/src/api/errorMessages.ts | 10 +- .../master-data/HolidayCalendarTab.tsx | 10 +- .../components/master-data/SupplierTab.tsx | 6 +- client/src/components/navigation/NavLinks.tsx | 63 +-- client/src/components/navigation/Topbar.tsx | 30 +- .../travel/TravelReconciliationTab.tsx | 10 +- client/src/features/company/singleCompany.ts | 14 + .../features/navigation/managementRoutes.ts | 360 ++++++++++++++++++ client/src/features/navigation/schema.ts | 25 +- client/src/layouts/AppShellLayout.tsx | 6 +- client/src/lib/recentVisits.ts | 31 +- client/src/pages/AssetRegistryPage.tsx | 8 +- client/src/pages/BudgetsPage.tsx | 14 +- client/src/pages/CutoverConsolePage.tsx | 2 +- client/src/pages/InventoryChecksPage.tsx | 4 +- client/src/pages/MasterDataPage.tsx | 60 ++- client/src/pages/MobileAssetScanPage.tsx | 4 +- .../pages/MobilePurchaseRequestDetailPage.tsx | 4 +- .../src/pages/MobilePurchaseRequestsPage.tsx | 2 +- client/src/pages/PaymentAccountsPage.tsx | 74 +++- client/src/pages/PurchaseRequestsPage.tsx | 10 +- client/src/pages/SystemSettingsPage.tsx | 36 +- client/src/pages/TravelRequestsPage.tsx | 12 +- client/src/pages/WorkflowTaskDetailPage.tsx | 4 +- client/src/router/index.tsx | 48 ++- .../management-navigation-contract.spec.ts | 130 +++++++ .../e2e/pc-finance-admin-visual-wave4.spec.ts | 56 ++- docs/产品口径.md | 6 + scripts/qa/route-manifest.json | 3 + 36 files changed, 1249 insertions(+), 178 deletions(-) create mode 100644 .trellis/tasks/08-03-management-navigation-single-company-ux/check.jsonl create mode 100644 .trellis/tasks/08-03-management-navigation-single-company-ux/design.md create mode 100644 .trellis/tasks/08-03-management-navigation-single-company-ux/implement.jsonl create mode 100644 .trellis/tasks/08-03-management-navigation-single-company-ux/implement.md create mode 100644 .trellis/tasks/08-03-management-navigation-single-company-ux/prd.md create mode 100644 .trellis/tasks/08-03-management-navigation-single-company-ux/task.json create mode 100644 client/src/features/company/singleCompany.ts create mode 100644 client/src/features/navigation/managementRoutes.ts create mode 100644 client/tests/e2e/management-navigation-contract.spec.ts diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index de65b9ac..f2a29977 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -2107,3 +2107,82 @@ const actions = collectVisibleActions(); expect(actions.map(({ key, label }) => ({ key, label }))).toEqual(EXPECTED_ACTIONS); for (const action of actions) expect(action.height).toBeGreaterThanOrEqual(44); ``` + +## Scenario: Management Route Metadata And Permission Disclosure + +### 1. Scope / Trigger + +- Trigger: adding or changing a PC management landing item, management subpage, + nested management detail, breadcrumb, sidebar ownership or recent-visit title. + +### 2. Signatures + +- Registry: `client/src/features/navigation/managementRoutes.ts`. +- Resolver inputs: `pathname: string` plus the current `permissions: string[]`. +- Router permissions: `getManagementRoutePermissions(key)`. +- Recent visits: `writeRecentVisit(path, title, permissions)` and + `readRecentVisits(permissions)`. + +### 3. Contracts + +- The management landing cards, root-navigation permission union, sidebar + active state, Topbar title/breadcrumb and recent-visit filtering derive from + the same registry entry. +- React Router keeps explicit component declarations and consumes the same + registry permissions. Server authorization remains the hard access boundary; + navigation metadata only enforces least disclosure. +- Match the most specific route deliberately. A nested employee profile must + resolve after its exact siblings and must not inherit a broader prefix by + accident. +- A user without the registered permission receives no restricted title, + breadcrumb or recent-visit link. Revoked permissions also clean stale local + recent-visit records. +- The sidebar keeps one `管理` root entry. Do not spread low-frequency child + pages back into the primary navigation. + +### 4. Validation & Error Matrix + +- Registered route + allowed permission -> natural Chinese title, parent chain + and management sidebar active state. +- Registered route + denied permission -> no restricted metadata; Router and + server guards still reject access. +- Unknown registry key passed to the Router helper -> throw during development + instead of silently using an empty permission list. +- Newly declared application route missing from the route manifest -> local + candidate route gate fails. + +### 5. Good/Base/Bad Cases + +- Good: `/people/:employeeId` uses the registry for both + `管理 / 人员 / 员工档案` and its `system.users` Router guard. +- Base: `/system/settings` displays only `管理`, with no self-referencing + breadcrumb. +- Bad: add a path to `SystemSettingsPage`, `NavLinks`, `PAGE_TITLES` and Router + as four hand-maintained facts. + +### 6. Tests Required + +- Registry contract covers unique keys, expected path inventory, parents, + permissions, landing metadata and root permission derivation. +- Permission-negative coverage asserts no title/breadcrumb disclosure and + filters a previously stored recent visit after permission revocation. +- Focused Playwright covers direct management deep links, sidebar active state, + nested breadcrumbs and the management-root return link. +- Run `cd client && npm run typecheck && npm run build` and + `bash scripts/qa/run-local-candidate.sh list`. + +### 7. Wrong vs Correct + +#### Wrong + +```tsx +const MANAGEMENT_ACTIVE_PATHS = ["/people", "/system/users"]; +... +``` + +#### Correct + +```tsx +isManagementPath(pathname, permissions); +... +``` diff --git a/.trellis/tasks/08-03-management-navigation-single-company-ux/check.jsonl b/.trellis/tasks/08-03-management-navigation-single-company-ux/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-03-management-navigation-single-company-ux/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-03-management-navigation-single-company-ux/design.md b/.trellis/tasks/08-03-management-navigation-single-company-ux/design.md new file mode 100644 index 00000000..df5b317f --- /dev/null +++ b/.trellis/tasks/08-03-management-navigation-single-company-ux/design.md @@ -0,0 +1,96 @@ +# 管理导航闭环与单公司体验收敛设计 + +## 1. 设计边界 + +本任务修复管理信息架构的单一事实源和单公司配置体验,不把管理区改造成产品化后台。侧栏继续只有一个「管理」入口;管理首页继续承担低频配置入口聚合。统一层只提供路由元数据、父子关系和导航表达,不接管 React Router 组件装配或服务端权限校验。 + +## 2. 管理路由注册表 + +在 `client/src/features/navigation/` 建立共享管理路由注册表。每项建议包含: + +- `key`:稳定标识。 +- `path`:可导航地址;详情项可只提供 matcher 和父级。 +- `match`:exact/prefix 或显式匹配规则,避免 `/people` 与 `/people/:id` 标题混淆。 +- `title`:Topbar、面包屑和最近访问使用的自然中文。 +- `parentKey`:父级管理项;顶层配置页父级固定为 management root。 +- `group`、`icon`、`description`、`anyOf`:管理首页卡片的分组、展示和权限合同。 +- `showOnLanding`:详情层级不在聚合页重复显示。 + +共享解析函数: + +- `resolveManagementRoute(pathname, permissions)`:只返回当前有权识别的最具体匹配项。 +- `resolveManagementBreadcrumbs(pathname, permissions)`:在当前权限范围内沿 `parentKey` 生成可点击路径。 +- `isManagementPath(pathname, permissions)`:供侧栏选中判断,避免无权路径激活管理导航。 +- `getVisibleManagementItems(permissions)`:供管理首页渲染。 + +管理根入口的 `anyOf` 从所有可见管理子项权限求并集派生,避免 `NavLinks.tsx` 再维护一份权限列表。页面可见性仍以现有 Router 权限守卫和服务端权限校验为硬边界;注册表权限负责导航信息最小披露,不能替代访问控制。 + +`features/navigation/schema.ts` 先调用管理解析器,再回退到现有业务页面元数据;`NavLinks.tsx` 删除 `MANAGEMENT_ACTIVE_PATHS`;`SystemSettingsPage.tsx` 删除本地 `ITEMS`。 + +## 3. Topbar 面包屑 + +Topbar 已是所有 PC 页面稳定存在的壳层,比逐页增加返回按钮更可靠。管理路径命中时,把当前“标题 + 管理分组胶囊”表达为紧凑面包屑: + +```text +管理 / 企业银行账户 +管理 / 人员 / 员工档案 +``` + +- 非当前节点使用 `Link`;当前节点使用文本并带 `aria-current="page"`。 +- 管理首页仍显示普通标题「管理」。 +- 非管理业务页面保持现有 Topbar,不在本任务推广全系统面包屑。 +- 页面内部 `PageHeader` / `WorkspaceHeader` 不重复增加返回按钮,避免一页两套层级。 +- Topbar 从 `useAuth()` 取得当前权限后解析管理元数据;无权命中时不渲染受限标题或父链,避免权限守卫跳转前的短暂信息泄露。 + +## 4. 路由覆盖门禁 + +管理首页注册表是可见入口事实源,React Router 仍显式声明页面。测试用显式允许的管理路由清单核对: + +1. 每个 landing item 都能被 resolver 精确识别。 +2. 每个已注册详情路径返回正确父链。 +3. 所有管理页面标题均为自然中文。 +4. `SystemSettingsPage` 和 `NavLinks` 不再出现第二份管理路径数组。 +5. `/system/payment-accounts` 明确回归侧栏 active。 +6. 同一路径在权限具备/缺失时分别验证可见元数据与不披露行为。 + +不在运行时解析 Router JSX,也不引入通用动态路由框架。 + +## 5. 最近访问权限收口 + +当前 `AppShellLayout` 会把路径和 `resolvePageMeta()` 标题写入 localStorage,但仓库中尚无读取最近访问的 UI。共享注册表接入后: + +- 写入管理路径前按当前权限解析;无权路径不写入受限标题。 +- 提供按当前权限过滤的读取函数,未来消费者只能读取过滤后的结果,不能直接展示 localStorage 原值。 +- 权限被撤销后,过滤结果立即隐藏受限标题和链接;可同步清理失效记录,避免旧数据长期残留。 +- 不把最近访问改成服务端存储,也不在本任务新增入口或页面。 + +## 6. 单公司 UI + +后端模型保持不变。前端把 `LegalEntity` 解释为当前公司资料: + +- 0 条 active:显示「完善公司信息」初始化区。 +- 1 条 active:显示公司摘要和现有维护能力,隐藏多公司选择语义。 +- 多条 active:显示异常提示,不猜测默认公司,不自动清理。 + +企业银行账户页面复用同一查询: + +- 0 条:只显示空状态和去公司信息的链接。 +- 1 条:内部自动使用其 ID,页面只读展示名称。 +- 多条:禁用保存并提示异常。 + +暂不改变服务端 `legalEntityId` 请求字段,以保持 API、审计、付款快照和历史数据兼容。 + +## 7. 文档口径 + +`docs/产品口径.md` 当前编号到 R-5,因此新增 R-6,而不是留下缺号直接写 R-7。内容固化:单公司、内部 OA、非 SaaS、无公司切换;保留内部关系模型不等于对用户暴露多公司能力。 + +## 8. 兼容与回滚 + +- 无数据库迁移,无生产数据写入。 +- 回滚只需恢复前端注册表消费者和旧文案;API/数据不受影响。 +- 异常多公司场景保持 fail-closed,不会因前端自动选择错误公司影响付款账户。 +- 生产公司信息、付款账户和员工银行机构已通过既有路径完成配置;本任务只修复后续管理体验,不回读、不迁移也不改写这些真实资料。 + +## 9. 视觉与技能边界 + +项目要求页面视觉与交互以 `frontend-design` skill 裁决,但当前会话未提供该 skill。实施前若仍不可用,沿用现有 Topbar、Link、空状态、表单和管理卡片组件,不新增视觉体系;任何明显视觉重排需再次说明该限制。 diff --git a/.trellis/tasks/08-03-management-navigation-single-company-ux/implement.jsonl b/.trellis/tasks/08-03-management-navigation-single-company-ux/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-03-management-navigation-single-company-ux/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-03-management-navigation-single-company-ux/implement.md b/.trellis/tasks/08-03-management-navigation-single-company-ux/implement.md new file mode 100644 index 00000000..aa05a64e --- /dev/null +++ b/.trellis/tasks/08-03-management-navigation-single-company-ux/implement.md @@ -0,0 +1,64 @@ +# 管理导航闭环与单公司体验收敛实施计划 + +## 1. 实施前门禁 + +- [x] 用户审查并明确批准 PRD、设计和本实施计划后,才运行 `task.py start`。 +- [x] 运行 `trellis-before-dev`,读取 frontend、hyb-oa、项目治理和产品体验相关规约。 +- [x] 实施前再次检查 `frontend-design` skill 是否可用;不可用时只复用现有视觉组件,不自行发明新样式。 +- [x] 保护当前生产公司/银行资料;不连接生产、不写真实数据、不执行部署。 + +## 2. 共享管理路由 + +- [x] 建立管理路由注册表和最具体路径解析、父链、权限过滤函数。 +- [x] 迁移 `SystemSettingsPage` 的入口列表到共享注册表。 +- [x] 迁移 `NavLinks` 的管理选中判断并删除 `MANAGEMENT_ACTIVE_PATHS`。 +- [x] 让 `resolvePageMeta` 从共享注册表解析管理标题,补齐当前遗漏页面和人员档案详情。 +- [x] 让管理根入口权限从注册表派生,并让 resolver、面包屑和侧栏 active 接收当前权限;无权路径不披露受限元数据。 +- [x] 收口最近访问写入/读取:写入前校验当前权限,读取时按当前权限过滤并覆盖权限撤销场景。 +- [x] 增加注册表完整性、重复 key/path、父级循环、自然中文标题和权限字段测试。 + +## 3. Topbar 管理面包屑 + +- [x] 在 Topbar 命中管理子页时渲染 `管理 / 当前页`,管理首页不自指。 +- [x] 覆盖 `管理 / 人员 / 员工档案` 等二级父链、可点击链接和 `aria-current`。 +- [x] 验证直接深链、刷新、浏览器前进后退和窄屏换行,不依赖 location state。 +- [x] 保持非管理页面 Topbar 行为与最近访问记录兼容。 + +## 4. 单公司体验 + +- [x] 把主数据中的「法体」用户文案改为「公司信息」,保留 API/类型/数据库命名。 +- [x] 0/1/多条 active 公司信息分别渲染初始化、单公司维护和异常提示。 +- [x] 企业银行账户页在 0 条时提供公司信息入口,1 条时自动关联并只读显示,多条时阻断保存。 +- [x] 保持付款账户权限、完整账号输入、替换/停用、审计和通知调用不变。 + +## 5. 测试与检查 + +- [x] 增加共享 resolver、管理 landing、侧栏 active、Topbar breadcrumb 的聚焦测试。 +- [x] 增加无权深链不显示受限面包屑,以及权限撤销后最近访问不返回旧标题/链接的测试。 +- [x] 增加企业银行账户 0/1/多公司状态测试,断言无真实账号或 payload 进入证据。 +- [x] 运行 `cd client && npm run typecheck && npm run build`。 +- [x] 运行受影响前端测试及必要 Playwright 合成路径;不使用生产账号或真实银行资料。 +- [x] 运行 `git diff --check`、敏感内容、Trellis governance、process/docs 门禁。 + +## 6. 文档、审查与提交 + +- [x] 在 `docs/产品口径.md` 以 R-6 固化单公司内部 OA 边界。 +- [x] 若路由注册表形成稳定通用合同,用 `trellis-update-spec` 更新现有 frontend spec;不新建无必要文档。 +- [x] 只读复核路由覆盖、权限可见性、面包屑父链和 0/1/多公司 fail-closed 行为。 +- [x] 中文提交并引用 `management-navigation-single-company-ux`;不自动部署。 +- [ ] 另行批准生产部署后运行公网检查,只有通过后才通知用户线上验收。 + +## 7. 回滚点 + +- 共享路由迁移必须先有 resolver 测试,再删除旧数组;任一入口丢失时恢复消费者,不修改服务端。 +- 单公司 UI 只改变展示与默认选择;若异常场景处理不符合预期,回滚前端即可,`LegalEntity` 和付款账户数据不变。 + +## 8. 本地实施与验证记录 + +- 共享注册表已驱动管理首页、管理根权限、Router 权限、侧栏 active、Topbar 标题/面包屑和最近访问过滤。 +- 只读复核发现并修正 `/people/:employeeId` 缺少显式前端权限守卫的问题;服务端权限边界未改。 +- 企业银行账户页已覆盖零个、一个和异常多个启用公司状态;异常状态 fail-closed。 +- 聚焦 Playwright:8/8 通过;此前完整 PC 财务/管理回归:9/9 通过。 +- `npm run typecheck`、`npm run build`、路由清单门禁、Trellis validate/governance、process contract、docs audit、敏感内容和 `git diff --check` 均通过;docs audit 保留既有 22 个未登记文档警告,无错误。 +- 路由清单顺带补齐既有 `/mobile/payment-batches` 分类缺口,仅登记现有路由和现有测试,不改变产品行为。 +- 未部署、未连接生产、未写入真实公司或银行资料。 diff --git a/.trellis/tasks/08-03-management-navigation-single-company-ux/prd.md b/.trellis/tasks/08-03-management-navigation-single-company-ux/prd.md new file mode 100644 index 00000000..878a1065 --- /dev/null +++ b/.trellis/tasks/08-03-management-navigation-single-company-ux/prd.md @@ -0,0 +1,118 @@ +# 管理导航闭环与单公司体验收敛 + +## Goal + +让管理人员从「管理」进入任一配置页后始终知道当前位置并能一键返回管理首页,同时把管理入口、侧栏归属和页面标题收敛到同一份路由事实源;在此基础上按“单公司、内部 OA、非产品化”边界简化公司信息和企业银行账户配置,不让用户面对无业务价值的多公司选择。 + +## Background + +- 侧栏管理分组只有一个顶层入口:`client/src/features/navigation/schema.ts` 的 `ADMIN_NAV_ORDER` 仅包含 `/system/settings`。这符合“管理聚合入口”的产品方向,本任务不把每个配置页重新铺回侧栏。 +- 子页归属目前是重复且不完整的手工事实:`SystemSettingsPage.tsx` 维护管理首页入口;`NavLinks.tsx` 维护独立的 `MANAGEMENT_ACTIVE_PATHS`;`features/navigation/schema.ts` 的 `PAGE_TITLES` 又维护 Topbar 标题。三者已经漂移。 +- `/system/payment-accounts` 未加入 `MANAGEMENT_ACTIVE_PATHS`,因此当前页面不保持侧栏「管理」高亮。`PAGE_TITLES` 还缺少用户、部门、费用标准、报销模板、主数据、企业银行账户和通知渠道等多个管理页,缺失时可能回退为原始路径。 +- 管理子页没有统一父级面包屑。用户从聚合页进入配置页后,只能自行重新寻找侧栏「管理」,深链或刷新后更缺少上下文。 +- 本任务规划期间,生产曾因没有 `LegalEntity` 数据而使企业银行账户页面出现无选项的法人主体下拉框;管理员现已通过既有路径完成公司信息、付款账户和员工银行机构配置。该业务阻塞已经解除,但零公司状态仍需修复,避免以后初始化或测试环境再次走入死路。 +- 本系统只服务当前公司内部、小于 10 人团队,不作为产品发布,不支持多公司、多租户或公司切换。`LegalEntity` 仍作为付款账户、成本中心等数据的内部关联锚点保留,不因前端简化而重做数据库模型。 +- `docs/产品口径.md` 的权限口径目前编号到 R-5;单公司长期边界应作为下一条 R-6 固化,避免制造缺号的 R-7。 + +## Priority + +1. 高:管理导航闭环与共享路由事实源,覆盖每次管理页面访问。 +2. 中:企业银行账户空状态与唯一公司自动关联,避免配置死路。 +3. 低:公司信息单例化表达和「法体」自然中文改名;不阻塞当前一次性生产录入。 + +## Scope + +- 前端管理路由元数据、管理聚合页入口、PC 侧栏选中状态、Topbar 标题/面包屑和最近访问标题。 +- 主数据中的公司信息单例表达,以及企业银行账户页面在零个、一个或异常多个启用公司时的交互。 +- 与上述行为直接相关的前端类型、单元/组件测试和现有产品口径文档。 +- 不修改后端数据模型、权限、银行资料加密/审计、制单逻辑或生产数据;不包含付款详情结构统一。 + +## Requirements + +### R1 管理路由单一事实源 + +- 新建一份前端管理路由注册表,至少包含稳定 key、路径匹配规则、标题、父级、管理首页分组、图标、说明和权限要求。 +- 「管理」聚合页入口、侧栏「管理」选中判断、Topbar 页面标题/分组和管理面包屑必须从该注册表派生,不再分别维护 `ITEMS`、`MANAGEMENT_ACTIVE_PATHS` 和不完整的管理 `PAGE_TITLES`。 +- 注册表中的权限条件是路由元数据合同的一部分;管理根入口的权限集合从可见子项派生,不再在 `NavLinks.tsx` 单独维护另一份 `anyOf`。 +- React Router 的页面组件与权限边界继续在 `router/index.tsx` 显式声明;本任务不为追求配置化而把懒加载组件塞进产品元数据。 +- 注册表必须覆盖当前管理首页全部入口及必要详情层级,包括人员档案;新增管理页如果未登记,测试必须失败,而不是回退为原始路径后静默上线。 + +### R2 管理导航闭环 + +- PC 管理首页仍只占一个侧栏入口,不把低频配置页铺回主导航。 +- 任一管理子页的 Topbar 显示可点击父级路径,例如 `管理 / 企业银行账户`;点击「管理」固定回到 `/system/settings`,不依赖浏览器历史。 +- 人员档案等二级页面显示 `管理 / 人员 / 员工档案`;中间「人员」可返回人员列表。 +- 直接访问深链、刷新页面或从全局搜索进入时,面包屑和侧栏高亮仍正确。 +- 管理标题和父链解析必须接收当前用户权限;无权访问某个管理页时不得通过 Topbar 面包屑、父链或管理入口泄露该页面名称。 +- 管理首页本身只显示当前页标题,不显示自指的「返回管理」。 + +### R3 页面标题与侧栏状态 + +- `/system/payment-accounts` 及所有已登记管理子页均保持侧栏「管理」选中。 +- Topbar 不得把管理子页显示为原始 URL;管理页标题和分组必须与聚合页卡片一致。 +- 最近访问记录只写入当前有权访问的管理路由,并继续使用自然中文标题,不因注册表重构写入原始路径。 +- 读取最近访问时必须按当前权限重新过滤;用户权限被收回后,旧记录不得继续显示受限管理页面标题或链接。当前实现只有 localStorage 写入、尚无读取 UI,本任务修正写入/读取合同但不新建最近访问页面。 + +### R4 单公司长期边界 + +- 在 `docs/产品口径.md` 增加下一条可用编号 R-6:系统只服务当前公司内部,不做多公司、多租户、公司切换或面向外部客户的产品化配置。 +- 不新增 tenant/company switcher、跨公司权限、公司维度工作区或多公司初始化流程。 +- 保留 `LegalEntity` 表和现有关联作为内部账务/主数据锚点;本任务不重命名数据库表、不迁移外键、不删除历史能力。 +- 前端用户文案把「法体」统一改为「公司信息」或「公司」,不向普通管理员暴露会计/产品化术语。 + +### R5 公司信息单例表达 + +- 公司信息为空时,主数据页显示一次性初始化表单和明确说明。 +- 正常存在一条启用公司信息时,以单公司查看/维护方式展示,不提供公司切换器或鼓励继续创建第二家公司。 +- 若历史或测试环境异常存在多条启用记录,不静默选择,不自动合并或删除;显示管理员可理解的异常提示,并保留现有数据供单独治理。 +- 当前生产通过既有「主数据 → 法体」录入不被本任务阻塞;本任务不执行任何生产公司资料写入。 + +### R6 企业银行账户配置不走死路 + +- 没有公司信息时,不显示无解释的空法人主体下拉框;显示「请先完善公司信息」和可点击入口。 +- 恰有一条启用公司信息时自动关联并只读显示公司名称,不要求用户重复选择。 +- 异常存在多条启用公司信息时禁止猜测默认值,明确提示先处理公司信息。 +- 公司银行账户、员工银行资料、权限、加密、审计和通知合同保持不变;本任务不读取、回填或迁移真实完整账号。 + +### R7 范围与设备 + +- 本任务服务 PC 管理配置体验;按照现有产品规则,不新建复杂移动端管理台。 +- 窄屏通过现有 Topbar 菜单和可换行面包屑保持可用,但不新增移动专属公司/银行配置页面。 +- 付款详情结构统一、处理进度时间线、粘性操作区和银行制单业务逻辑不纳入本任务。 + +## Acceptance Criteria + +- [x] 管理首页全部可见入口、侧栏归属、Topbar 标题和面包屑来自同一管理路由注册表;不存在第二份手工管理路径列表。 +- [x] 自动化逐项证明当前管理入口均有标题、权限、父级和路径匹配;新增未登记管理页会导致测试失败。 +- [x] 无权限用户看不到对应管理入口;深链访问期间 Topbar 不渲染受限页面名称或面包屑。 +- [x] 所有管理页面均显示 `管理 / 当前页面`,且「管理」可返回聚合页;员工档案显示 `管理 / 人员 / 员工档案`。 +- [x] 直接深链、刷新和前进后退后父链仍正确;管理首页不显示自指面包屑。 +- [x] 所有管理子页保持侧栏「管理」高亮,`/system/payment-accounts` 回归测试覆盖此前遗漏。 +- [x] Topbar 与最近访问记录不再为管理子页回退显示原始路径。 +- [x] 最近访问按当前权限过滤:撤销权限后,既有 localStorage 记录不再向用户显示受限页面标题或链接;聚焦测试覆盖权限变更场景。 +- [x] 主数据 UI 使用「公司信息」而不是「法体」,正常单公司状态不出现公司切换器或新增第二公司的主导入口。 +- [x] 零公司信息时,企业银行账户页提供明确说明和「完善公司信息」入口;一条时自动关联;多条异常时不静默选择。 +- [x] `LegalEntity` 数据模型、现有外键、付款账户加密/审计/通知和生产数据保持不变,无数据库迁移、无真实资料写入。 +- [x] `docs/产品口径.md` 以连续编号 R-6 固化单公司内部 OA 边界。 +- [x] 前端相关单元/组件测试、TypeScript 检查和生产构建通过;Trellis、敏感内容和 diff 门禁通过。 +- [x] 只有部署与公网检查通过后才通知用户线上验收;生产公司资料录入另由管理员执行。 + +## Test Plan + +- 注册表契约测试:逐项校验稳定 key、唯一匹配、父级、标题、权限和管理首页可见项;覆盖未登记管理入口的失败用例。 +- 导航组件测试:覆盖管理首页、企业银行账户、人员列表与员工档案深链,验证侧栏高亮、Topbar 标题、面包屑链接和最近访问中文标题;同一路径分别验证有权和无权状态。 +- 权限变更测试:先以有权状态写入最近访问,再撤销权限读取,断言受限页面标题和链接均不返回;无权深链不渲染受限面包屑。 +- 公司状态组件测试:分别覆盖零个、一个和多个启用公司;验证引导入口、自动关联、只读公司名称和异常 fail-closed 行为。 +- 回归检查:确认管理页权限边界及 React Router 显式路由未改变,付款账户敏感字段不会进入测试快照或日志。 +- 前端门禁:运行相关 Vitest、`npm run typecheck` 与 `npm run build`。 +- 治理门禁:运行 Trellis task validate、Trellis governance、敏感内容检查(如当前流程要求)与 `git diff --check`。 +- 面向线上用户的验收仅在另行批准部署且公网检查通过后进行;生产配置和真实数据写入不属于自动化测试。 + +## Out of Scope + +- 多租户、多公司切换、跨公司数据隔离、集团组织、合并报表或 SaaS 产品化。 +- 重命名或删除 `LegalEntity` 数据模型、外键迁移、历史数据合并和生产数据修复。 +- 把所有管理子页重新放进侧栏,或建设复杂的二级侧栏导航。 +- 移动端管理配置台、付款详情结构统一、审批/处理进度重构和银行制单业务变更。 +- 代替管理员录入公司全称、税号、银行账户或员工收款资料。 +- 取消或重建既有付款批次、生成制单、网银导入、上传回单或登记付款完成;这些属于独立的生产操作与制单验收。 diff --git a/.trellis/tasks/08-03-management-navigation-single-company-ux/task.json b/.trellis/tasks/08-03-management-navigation-single-company-ux/task.json new file mode 100644 index 00000000..af103d7f --- /dev/null +++ b/.trellis/tasks/08-03-management-navigation-single-company-ux/task.json @@ -0,0 +1,26 @@ +{ + "id": "management-navigation-single-company-ux", + "name": "management-navigation-single-company-ux", + "title": "管理导航闭环与单公司体验收敛", + "description": "统一管理首页、侧栏高亮与顶部面包屑的路由事实源,并按单公司内部 OA 口径简化公司信息和企业银行账户配置。", + "status": "in_progress", + "dev_type": null, + "scope": "client 管理导航共享事实源、公司信息单例体验、企业银行账户空状态/自动关联及产品边界文档", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-03", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 117c38b1..66da0205 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -261,11 +261,11 @@ export function mapApiErrorMessage( } if (details?.legalEntityId === "not_found") { - return "所选法体不存在。"; + return "所选公司不存在。"; } if (details?.legalEntityId === "archived") { - return "所选法体已归档,无法继续使用。"; + return "所选公司已归档,无法继续使用。"; } if (details?.ownerEmployeeId === "not_found") { @@ -289,11 +289,11 @@ export function mapApiErrorMessage( } if (details?.parentId === "cross_legal_entity_not_allowed") { - return "上级成本中心必须与当前法体一致。"; + return "上级成本中心必须与当前公司一致。"; } if (details?.legalEntity === "has_cost_centers") { - return "当前法体下仍存在未归档的成本中心,无法归档。"; + return "当前公司下仍存在未归档的成本中心,无法归档。"; } if (details?.costCenter === "has_children") { @@ -301,7 +301,7 @@ export function mapApiErrorMessage( } if (details?.holidayCalendar === "bound_as_default") { - return `当前日历仍被法体 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`; + return `当前日历仍被公司 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`; } if (details?.date === "duplicate") { diff --git a/client/src/components/master-data/HolidayCalendarTab.tsx b/client/src/components/master-data/HolidayCalendarTab.tsx index 138e312b..e9b8d739 100644 --- a/client/src/components/master-data/HolidayCalendarTab.tsx +++ b/client/src/components/master-data/HolidayCalendarTab.tsx @@ -258,7 +258,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities } value={legalEntityFilter} onChange={(event) => setLegalEntityFilter(event.target.value)} > - + {legalEntities .filter((item) => !item.archivedAt) .map((item) => ( @@ -302,7 +302,7 @@ export function HolidayCalendarTab({ canManage, includeArchived, legalEntities } />
- + 编码 / 名称 - 法体 + 公司 联系人 税号 / 账期 状态 diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index d77336c2..12323f82 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -8,6 +8,10 @@ import { BACKUP_NAV_ORDER, MAIN_NAV_ORDER, } from "../../features/navigation/schema"; +import { + isManagementPath, + MANAGEMENT_ROOT_PERMISSIONS, +} from "../../features/navigation/managementRoutes"; import { cn } from "../../lib/utils"; export type NavItem = { @@ -27,7 +31,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/weekly-reports", label: "周报", icon: "article" }, { to: "/announcements", label: "公告", icon: "campaign" }, { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] }, - { to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] }, + { to: "/system/settings", label: "管理", icon: "settings", anyOf: MANAGEMENT_ROOT_PERMISSIONS }, { to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, { to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] }, { to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, @@ -46,24 +50,6 @@ const ACCESSIBLE_LABELS: Record = { "/assets": "资产", }; -const MANAGEMENT_ACTIVE_PATHS = [ - "/system/settings", - "/system/workspace", - "/system/workflow-definitions", - "/system/feature-flags", - "/system/approval-rules", - "/system/expense-standards", - "/system/reimbursement-templates", - "/system/master-data", - "/system/notification-channels", - "/system/users", - "/system/departments", - "/system/cutover", - "/people", - "/people/stats", - "/organization", - "/audit-logs", -]; const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"]; const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"]; @@ -79,15 +65,15 @@ type ResolvedNavItem = { icon: string; }; -function isGroupedActive(item: ResolvedNavItem, pathname: string) { +function isGroupedActive(item: ResolvedNavItem, pathname: string, permissions: string[]) { if (item.href === "/system/settings") { - return MANAGEMENT_ACTIVE_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); + return isManagementPath(pathname, permissions); } return pathname === item.href || pathname.startsWith(`${item.href}/`); } -function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () => void) { +function renderNavItem(item: ResolvedNavItem, pathname: string, permissions: string[], onNavigate?: () => void) { const accessibleLabel = ACCESSIBLE_LABELS[item.href] ?? item.label; return ( @@ -98,11 +84,11 @@ function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () title={accessibleLabel} onClick={() => onNavigate?.()} className={({ isActive }) => - cn("group nav-link-item", isActive || isGroupedActive(item, pathname) ? "nav-link-item--active" : "nav-link-item--idle") + cn("group nav-link-item", isActive || isGroupedActive(item, pathname, permissions) ? "nav-link-item--active" : "nav-link-item--idle") } > {({ isActive }) => { - const active = isActive || isGroupedActive(item, pathname); + const active = isActive || isGroupedActive(item, pathname, permissions); return ( <> @@ -116,19 +102,19 @@ function renderNavItem(item: ResolvedNavItem, pathname: string, onNavigate?: () ); } -function renderPrimarySection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) { +function renderPrimarySection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) { if (!items.length) { return null; } return (
-
{items.map((item) => renderNavItem(item, pathname, onNavigate))}
+
{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}
); } -function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavigate?: () => void) { +function renderBackupSection(items: ResolvedNavItem[], pathname: string, permissions: string[], onNavigate?: () => void) { if (!items.length) { return null; } @@ -142,7 +128,7 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavig 更多 expand_more -
{items.map((item) => renderNavItem(item, pathname, onNavigate))}
+
{items.map((item) => renderNavItem(item, pathname, permissions, onNavigate))}
); } @@ -202,6 +188,20 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation }; } +function normalizeManagementRoot( + sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }, + permissions: string[], +) { + const canAccessManagement = hasAnyPermission(permissions, MANAGEMENT_ROOT_PERMISSIONS); + const withoutManagement = sections.admin.filter((item) => item.href !== "/system/settings"); + return { + ...sections, + admin: canAccessManagement + ? [{ key: "/system/settings", href: "/system/settings", label: "管理", icon: "settings" }, ...withoutManagement] + : withoutManagement, + }; +} + export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { const { me, permissions } = useAuth(); const location = useLocation(); @@ -211,15 +211,16 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { staleTime: 60_000, }); - const sections = navigationQuery.data?.sections + const baseSections = navigationQuery.data?.sections ? normalizeSections(navigationQuery.data.sections) : buildFallbackItems(permissions, me?.roleCodes ?? []); + const sections = normalizeManagementRoot(baseSections, permissions); const primaryItems = [...sections.main, ...sections.admin]; return ( ); } diff --git a/client/src/components/navigation/Topbar.tsx b/client/src/components/navigation/Topbar.tsx index f2eec6e5..3ca7c759 100644 --- a/client/src/components/navigation/Topbar.tsx +++ b/client/src/components/navigation/Topbar.tsx @@ -5,6 +5,7 @@ import type { NotificationItem } from "../../api/notifications"; import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../../api/notifications"; import { useAuth } from "../../auth/AuthContext"; import { resolvePageMeta } from "../../features/navigation/schema"; +import { resolveManagementBreadcrumbs } from "../../features/navigation/managementRoutes"; import { resolveNotificationTargetLink, formatNotificationRelativeTime } from "../../lib/notificationLinks"; import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; import { isWecomWebView } from "../../lib/runtimeContext"; @@ -45,7 +46,7 @@ function BellIcon() { } export function Topbar() { - const { me, logout } = useAuth(); + const { me, logout, permissions } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -53,7 +54,9 @@ export function Topbar() { const [searchOpen, setSearchOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false); const wecomEntry = isWecomWebView(); - const pageMeta = resolvePageMeta(location.pathname); + const pageMeta = resolvePageMeta(location.pathname, permissions); + const managementBreadcrumbs = resolveManagementBreadcrumbs(location.pathname, permissions); + const showManagementBreadcrumbs = managementBreadcrumbs.length > 1; const unreadQuery = useQuery({ enabled: Boolean(me), @@ -165,7 +168,28 @@ export function Topbar() {
-

{pageMeta.title}

+ {showManagementBreadcrumbs ? ( + + ) : ( +

{pageMeta.title}

+ )} {pageMeta.group} diff --git a/client/src/components/travel/TravelReconciliationTab.tsx b/client/src/components/travel/TravelReconciliationTab.tsx index a57d3f9b..ac6a2eef 100644 --- a/client/src/components/travel/TravelReconciliationTab.tsx +++ b/client/src/components/travel/TravelReconciliationTab.tsx @@ -154,7 +154,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav const createMutation = useMutation({ mutationFn: async () => { if (!createLegalEntityId || !createPeriodStart || !createPeriodEnd) { - throw new Error('请先填写法体与对账周期'); + throw new Error('请先填写公司与对账周期'); } return createTravelReconciliationBatch({ legalEntityId: createLegalEntityId, @@ -272,7 +272,7 @@ export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: Trav
- +
- +
- + {formScopeType === "department_legacy" ? ( @@ -547,7 +547,7 @@ export function BudgetsPage() { setFormCostCenterId(""); }} > - + {(legalEntitiesQuery.data?.items ?? []).map((legalEntity) => ( + {(legalEntitiesQuery.data?.items ?? []).map((item) => (
diff --git a/client/src/pages/InventoryChecksPage.tsx b/client/src/pages/InventoryChecksPage.tsx index 09884dce..2aedf120 100644 --- a/client/src/pages/InventoryChecksPage.tsx +++ b/client/src/pages/InventoryChecksPage.tsx @@ -38,7 +38,7 @@ function scopeLabel(scopeType: InventoryCheckScopeType) { case "all": return "全量盘点"; case "legal_entity": - return "法体盘点"; + return "公司盘点"; case "cost_center": return "成本中心盘点"; case "location": @@ -278,7 +278,7 @@ export function InventoryChecksPage() { setScopeRefId(""); }}> - + diff --git a/client/src/pages/MasterDataPage.tsx b/client/src/pages/MasterDataPage.tsx index 4c42c008..0b6fa7ee 100644 --- a/client/src/pages/MasterDataPage.tsx +++ b/client/src/pages/MasterDataPage.tsx @@ -30,6 +30,7 @@ import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; +import { resolveSingleCompanyState } from "../features/company/singleCompany"; const DOCUMENT_ENTITY_TYPE_OPTIONS: Array<{ value: DocumentCategoryEntityType; label: string }> = [ { value: "reimbursement", label: "报销" }, @@ -132,6 +133,12 @@ export function MasterDataPage() { }), }); + const activeCompaniesQuery = useQuery({ + queryKey: ["master-data", "legal-entities", "active-company-state"], + enabled: canView, + queryFn: () => fetchLegalEntities({ enabled: true }), + }); + const costCentersQuery = useQuery({ queryKey: [ "master-data", @@ -167,6 +174,8 @@ export function MasterDataPage() { }); const legalEntities = legalEntitiesQuery.data?.items ?? []; + const activeCompanies = activeCompaniesQuery.data?.items ?? []; + const companyState = resolveSingleCompanyState(activeCompanies); const costCenters = costCentersQuery.data?.items ?? []; const documentCategories = documentCategoriesQuery.data?.items ?? []; const masterDataStats = { @@ -211,7 +220,7 @@ export function MasterDataPage() { setLegalEntityCurrency("CNY"); setLegalEntityTaxNo(""); setError(""); - toast.success("法体已创建"); + toast.success("公司信息已创建"); }, onError: (mutationError) => { const message = resolveError(mutationError); @@ -312,7 +321,7 @@ export function MasterDataPage() { setIncludeArchived((value) => !value)}> {includeArchived ? "隐藏归档" : "显示归档"} @@ -325,7 +334,7 @@ export function MasterDataPage() { ) : null}
-
法体
{masterDataStats.legalEntities}
+
公司信息
{masterDataStats.legalEntities}
成本中心
{masterDataStats.costCenters}
单据分类
{masterDataStats.documentCategories}
归档项
{masterDataStats.archived}
@@ -334,7 +343,7 @@ export function MasterDataPage() { setTab(value as typeof tab)}> - 法体 + 公司信息 成本中心 单据分类 日历 @@ -344,7 +353,7 @@ export function MasterDataPage() {
-

法体筛选

+

公司信息筛选

- + {legalEntities .filter((item) => !item.archivedAt) .map((item) => ( @@ -561,7 +593,7 @@ export function MasterDataPage() { 编码 名称 - 所属法体 + 所属公司 上级 负责人 状态 diff --git a/client/src/pages/MobileAssetScanPage.tsx b/client/src/pages/MobileAssetScanPage.tsx index 1d6ab50c..2ad91f6b 100644 --- a/client/src/pages/MobileAssetScanPage.tsx +++ b/client/src/pages/MobileAssetScanPage.tsx @@ -102,7 +102,7 @@ function assetTypeLabel(assetType: AssetItem["assetType"]) { function inventoryScopeLabel(inventoryCheck: InventoryCheck) { if (inventoryCheck.scopeType === "all") return "全量盘点"; - if (inventoryCheck.scopeType === "legal_entity") return "法体盘点"; + if (inventoryCheck.scopeType === "legal_entity") return "公司盘点"; if (inventoryCheck.scopeType === "cost_center") return "成本中心盘点"; return "位置盘点"; } @@ -559,7 +559,7 @@ export function MobileAssetScanPage() {
类型:{assetTypeLabel(selectedAsset.assetType)}
状态:{statusLabel(selectedAsset.status)}
-
法体:{selectedAsset.legalEntity?.name ?? "-"}
+
公司:{selectedAsset.legalEntity?.name ?? "-"}
成本中心:{selectedAsset.costCenter?.name ?? "-"}
位置:{selectedAsset.location ?? "-"}
归属人:{selectedAsset.assignee?.name ?? "未分配"}
diff --git a/client/src/pages/MobilePurchaseRequestDetailPage.tsx b/client/src/pages/MobilePurchaseRequestDetailPage.tsx index 7a7f83f6..f07380f4 100644 --- a/client/src/pages/MobilePurchaseRequestDetailPage.tsx +++ b/client/src/pages/MobilePurchaseRequestDetailPage.tsx @@ -52,7 +52,7 @@ function resolveMobilePurchaseEvidence(detail: { projectId: string | null; }) { if (!detail.supplierId) return "待补供应商"; - if (!detail.legalEntityId || !detail.costCenterId) return "待补法体 / 成本中心"; + if (!detail.legalEntityId || !detail.costCenterId) return "待补公司 / 成本中心"; if (!detail.documentCategoryId) return "待补单据分类"; if (!detail.budgetPoolId) return "预算池未匹配"; if (!detail.projectId) return "未关联项目"; @@ -167,7 +167,7 @@ export function MobilePurchaseRequestDetailPage() {
{formatDate(detail.expectedDeliveryDate)}
-
法体
+
公司
{detail.legalEntity?.name ?? "-"}
diff --git a/client/src/pages/MobilePurchaseRequestsPage.tsx b/client/src/pages/MobilePurchaseRequestsPage.tsx index de3deda4..1bd114c0 100644 --- a/client/src/pages/MobilePurchaseRequestsPage.tsx +++ b/client/src/pages/MobilePurchaseRequestsPage.tsx @@ -49,7 +49,7 @@ function resolvePurchaseListNextStep(status: PurchaseRequestStatus) { function resolvePurchaseListEvidence(item: PurchaseRequestSummary) { if (!item.supplierId) return "待补供应商"; - if (!item.legalEntityId || !item.costCenterId) return "待补法体 / 成本中心"; + if (!item.legalEntityId || !item.costCenterId) return "待补公司 / 成本中心"; if (!item.documentCategoryId) return "待补单据分类"; if (!item.budgetPoolId) return "预算池未匹配"; if (!item.projectId) return "未关联项目"; diff --git a/client/src/pages/PaymentAccountsPage.tsx b/client/src/pages/PaymentAccountsPage.tsx index c8d6c344..165fa330 100644 --- a/client/src/pages/PaymentAccountsPage.tsx +++ b/client/src/pages/PaymentAccountsPage.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -14,8 +15,9 @@ import { import { fetchLegalEntities } from "../api/masterData"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; -import { hasPermission } from "../auth/permissions"; +import { hasAnyPermission, hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; +import { resolveSingleCompanyState } from "../features/company/singleCompany"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; @@ -33,6 +35,7 @@ export function PaymentAccountsPage() { const { permissions } = useAuth(); const canManage = hasPermission(permissions, "payment.account.manage"); const canReveal = hasPermission(permissions, "payment.account.read_full"); + const canManageCompanyInformation = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]); const [editing, setEditing] = useState(null); const [legalEntityId, setLegalEntityId] = useState(""); const [bankInstitutionId, setBankInstitutionId] = useState(""); @@ -51,9 +54,19 @@ export function PaymentAccountsPage() { enabled: canManage, }); + const activeCompanies = legalEntitiesQuery.data?.items ?? []; + const companyState = resolveSingleCompanyState(activeCompanies); + const singleCompany = companyState.kind === "single" ? companyState.company : null; + + useEffect(() => { + if (!editing && singleCompany) { + setLegalEntityId(singleCompany.id); + } + }, [editing, singleCompany]); + const resetForm = () => { setEditing(null); - setLegalEntityId(""); + setLegalEntityId(singleCompany?.id ?? ""); setBankInstitutionId(""); setAccountName(""); setAccountNumber(""); @@ -93,17 +106,56 @@ export function PaymentAccountsPage() { const accounts = accountsQuery.data?.items ?? []; return ( - +
- {canManage ? ( + {canManage && legalEntitiesQuery.isLoading ? ( + +
正在读取公司信息...
+
+ ) : null} + + {canManage && legalEntitiesQuery.isError ? ( + +
公司信息读取失败,请刷新后重试。
+
+ ) : null} + + {canManage && !legalEntitiesQuery.isLoading && !legalEntitiesQuery.isError && companyState.kind === "empty" ? ( + +
+

企业银行账户必须关联当前公司。请先完善公司信息,再回来录入付款账户。

+ {canManageCompanyInformation ? ( + + ) : ( +

请联系管理员先完善公司信息。

+ )} +
+
+ ) : null} + + {canManage && companyState.kind === "multiple" ? ( + +
+

检测到多条启用的公司信息,系统不会猜测付款账户应关联哪一条。请先处理公司信息。

+ {canManageCompanyInformation ? ( + + ) : null} +
+
+ ) : null} + + {canManage && singleCompany ? (
- - + +
+ {editing?.legalEntity.name ?? singleCompany.name} +
@@ -158,7 +210,7 @@ export function PaymentAccountsPage() { { if (!open) setDisableTarget(null); }}> - 停用企业银行账户停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗? + 停用企业银行账户停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "当前公司"} 的当前账户吗? diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index 0e99a510..160a7833 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -178,7 +178,7 @@ function resolvePurchaseEvidenceHint(item: { }) { const missingFields = [ item.supplierId ? null : "供应商", - item.legalEntityId ? null : "法体", + item.legalEntityId ? null : "公司", item.costCenterId ? null : "成本中心", item.documentCategoryId ? null : "单据分类", ].filter(Boolean); @@ -590,7 +590,7 @@ export function PurchaseRequestsPage() { throw new Error("提交前请选择供应商"); } if (!legalEntityId) { - throw new Error("提交前请选择法体"); + throw new Error("提交前请选择公司"); } if (!costCenterId) { throw new Error("提交前请选择成本中心"); @@ -991,7 +991,7 @@ export function PurchaseRequestsPage() { setPage(1); }} > - + {(legalEntitiesQuery.data?.items ?? []).map((item) => (
- + - + {(legalEntitiesQuery.data?.items ?? []).map((item) => ( ))} @@ -828,7 +828,7 @@ export function TravelRequestsPage() {

{detail.budgetPool?.name ?? "待提交时自动解析"}

-

法体

+

公司

{detail.legalEntity?.name ?? "-"}

@@ -1030,7 +1030,7 @@ function TravelRequestForm(props: TravelRequestFormProps) { setEndDate(event.target.value)} readOnly={readOnly} />
- +