完成 Wave4-5 财务与管理页刷新(05-29-frontend-ux-wave4-5-finance-admin-refresh)

这个提交包含在:
2026-05-29 09:12:27 +08:00
父节点 1e1e0fecd6
当前提交 bfb1cb6e65
共修改 11 个文件,包含 924 行新增和 208 行删除
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify changed pages follow frontend-design plus shared finance/admin surface conventions"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Verify Trellis flow, approvals and task evidence are complete"}
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "Verify evidence paths and summaries avoid sensitive data and absolute local paths"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Verify admin/finance evidence does not expose secrets, full accounts, payloads or sensitive business content"}
{"file": ".trellis/tasks/05-29-frontend-ux-wave4-5-finance-admin-refresh/research/scope-audit.md", "reason": "Verify implementation matches the agreed Wave4 refresh and Wave5 gap-fill boundary"}
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Wave4-5 frontend design authority, shared finance/admin surface conventions and verification requirements"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task contract, approval and commit boundaries for non-trivial implementation"}
{"file": ".trellis/spec/hyb-oa/deployment-trialrun.md", "reason": "Public deployment and live readiness gates if the frontend changes are released"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Finance/admin pages include payment, export, audit and WeCom-related privacy boundaries"}
{"file": ".trellis/tasks/05-29-frontend-ux-wave4-5-finance-admin-refresh/research/scope-audit.md", "reason": "Task-specific scope audit distinguishing historical Wave4 from current Wave5 gaps"}
@@ -0,0 +1,96 @@
# Implementation Plan
## Current Decision
This task completes the remaining frontend wave work by combining:
1. Wave4 finance/operations refresh after the newer Wave0-3 baseline.
2. Wave5 admin/config gap fill for pages that were not fully covered by the historical Wave4 task.
It is not a blank rebuild of finance/admin pages.
## Recommended Implementation Order
1. Baseline audit:
- Compare current finance/admin pages against `frontend-design`, Wave0-3 shell/header conventions and historical Wave4 classes.
- Identify pages that need code changes versus pages that only need test coverage.
2. Wave5 gap pages:
- `ApprovalRulesPage`
- `PeoplePage`
- `OrganizationPage`
- `WorkspaceSetupPage`
3. Wave4 refresh pages:
- `PaymentBatchesPage`
- `ApOperationsPage`
- `InvoicePoolPage`
4. Regression surfaces:
- `UserManagementPage`
- `MasterDataPage`
- `NotificationChannelsPage`
- `SystemSettingsPage`
- `FeatureFlagsPage`
- `WorkflowDefinitionsPage`
- `AuditLogsPage`
5. Test baseline:
- Extend or replace the existing focused Wave4 Playwright spec so it covers Wave4 plus Wave5 gaps.
- Keep public deployment gated by explicit approval.
## Guardrails
- No backend, schema, permission, payment-state, approval-rule semantic, WeCom or env changes.
- Do not remove existing business actions.
- Payment, export, sync, enable/disable and config-publish actions keep original confirmation and permission behavior.
- Do not add a new design language; reuse existing finance/admin/ops classes.
- Do not claim user-test readiness until public deployment and public checks pass.
## Local Verification Plan
```bash
cd client && npm run typecheck
cd client && npm run build
cd client && npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list
node scripts/qa/check-ui-design-rules.mjs --strict
python3 ./.trellis/scripts/task.py validate 05-29-frontend-ux-wave4-5-finance-admin-refresh
node scripts/qa/check-trellis-governance.mjs
git diff --check
```
## Completed Implementation
2026-05-29:
- Refreshed `ApprovalRulesPage` with Wave5 admin summary metrics, a command-oriented creation panel and a cleaner rule-list surface.
- Refreshed `PeoplePage` to use the shared workspace/header baseline, finance/admin metrics, WeCom HR command surface, filter panel, batch-action panel and focused table container.
- Refreshed `OrganizationPage` to use the shared management shell, organization summary metrics and admin command panels while keeping hierarchy readability.
- Refreshed `WorkspaceSetupPage` to use the shared management shell, module visibility metrics, role preset tiles and a focused module table with Chinese visibility labels.
- Extended `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts` from historical Wave4 coverage to Wave4-5 coverage.
- Kept Wave4 finance pages on the existing approved surface; no backend or business-state changes were made.
## Local Verification Result
- `cd client && npm run typecheck` passed.
- `cd client && npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list` passed: 3 tests.
- `cd client && npm run build` passed.
- `node scripts/qa/check-ui-design-rules.mjs --strict` passed with 0 findings.
- `python3 ./.trellis/scripts/task.py validate 05-29-frontend-ux-wave4-5-finance-admin-refresh` passed.
- `node scripts/qa/check-trellis-governance.mjs` passed after task metadata was completed.
- `git diff --check` passed.
## Spec Update Decision
No `.trellis/spec/` update is needed for this implementation. The task applied existing frontend conventions (`ListWorkspace`, `WorkspaceHeader`, `finance-status-strip`, `admin-command-panel`, `PrimaryTable`) and did not introduce a new reusable frontend contract, API signature, env key, schema, permission rule or deployment behavior.
## Deployment Plan
Only after explicit approval:
```bash
SYNC_ENV=0 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash deploy/trialrun/deploy.sh
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh
OAOPS1_REPORT_DIR=.trellis/tasks/05-29-frontend-ux-wave4-5-finance-admin-refresh/research/postdeploy-<date>-wave4-5-public \
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh
```
## Spec Update Expectation
Only update `.trellis/spec/` if implementation introduces a reusable frontend convention beyond the existing finance/admin surfaces. If the task only applies existing conventions, record that no spec update is needed.
@@ -0,0 +1,170 @@
# 前端体验 Wave4-5 财务与管理页刷新
## Goal
在 Wave0-3 新全局体验基线已完成并部署后,继续完成剩余前端任务:复审 Wave4 财务/运营页,并补齐 Wave5 管理配置页缺口,让财务、管理员和负责人在低频但高风险页面中仍看到统一、克制、可扫描、可操作的 OA 工作界面。
## What I Already Know
- 用户要求“继续完成所有任务”,上下文指向 Wave4 和 Wave5 未完全按新基线收口。
- 历史任务 `05-27-pc-finance-admin-visual-wave4` 已完成并部署验证过,覆盖付款批次、AP、票据池和部分 C 类管理页。
- 历史 Wave4 发生在最新 Wave0-3 新基线之前,因此本任务不是从零重做,而是做“新基线后的复审 + 缺口补齐”。
- Wave5 原计划为 `admin-config`;当前没有独立 Wave5 任务,且一部分管理页已被历史 Wave4 合并覆盖。
- `frontend-design` skill 是视觉与交互裁决来源;Trellis、隐私、部署和真实业务边界仍按项目 specs 和 docs 执行。
- 面向用户可测试的页面改动,只有部署和公网检查通过后才能告知用户测试。
## Product Direction
- Tone:quiet finance/admin command surface,克制、紧凑、风险清晰。
- 财务页优先回答:待处理是什么、异常在哪里、下一步动作是什么、是否会产生付款/导出/审计后果。
- 管理页优先回答:当前配置状态是什么、改动影响范围是什么、危险动作如何确认和追溯。
- 不做营销式页面、不新增装饰 hero、不降低高密信息页的扫描效率。
## Scope
### Wave4 Refresh:财务与运营页
复审并必要调整:
- `/payment-batches`
- `/ap-operations`
- `/invoice-pool`
目标:
- 对齐 Wave0-3 的 shell、header、command surface、状态摘要和表格/列表密度。
- 保留现有付款批次、复核、回单、导出、票据上传、票据绑定、OCR/验真、风险提示和错误处理。
- 不新增真实支付渠道,不改变付款状态机,不写 ERP。
### Wave5 Gap Fill:管理配置页
优先补齐尚未完全纳入新基线或历史 Wave4 覆盖不足的页面:
- `/system/approval-rules`
- `/people`
- `/organization`
- `/system/workspace`
同步复审已覆盖但需要防回退的页面:
- `/system/users`
- `/system/master-data`
- `/system/notification-channels`
- `/system/settings`
- `/system/feature-flags`
- `/system/workflow-definitions`
- `/audit-logs`
目标:
- 管理配置页统一 `ListWorkspace`/`WorkspaceHeader`、状态摘要、命令区、主表格/主列表和危险操作层级。
- 降低解释性文案,但保留配置影响、权限、审计、不可逆动作和外部集成边界提示。
- 组织/人员页面避免“普通资料浏览”和“管理配置”风格割裂。
## Requirements
### R1. Wave4 不重复重写
- 历史 Wave4 已通过的财务页只做 gap-driven refresh。
- 不为了视觉统一改动 API、状态机、权限或付款动作。
- 若页面已符合新基线,只更新测试覆盖或验收记录,不强行改代码。
### R2. Wave5 管理配置补齐
- 审批规则页面必须突出规则数量、启用状态、匹配顺序、影响范围和“只影响新单据”的边界。
- 人员/组织/工作区页面必须对齐管理页骨架,减少独立 `card-std` 拼接感。
- 配置、导出、同步、停用、启用、写入企业微信或影响导航的动作必须保留原确认、权限和错误提示。
### R3. 视觉与交互一致性
- 复用 `finance-status-strip`、`finance-metric-card`、`finance-command-panel`、`admin-command-panel`、`admin-tile-grid`、`ops-table-focus` 等现有共享类。
- 不新增一套新的页面局部设计系统。
- 按 `frontend-design` 做实际页面质量判断,避免啰嗦、按钮挤压、说明堆叠和风格漂移。
### R4. 验证与发布
- 本地必须通过 typecheck/build、UI design rules、focused Playwright、Trellis governance 和 `git diff --check`。
- Focused Playwright 必须覆盖 Wave4 财务页和 Wave5 管理缺口页。
- 部署给用户测试前必须跑部署脚本、公网 readiness 和 OAOPS1 只读巡检。
## Acceptance Criteria
- [x] Wave4 财务/运营页完成新基线复审;必要改动已实施,未发现回退。
- [x] Wave5 管理配置缺口页完成统一骨架、状态摘要、命令区和主内容收敛。
- [x] 历史 Wave4 覆盖页完成防回退视觉/交互检查。
- [x] 没有改变后端 API、权限模型、付款状态机、审批规则语义、数据库 schema、企业微信配置或公网真实数据。
- [x] `cd client && npm run typecheck` 通过。
- [x] `cd client && npm run build` 通过。
- [x] Focused Playwright 覆盖 Wave4-5 页面并通过。
- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 通过。
- [x] `python3 ./.trellis/scripts/task.py validate 05-29-frontend-ux-wave4-5-finance-admin-refresh` 通过。
- [x] `node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check` 通过。
- [ ] 若部署给用户测试,已完成公网部署与公网检查并记录证据。
## Implementation Result
2026-05-29 本地实施完成:
- Wave5 缺口页完成新基线补齐:
- `/system/approval-rules` 新增规则状态摘要、创建命令区和规则列表命令区,突出启用数、优先级和“只影响新单据”边界。
- `/people` 迁移到 `ListWorkspace`/`WorkspaceHeader`,新增人员状态摘要、企微 HR 主数据命令区、筛选命令区、批量动作区和 `PrimaryTable`。
- `/organization` 迁移到统一管理骨架,新增组织摘要、部门命令面板和统一组织节点卡片。
- `/system/workspace` 迁移到统一管理骨架,新增模块显隐摘要、工作区基线面板、角色预设卡片和模块显隐主表。
- Wave4 财务/运营页未重写业务结构,继续复用历史已通过的财务 surface,并通过 focused Playwright 防回退。
- Focused Playwright 扩展到 Wave4-5 页面:付款批次、AP、票据池、审批规则、人员、组织、工作区、用户、主数据、通知渠道、系统设置、功能开关、流程定义、审计日志。
- 本轮没有改后端、数据库、权限、审批规则语义、付款状态机、企业微信配置、SMTP 或生产配置。
## Test Plan
```bash
cd client && npm run typecheck
cd client && npm run build
cd client && npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list
node scripts/qa/check-ui-design-rules.mjs --strict
python3 ./.trellis/scripts/task.py validate 05-29-frontend-ux-wave4-5-finance-admin-refresh
node scripts/qa/check-trellis-governance.mjs
git diff --check
```
Deployment gate, only after approval:
```bash
SYNC_ENV=0 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash deploy/trialrun/deploy.sh
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh
OAOPS1_REPORT_DIR=.trellis/tasks/05-29-frontend-ux-wave4-5-finance-admin-refresh/research/postdeploy-<date>-wave4-5-public \
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh
```
## Out Of Scope
- 不新增真实付款渠道、ERP 写入、企业微信写入、数据库迁移或生产配置变更。
- 不新增通用文件中心。
- 不恢复差旅主线能力。
- 不把移动端改造成完整 PC 管理后台。
- 不删除已有业务能力;如发现业务流程缺陷,记录为后续业务流程任务。
## Research References
- `research/scope-audit.md` — Wave4 历史覆盖、Wave5 缺口和本任务页面清单。
- `.trellis/tasks/archive/2026-05/05-27-pc-finance-admin-visual-wave4/` — 历史 Wave4 范围、实现和部署证据。
- `.trellis/tasks/archive/2026-05/05-28-frontend-ux-redesign/` — Wave0-2 新基线和 Wave3-5 后续计划。
- `.trellis/tasks/archive/2026-05/05-28-frontend-ux-wave3-business-mobile/` — 最新 Wave3 refresh 和部署证据。
## Technical Notes
- 重点代码区域:
- `client/src/pages/PaymentBatchesPage.tsx`
- `client/src/pages/ApOperationsPage.tsx`
- `client/src/pages/InvoicePoolPage.tsx`
- `client/src/pages/ApprovalRulesPage.tsx`
- `client/src/pages/PeoplePage.tsx`
- `client/src/pages/OrganizationPage.tsx`
- `client/src/pages/WorkspaceSetupPage.tsx`
- `client/src/pages/UserManagementPage.tsx`
- `client/src/pages/MasterDataPage.tsx`
- `client/src/pages/NotificationChannelsPage.tsx`
- `client/src/pages/SystemSettingsPage.tsx`
- `client/src/pages/FeatureFlagsPage.tsx`
- `client/src/pages/WorkflowDefinitionsPage.tsx`
- `client/src/pages/AuditLogsPage.tsx`
- `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts`
@@ -0,0 +1,65 @@
# Wave4-5 Scope Audit
## Summary
Historical Wave4 is complete, deployed and archived, but it predates the latest Wave0-3 refresh line. The remaining work should not redo the whole finance/admin surface. It should validate the historical Wave4 pages against the new baseline and fill the missing Wave5 admin/config pages.
## Historical Wave4 Covered
Task: `.trellis/tasks/archive/2026-05/05-27-pc-finance-admin-visual-wave4/`
Covered pages:
- `/payment-batches`
- `/ap-operations`
- `/invoice-pool`
- `/system/users`
- `/system/master-data`
- `/system/notification-channels`
- `/system/settings`
- `/system/feature-flags`
- `/system/workflow-definitions`
- `/audit-logs`
Evidence:
- Local typecheck/build/focused Playwright/design rules/Trellis governance passed.
- Deployed commit `c5eb27e` passed public deploy checks and Wave4 public visual smoke.
- OAOPS1 postdeploy Wave4 read-only巡检 later recorded `PASS_OA_REAL_USE_DAILY_READY`.
## Current New Baseline
Wave0-2 task established the new global shell, navigation, page header, list/detail surface and high-frequency core pages. Wave3 then refreshed business detail and mobile key pages against that new baseline and passed public checks on 2026-05-29.
Therefore the next work should be a new-baseline refresh, not a full redesign.
## Gap Pages For Wave5
The following pages are active management/config pages but were not first-class targets in historical Wave4:
- `/system/approval-rules` — currently uses `ListWorkspace`/`WorkspaceHeader`, but still has a large create form and rule table that should be brought into admin command/status patterns.
- `/people` — people and HR master operation surface still uses older `PageHeader`/card/table style; it has high-risk actions including export, batch disable/enable, manager/department transfer and WeCom HR master sync.
- `/organization` — still uses older `PageHeader` and standalone cards; should align with management overview style while preserving hierarchy readability.
- `/system/workspace` — still uses older `PageHeader` and cards; affects navigation visibility and small-team workspace setup, so it belongs in Wave5 admin-config.
Secondary watchlist:
- `/system/departments`
- `/system/expense-standards`
- `/system/integrations/erp`
- `/system/cutover`
These are lower priority unless the initial implementation finds visible inconsistency or existing focused tests already cover them cheaply.
## Recommended Task Boundary
Do in this task:
- Refresh the four Wave5 gap pages.
- Re-run/extend focused Wave4 visual tests to cover both historical Wave4 pages and new Wave5 gap pages.
- Touch Wave4 finance pages only if the test/audit finds current new-baseline gaps.
Do not do in this task:
- Backend workflow, approval semantics, permission, payment state machine, database migration or Enterprise WeChat write changes.
- Real payment, ERP write, production config mutation or real business data automation.
@@ -0,0 +1,26 @@
{
"id": "frontend-ux-wave4-5-finance-admin-refresh",
"name": "frontend-ux-wave4-5-finance-admin-refresh",
"title": "前端体验 Wave4-5 财务与管理页刷新",
"description": "在 Wave0-3 新基线后复审财务/运营页并补齐管理配置页体验缺口。",
"status": "in_progress",
"dev_type": null,
"scope": "client frontend finance admin ux",
"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": {}
}
+65 -6
查看文件
@@ -61,6 +61,22 @@ export function ApprovalRulesPage() {
});
const rules = listQuery.data?.items ?? [];
const ruleStats = useMemo(() => {
const enabledRules = rules.filter((rule) => rule.enabled);
const disabledRules = rules.length - enabledRules.length;
const fallbackRules = rules.filter((rule) => rule.ownerSelfFallbackRole).length;
const topPriority = enabledRules.length
? Math.min(...enabledRules.map((rule) => rule.priority))
: null;
return {
total: rules.length,
enabled: enabledRules.length,
disabled: disabledRules,
fallback: fallbackRules,
topPriority,
};
}, [rules]);
const createPayload = useMemo(() => {
return {
@@ -169,8 +185,41 @@ export function ApprovalRulesPage() {
</p>
) : null}
<div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建审批规则</h3>
<div className="finance-status-strip" data-testid="approval-rules-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">规则总数</div>
<div className="finance-metric-card__value">{ruleStats.total}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">启用规则</div>
<div className="finance-metric-card__value">{ruleStats.enabled}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">停用规则</div>
<div className="finance-metric-card__value">{ruleStats.disabled}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">最高优先级</div>
<div className="finance-metric-card__value">{ruleStats.topPriority ?? "-"}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">本人兜底</div>
<div className="finance-metric-card__value">{ruleStats.fallback}</div>
</div>
</div>
<section className="admin-command-panel space-y-4" data-testid="approval-rules-create-panel">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">新建审批规则</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
规则按优先级匹配,只影响新提交单据;历史审批实例不回写。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-600">
金额段 + 角色链
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
@@ -311,10 +360,20 @@ export function ApprovalRulesPage() {
</Button>
</div>
</div>
</div>
</section>
<div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">规则列表</h3>
<section className="admin-command-panel space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">规则列表</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
优先级越小越先匹配;启停动作会经过确认。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
当前 {ruleStats.enabled} 条启用
</div>
</div>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
@@ -403,7 +462,7 @@ export function ApprovalRulesPage() {
</Table>
</PrimaryTable>
) : null}
</div>
</section>
<ConfirmDialog
open={confirmToggleRule !== null}
+68 -13
查看文件
@@ -7,19 +7,26 @@ import {
type OrganizationTreeNode,
} from "../api/employees";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
function countNodes(nodes: OrganizationTreeNode[]): number {
return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
}
function countExternalManagers(nodes: OrganizationTreeNode[]): number {
return nodes.reduce(
(total, node) => total + (node.externalManager ? 1 : 0) + countExternalManagers(node.children),
0,
);
}
function OrganizationNodeCard({ node, depth = 0 }: { node: OrganizationTreeNode; depth?: number }) {
return (
<div className="space-y-3">
<div
className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
style={{ marginLeft: `${depth * 24}px` }}
className="finance-list-card"
style={{ marginLeft: `${Math.min(depth, 4) * 18}px` }}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
@@ -78,20 +85,45 @@ export function OrganizationPage() {
}, [organizationQuery.data?.items]);
if (organizationQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载组织架构中...</div>;
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系。"
/>
<div className="admin-command-panel text-sm text-zinc-500">加载组织架构中...</div>
</ListWorkspace>
);
}
if (organizationQuery.isError) {
return <div className="card-std p-6 text-sm text-red-600">加载组织架构失败</div>;
return (
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系。"
/>
<div className="admin-command-panel text-sm text-red-600">加载组织架构失败</div>
</ListWorkspace>
);
}
const sections = organizationQuery.data?.items ?? [];
const totalEmployees = sections.reduce((total, section) => total + countNodes(section.roots), 0);
const externalManagerCount = sections.reduce((total, section) => total + countExternalManagers(section.roots), 0);
const expandedCount = sections.filter((section) => {
const key = section.department?.id ?? section.department?.name ?? "unknown";
return expandedDepartments[key] ?? true;
}).length;
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="组织架构"
subtitle="按部门查看当前 active 员工的汇报关系,跨部门上级会在节点右侧单独标注。"
subtitle="按部门查看 active 员工的汇报关系,跨部门上级单独标注,便于组织复核。"
actions={
<div className="flex gap-2">
<Button
@@ -124,6 +156,29 @@ export function OrganizationPage() {
}
/>
<div className="finance-status-strip" data-testid="organization-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">部门分组</div>
<div className="finance-metric-card__value">{sections.length}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">展示员工</div>
<div className="finance-metric-card__value">{totalEmployees}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">已展开</div>
<div className="finance-metric-card__value">{expandedCount}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">跨部门汇报</div>
<div className="finance-metric-card__value">{externalManagerCount}</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>
{sections.length ? (
<div className="space-y-6">
{sections.map((section: OrganizationDepartmentSection) => {
@@ -132,11 +187,11 @@ export function OrganizationPage() {
const totalNodes = countNodes(section.roots);
return (
<section key={sectionKey} className="card-std p-6">
<section key={sectionKey} className="admin-command-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h3>
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工</div>
<h2 className="text-base font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h2>
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工 · {expanded ? "已展开" : "已折叠"}</div>
</div>
<Button
type="button"
@@ -166,9 +221,9 @@ export function OrganizationPage() {
})}
</div>
) : (
<div className="card-std p-6 text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
<div className="admin-command-panel text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
)}
</div>
</ListWorkspace>
);
}
+169 -119
查看文件
@@ -29,7 +29,7 @@ import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -397,6 +397,13 @@ export function PeoplePage() {
const wecomHrMasterError = wecomHrMasterRunsQuery.isError
? mapApiMessage(wecomHrMasterRunsQuery.error, "企微 HR 主数据同步未就绪")
: "";
const peopleStats = {
total: employeesQuery.data?.total ?? items.length,
active: items.filter((item) => item.status === "active").length,
archived: items.filter((item) => item.status === "archived").length,
departments: new Set(items.map((item) => item.department?.id).filter(Boolean)).size,
wecomAttention: items.filter((item) => shouldShowWecomRetry(item.wecomDirectory)).length,
};
function toggleSelection(employeeId: string, checked: boolean) {
setSelectedEmployeeIds((current) => {
@@ -415,10 +422,11 @@ export function PeoplePage() {
}
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="人员管理"
subtitle="按当前员工档案与账号状态做筛选、批量处理和统计查看。"
subtitle="按员工档案、账号状态和企业微信镜像做筛选、批量处理和审计留痕。"
actions={
<div className="flex flex-wrap gap-2">
{canManageUsers ? (
@@ -462,8 +470,31 @@ export function PeoplePage() {
}
/>
<div className="finance-status-strip" data-testid="people-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">员工总数</div>
<div className="finance-metric-card__value">{peopleStats.total}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">当前页生效</div>
<div className="finance-metric-card__value">{peopleStats.active}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">当前页归档</div>
<div className="finance-metric-card__value">{peopleStats.archived}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">涉及部门</div>
<div className="finance-metric-card__value">{peopleStats.departments}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">企微待处理</div>
<div className="finance-metric-card__value">{peopleStats.wecomAttention}</div>
</div>
</div>
{canManageUsers ? (
<div className="card-std p-4">
<section className="admin-command-panel" data-testid="people-wecom-command-panel">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
@@ -507,10 +538,21 @@ export function PeoplePage() {
</Button>
</div>
</div>
</div>
</section>
) : null}
<div className="card-std space-y-4 p-4">
<section className="admin-command-panel space-y-4" data-testid="people-filter-panel">
<div className="flex flex-col gap-1 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">筛选与批量范围</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
先收窄部门、状态、上级和入职时间,再执行批量动作。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
已选 {selectedEmployeeIds.length} 人
</div>
</div>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
<Input
placeholder="搜索姓名、工号、手机号、邮箱"
@@ -582,10 +624,10 @@ export function PeoplePage() {
<span>到</span>
<span>{hireDateTo || "-"}</span>
</div>
</div>
</section>
{canManageUsers && selectedEmployeeIds.length ? (
<div className="card-std flex flex-wrap items-center justify-between gap-3 p-4">
<section className="admin-command-panel flex flex-wrap items-center justify-between gap-3" data-testid="people-batch-actions">
<div className="text-sm text-zinc-700">
已选 <span className="font-semibold text-zinc-900">{selectedEmployeeIds.length}</span> 名员工
</div>
@@ -622,10 +664,16 @@ export function PeoplePage() {
清空选择
</Button>
</div>
</div>
</section>
) : null}
<div className="card-std p-6">
<section className="admin-command-panel space-y-4">
<div>
<h2 className="text-base font-semibold text-zinc-900">人员列表</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
查看档案进入个人详情;启停、离职和企微重试会保留操作结果。
</p>
</div>
{exportError ? (
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
@@ -636,124 +684,126 @@ export function PeoplePage() {
{mapApiMessage(employeesQuery.error, "加载人员列表失败")}
</p>
) : null}
<Table>
<TableHeader>
<TableRow>
{canManageUsers ? <TableHead>选择</TableHead> : null}
<TableHead>员工</TableHead>
<TableHead>部门 / 上级</TableHead>
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>企业微信</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => {
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
return (
<TableRow key={item.id}>
{canManageUsers ? (
<PrimaryTable>
<Table data-testid="people-table">
<TableHeader>
<TableRow>
{canManageUsers ? <TableHead>选择</TableHead> : null}
<TableHead>员工</TableHead>
<TableHead>部门 / 上级</TableHead>
<TableHead>岗位 / 雇用状态</TableHead>
<TableHead>角色</TableHead>
<TableHead>账号状态</TableHead>
<TableHead>企业微信</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => {
const wecomView = wecomDirectoryStatusView(item.wecomDirectory);
return (
<TableRow key={item.id}>
{canManageUsers ? (
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<input
aria-label={`选择员工 ${item.name}`}
checked={selectedEmployeeIds.includes(item.id)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400"
disabled={batchPending}
type="checkbox"
onChange={(event) => toggleSelection(item.id, event.target.checked)}
/>
</TableCell>
) : null}
<TableCell>
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
<div>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">
{item.employeeNo} · {item.email}
</div>
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.department?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
上级:{item.directManager ? `${item.directManager.name} · ${item.directManager.employeeNo}` : "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div className="text-zinc-900">{item.title || "-"}</div>
<div className="text-xs text-zinc-500">
{employmentStatusLabel(item.employmentStatus)} · 入职 {item.hireDate ?? "-"}
</div>
</div>
</div>
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={wecomView.status} label={wecomView.label} />
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
{shouldShowWecomRetry(item.wecomDirectory) ? (
</TableCell>
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={wecomView.status} label={wecomView.label} />
<div className="max-w-40 truncate text-xs text-zinc-500">{wecomView.hint}</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/people/${item.id}`}>查看档案</Link>
</Button>
{canManageUsers ? (
<>
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
发起入职
</Button>
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
转正
</Button>
{shouldShowWecomRetry(item.wecomDirectory) ? (
<Button
size="sm"
variant="secondary"
type="button"
disabled={wecomDirectoryRetryMutation.isPending}
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
>
企微重试
</Button>
) : null}
<Button
size="sm"
variant="secondary"
type="button"
disabled={wecomDirectoryRetryMutation.isPending}
onClick={() => wecomDirectoryRetryMutation.mutate(item.id)}
disabled={offboardingMutation.isPending}
onClick={() => setOffboardingTarget(item)}
>
企微重试
发起离职
</Button>
) : null}
<Button
size="sm"
variant="secondary"
type="button"
disabled={offboardingMutation.isPending}
onClick={() => setOffboardingTarget(item)}
>
发起离职
</Button>
</>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
暂无匹配员工
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : null}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={tableColumnCount}>
暂无匹配员工
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
</section>
<Dialog open={transferDialogOpen} onOpenChange={setTransferDialogOpen}>
<DialogContent className="max-w-lg">
@@ -890,7 +940,7 @@ export function PeoplePage() {
}
}}
/>
</div>
</ListWorkspace>
);
}
+116 -62
查看文件
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSystemNavigation, fetchWorkspaceSettings, updateWorkspaceSettings } from "../api/workspace";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { toast } from "sonner";
@@ -46,6 +46,16 @@ const MODULE_LABELS: Record<string, string> = {
profile: "个人设置",
};
const VISIBILITY_OPTIONS = [
{ value: "main", label: "主导航" },
{ value: "admin", label: "管理区" },
{ value: "hidden", label: "隐藏" },
] as const;
function visibilityModeLabel(mode: string) {
return VISIBILITY_OPTIONS.find((item) => item.value === mode)?.label ?? mode;
}
export function WorkspaceSetupPage() {
const queryClient = useQueryClient();
const workspaceQuery = useQuery({ queryKey: ["workspace-settings"], queryFn: fetchWorkspaceSettings });
@@ -74,95 +84,139 @@ export function WorkspaceSetupPage() {
const workspace = workspaceQuery.data;
const rolePresets = navigationQuery.data?.recommendedRolePresets ?? [];
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
const workspaceStats = useMemo(() => {
const entries = Object.entries(workspace?.moduleVisibility ?? {});
return {
total: entries.length,
main: entries.filter(([, mode]) => mode === "main").length,
admin: entries.filter(([, mode]) => mode === "admin").length,
hidden: entries.filter(([, mode]) => mode === "hidden").length,
rolePresets: rolePresets.length,
};
}, [rolePresets.length, workspace?.moduleVisibility]);
if (workspaceQuery.isLoading || !workspace) {
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="工作区设置"
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
/>
<div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>
</div>
<div className="admin-command-panel text-sm text-zinc-500">加载工作区设置中...</div>
</ListWorkspace>
);
}
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
eyebrow="管理"
title="工作区设置"
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
subtitle="按小团队运营模式收敛主导航、管理入口和备用模块。"
/>
<div className="grid gap-4 xl:grid-cols-3">
<div className="card-std p-6">
<div className="text-sm text-zinc-500">产品模式</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.profile}</div>
<div className="mt-3 text-sm text-zinc-600">单公司、账号密码、站内通知、桌面优先。</div>
<div className="finance-status-strip" data-testid="workspace-settings-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">模块总数</div>
<div className="finance-metric-card__value">{workspaceStats.total}</div>
</div>
<div className="card-std p-6">
<div className="text-sm text-zinc-500">初始化状态</div>
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.persisted ? "已完成" : "待初始化"}</div>
<div className="mt-3 text-sm text-zinc-600">首次管理员进入此页后保存一次,即完成小团队工作区初始化。</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">主导航</div>
<div className="finance-metric-card__value">{workspaceStats.main}</div>
</div>
<div className="card-std p-6">
<div className="text-sm text-zinc-500">推荐角色预设</div>
<div className="mt-3 space-y-2 text-sm">
<div className="finance-metric-card">
<div className="finance-metric-card__label">管理区</div>
<div className="finance-metric-card__value">{workspaceStats.admin}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">隐藏</div>
<div className="finance-metric-card__value">{workspaceStats.hidden}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">初始化</div>
<div className="finance-metric-card__value text-base">{workspace.persisted ? "已完成" : "待初始化"}</div>
</div>
</div>
<section className="admin-command-panel space-y-4" data-testid="workspace-profile-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-900">工作区基线</h2>
<p className="mt-1 text-sm leading-6 text-zinc-600">
{workspace.profile} · 单公司 · 账号密码 · 站内通知 · 桌面优先。
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
{workspaceStats.rolePresets} 个角色预设
</div>
</div>
{rolePresets.length ? (
<div className="admin-tile-grid">
{rolePresets.map((item) => (
<div key={item.key} className="rounded-lg border border-zinc-200 p-3">
<div key={item.key} className="admin-tile-card">
<div className="font-medium text-zinc-900">{item.label}</div>
<div className="mt-1 text-zinc-600">{item.description}</div>
<div className="mt-1 text-xs text-zinc-500">角色:{item.roleCodes.join(" / ")}</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-300 px-4 py-5 text-sm text-slate-500">
当前没有推荐角色预设。
</div>
)}
</section>
<div className="card-std p-6 space-y-4">
<section className="admin-command-panel space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">模块显隐</h3>
<p className="mt-1 text-sm text-zinc-500">主导航只保留高频入口;备用模块保留代码与数据,但默认放在管理员备用区。</p>
<p className="mt-1 text-sm leading-6 text-zinc-600">
主导航只保留高频入口;备用模块保留代码与数据,低频功能默认收进管理区或隐藏。
</p>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>模块</TableHead>
<TableHead>当前显隐</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{moduleEntries.map(([key, mode]) => (
<TableRow key={key}>
<TableCell>{MODULE_LABELS[key] ?? key}</TableCell>
<TableCell>{mode}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{(["main", "admin", "hidden"] as const).map((nextMode) => (
<Button
key={nextMode}
size="sm"
variant={mode === nextMode ? "default" : "secondary"}
type="button"
disabled={mutation.isPending}
onClick={() => {
const nextVisibility = { ...(workspace.moduleVisibility ?? {}), [key]: nextMode };
mutation.mutate({ moduleVisibility: nextVisibility });
}}
>
{nextMode}
</Button>
))}
</div>
</TableCell>
<PrimaryTable>
<Table data-testid="workspace-modules-table">
<TableHeader>
<TableRow>
<TableHead>模块</TableHead>
<TableHead>当前显隐</TableHead>
<TableHead>操作</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TableHeader>
<TableBody>
{moduleEntries.map(([key, mode]) => (
<TableRow key={key}>
<TableCell>{MODULE_LABELS[key] ?? key}</TableCell>
<TableCell>{visibilityModeLabel(mode)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{VISIBILITY_OPTIONS.map((option) => (
<Button
key={option.value}
size="sm"
variant={mode === option.value ? "default" : "secondary"}
type="button"
disabled={mutation.isPending}
onClick={() => {
const nextVisibility = { ...(workspace.moduleVisibility ?? {}), [key]: option.value };
mutation.mutate({ moduleVisibility: nextVisibility });
}}
>
{option.label}
</Button>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</PrimaryTable>
</section>
</ListWorkspace>
);
}
@@ -30,8 +30,11 @@ const allPermissions = [
"risk.review.manage",
"travel.payment.manage",
"system.user.manage",
"system.users",
"system.settings",
"system.audit.view",
"masterdata.manage",
"notification.channel.manage",
];
const visualMe = {
@@ -517,9 +520,13 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
navigationItem("invoice-pool", "/invoice-pool", "票据中心", "receipt_long"),
],
admin: [
navigationItem("people", "/people", "人员管理", "badge", "admin"),
navigationItem("organization", "/organization", "组织架构", "account_tree", "admin"),
navigationItem("workspace", "/system/workspace", "工作区设置", "dashboard_customize", "admin"),
navigationItem("users", "/system/users", "用户管理", "group", "admin"),
navigationItem("master-data", "/system/master-data", "主数据", "database", "admin"),
navigationItem("notification-channels", "/system/notification-channels", "通知渠道", "notifications", "admin"),
navigationItem("approval-rules", "/system/approval-rules", "审批规则", "account_tree", "admin"),
navigationItem("settings", "/system/settings", "系统设置", "settings", "admin"),
navigationItem("feature-flags", "/system/feature-flags", "功能开关", "toggle_on", "admin"),
navigationItem("workflow-definitions", "/system/workflow-definitions", "流程定义", "schema", "admin"),
@@ -532,6 +539,35 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
return;
}
if (pathName === "/api/system/workspace") {
await fulfillJson(route, {
id: "workspace-wave4",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {
portal: "main",
workflowTasks: "main",
reimbursements: "main",
paymentBatches: "admin",
apOperations: "admin",
invoicePool: "admin",
people: "admin",
organization: "admin",
approvalRules: "admin",
featureFlags: "admin",
travelRequests: "hidden",
},
createdAt: null,
updatedAt: "2026-05-29T08:00:00.000Z",
updatedBy: visualMe.id,
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] });
return;
@@ -803,6 +839,97 @@ async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) {
return;
}
if (pathName === "/api/employees/organization") {
await fulfillJson(route, {
items: [
{
department: departments[0],
roots: [
{
id: visualMe.id,
employeeNo: visualMe.employeeNo,
name: visualMe.name,
title: "管理员",
status: "active",
avatarUrl: null,
department: departments[0],
externalManager: null,
children: [],
},
],
},
{
department: departments[1],
roots: [],
},
],
});
return;
}
if (pathName === "/api/employees/wecom-hr-master-sync/runs") {
await fulfillJson(route, {
items: [
{
id: "wecom-hr-run-wave4",
mode: "dry_run",
status: "success",
tokenSource: null,
requestedUserids: null,
limit: 50,
summary: { total: 1, created: 0, updated: 1, skipped: 0, conflicts: 0, failed: 0 },
errorCode: null,
errorMessage: null,
triggeredBy: visualMe.id,
startedAt: "2026-05-29T07:50:00.000Z",
finishedAt: "2026-05-29T07:51:00.000Z",
createdAt: "2026-05-29T07:50:00.000Z",
updatedAt: "2026-05-29T07:51:00.000Z",
itemCount: 1,
items: [],
},
],
});
return;
}
if (pathName === "/api/approval-rules") {
await fulfillJson(route, {
enabled: null,
items: [
{
id: "approval-rule-wave5-1",
name: "5000 以下经理审批",
minAmount: "0.00",
maxAmount: "5000.00",
level1Role: "manager",
level2Role: null,
level3Role: null,
ownerSelfFallbackRole: "finance_mgr",
enabled: true,
priority: 1,
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:00:00.000Z",
},
{
id: "approval-rule-wave5-2",
name: "高额财务负责人复核",
minAmount: "5000.01",
maxAmount: null,
level1Role: "manager",
level2Role: "finance_mgr",
level3Role: "ceo",
ownerSelfFallbackRole: null,
enabled: false,
priority: 2,
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/master-data/legal-entities") {
await fulfillJson(route, { includeArchived: false, items: legalEntities });
return;
@@ -886,16 +1013,20 @@ const pages = [
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches" },
{ path: "/ap-operations", summary: "ap-operations-summary", proof: "text=异常付款", screenshot: "02-ap-operations" },
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool" },
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "04-users" },
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=法体列表", screenshot: "05-master-data" },
{ path: "/system/notification-channels", summary: "notification-channels-summary", proof: "data-testid=notification-channels-table", screenshot: "06-notification-channels" },
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=系统设置", screenshot: "07-system-settings" },
{ path: "/system/feature-flags", summary: "feature-flags-summary", proof: "data-testid=feature-flags-table", screenshot: "08-feature-flags" },
{ path: "/system/workflow-definitions", summary: "workflow-definitions-summary", proof: "text=版本列表", screenshot: "09-workflow-definitions" },
{ path: "/audit-logs", summary: "audit-summary", proof: "text=PAYMENT_BATCH_REVIEW", screenshot: "10-audit-logs" },
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
{ path: "/system/workspace", summary: "workspace-settings-summary", proof: "data-testid=workspace-modules-table", screenshot: "07-workspace" },
{ path: "/system/users", summary: "users-summary", proof: "data-testid=users-table", screenshot: "08-users" },
{ path: "/system/master-data", summary: "master-data-summary", proof: "text=法体列表", screenshot: "09-master-data" },
{ path: "/system/notification-channels", summary: "notification-channels-summary", proof: "data-testid=notification-channels-table", screenshot: "10-notification-channels" },
{ path: "/system/settings", summary: "system-settings-summary", proof: "text=系统设置", screenshot: "11-system-settings" },
{ path: "/system/feature-flags", summary: "feature-flags-summary", proof: "data-testid=feature-flags-table", screenshot: "12-feature-flags" },
{ path: "/system/workflow-definitions", summary: "workflow-definitions-summary", proof: "text=版本列表", screenshot: "13-workflow-definitions" },
{ path: "/audit-logs", summary: "audit-summary", proof: "text=PAYMENT_BATCH_REVIEW", screenshot: "14-audit-logs" },
];
test("PC 财务与管理页 Wave4 视觉冒烟", async ({ page }) => {
test("PC 财务与管理页 Wave4-5 视觉冒烟", async ({ page }) => {
await mockWave4Apis(page);
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);