优化财务与管理页视觉 Wave4(05-27-pc-finance-admin-visual-wave4)
这个提交包含在:
@@ -299,3 +299,45 @@ onNeedRefresh() {
|
|||||||
<PasswordInput id="login-password" />
|
<PasswordInput id="login-password" />
|
||||||
<Link className="row-start-1" to="/forgot-password">忘记密码?</Link>
|
<Link className="row-start-1" to="/forgot-password">忘记密码?</Link>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Convention: Finance And Admin Console Surfaces
|
||||||
|
|
||||||
|
**What**: PC finance and C-class admin pages should reuse Wave4 shared classes
|
||||||
|
from `client/src/index.css` before creating page-local card/table layouts:
|
||||||
|
|
||||||
|
- `finance-status-strip`, `finance-metric-card`,
|
||||||
|
`finance-metric-card__label` and `finance-metric-card__value` for compact
|
||||||
|
five-column status summaries.
|
||||||
|
- `finance-workspace-grid`, `finance-command-panel`, `finance-list-card` and
|
||||||
|
`finance-list-card--active` for payment/AP/invoice operational workspaces.
|
||||||
|
- `admin-command-panel`, `admin-tile-grid`, `admin-tile-card` and
|
||||||
|
`admin-workspace-grid` for settings, workflow, feature-flag, user, master
|
||||||
|
data, notification-channel and audit pages.
|
||||||
|
|
||||||
|
**Why**: Finance/admin pages are lower-frequency but high-risk operational
|
||||||
|
surfaces. They need dense scanability, stable command regions and clear risk
|
||||||
|
hierarchy without turning every page into a bespoke Tailwind card system.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="finance-status-strip" data-testid="payment-batches-summary">
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">可处理</div>
|
||||||
|
<div className="finance-metric-card__value">{actionableCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="finance-workspace-grid">
|
||||||
|
<section className="finance-command-panel">...</section>
|
||||||
|
<section className="finance-command-panel">...</section>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tests Required**:
|
||||||
|
|
||||||
|
- `cd client && npm run typecheck`
|
||||||
|
- `cd client && npm run build`
|
||||||
|
- Focused Playwright coverage for changed finance/admin pages. The Wave4
|
||||||
|
baseline test is `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts`.
|
||||||
|
- UI design rules check when changing these shared classes.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "核对 Wave4 页面是否符合前端设计与验收边界"}
|
||||||
|
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端类型检查、构建和测试质量门禁"}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "Trellis 任务证据、截图和报告路径脱敏要求"}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "治理检查和敏感信息排除"}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Wave4 前端视觉与交互裁决、截图和 PC 操作页约束"}
|
||||||
|
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "React 组件拆分、复用和可维护性约束"}
|
||||||
|
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "TypeScript 类型和 props 约束"}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "财务、管理和测试证据不得泄露敏感资料"}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 实施记录
|
||||||
|
|
||||||
|
## 执行策略
|
||||||
|
|
||||||
|
1. 先审视财务、AP、票据池和 C 类管理页现有结构,复用 Wave2/Wave3 已建立的 `ops-*`、`detail-*` 工作台语言。
|
||||||
|
2. 只增加必要共享样式或轻量页面结构调整,不改业务 API 和状态机。
|
||||||
|
3. 财务高频页按“状态摘要、命令面板、风险/异常、主表格或主列表”收敛。
|
||||||
|
4. C 类管理页按“管理摘要、配置命令、主内容、危险操作”收敛。
|
||||||
|
5. 增加 focused Playwright mock 视觉用例,覆盖 PC 财务和管理页截图。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 不修改后端 API、schema、权限、审批/付款状态机、企业微信、SMTP 和部署环境变量。
|
||||||
|
- 不写公网业务数据,不执行真实付款、ERP 写入或企业微信写入。
|
||||||
|
- 功能开关、付款、导出、导入、删除等高风险动作必须保持原权限、确认和错误提示。
|
||||||
|
- 低频管理页可降噪,但入口不能消失。
|
||||||
|
|
||||||
|
## 待记录
|
||||||
|
|
||||||
|
## 实施改动
|
||||||
|
|
||||||
|
- 新增 Wave4 共享视觉类:`finance-status-strip`、`finance-metric-card`、`finance-workspace-grid`、`finance-command-panel`、`finance-list-card`、`admin-command-panel`、`admin-tile-grid`、`admin-workspace-grid`。
|
||||||
|
- 财务高频页完成状态摘要、命令面板、主列表/表格和风险信息层级收敛:
|
||||||
|
- `client/src/pages/PaymentBatchesPage.tsx`
|
||||||
|
- `client/src/pages/ApOperationsPage.tsx`
|
||||||
|
- `client/src/pages/InvoicePoolPage.tsx`
|
||||||
|
- C 类管理页完成统一 header、摘要、命令区和内容容器收敛:
|
||||||
|
- `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`
|
||||||
|
- 新增 focused Playwright:`client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts`。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run typecheck
|
||||||
|
cd client && npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list
|
||||||
|
cd client && npm run build
|
||||||
|
node scripts/qa/check-ui-design-rules.mjs
|
||||||
|
python3 ./.trellis/scripts/task.py validate 05-27-pc-finance-admin-visual-wave4
|
||||||
|
node scripts/qa/check-trellis-governance.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:以上命令均通过。
|
||||||
|
|
||||||
|
## 截图证据
|
||||||
|
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/01-payment-batches.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/02-ap-operations.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/03-invoice-pool.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/04-users.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/05-master-data.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/06-notification-channels.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/07-system-settings.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/08-feature-flags.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/09-workflow-definitions.png`
|
||||||
|
- `output/playwright/pc-finance-admin-visual-wave4/10-audit-logs.png`
|
||||||
|
|
||||||
|
## Spec 同步
|
||||||
|
|
||||||
|
- 已更新 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`,沉淀 Wave4 财务/管理页共享 surface 约定。
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# PC财务与管理页视觉重塑 Wave4
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在 Wave2、Wave3 已完成并部署验证后,继续用 `frontend-design` 收敛剩余 PC 财务/付款/AP/票据池和 C 类低频管理页的视觉与交互层级。目标是让真实试运行中的财务、管理员和负责人进入低频但关键页面时,仍然看到统一、简洁、可扫描的 Hyinda 工作台体验。
|
||||||
|
|
||||||
|
## Start Confirmation
|
||||||
|
|
||||||
|
- 用户在 2026-05-27 确认“继续做wave4”。
|
||||||
|
- 本任务只做前端视觉、信息层级和交互密度治理。
|
||||||
|
- 不改变后端 API、权限模型、付款状态机、审批规则、数据库 schema、SMTP、企业微信配置、真实付款、ERP 写入或生产数据。
|
||||||
|
- 设计裁决来源:`frontend-design` skill;业务和部署边界仍按 Trellis task、`.trellis/spec/hyb-oa/` 和 `docs/` 执行。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### 财务高频页
|
||||||
|
|
||||||
|
- 付款批次 `/payment-batches`
|
||||||
|
- AP 运营 `/ap-operations`
|
||||||
|
- 票据池 `/invoice-pool`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 强化当前状态、筛选/批量操作、风险/异常、主表格或主列表。
|
||||||
|
- 保留所有现有入口、确认、错误提示、导出、附件和只读边界。
|
||||||
|
- 不执行真实付款,不新增支付渠道,不写 ERP。
|
||||||
|
|
||||||
|
### C 类低频管理页
|
||||||
|
|
||||||
|
优先覆盖:
|
||||||
|
|
||||||
|
- 用户管理 `/system/users`
|
||||||
|
- 主数据 `/system/master-data`
|
||||||
|
- 通知渠道 `/system/notification-channels`
|
||||||
|
- 系统设置 `/system/settings`
|
||||||
|
- 功能开关 `/system/feature-flags`
|
||||||
|
- 流程定义 `/system/workflow-definitions`
|
||||||
|
- 审计日志 `/audit-logs`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 降低说明性文案和页面噪音。
|
||||||
|
- 统一管理页的 header、状态摘要、命令区、表格/列表容器和危险操作层级。
|
||||||
|
- 保持管理员可达性和审计/配置安全提示。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 不改移动端。
|
||||||
|
- 不改登录页、工作台、报销列表、审批中心、报销详情、审批详情和通知中心;这些已由 Wave2/Wave3 覆盖。
|
||||||
|
- 不改后端接口、数据模型、权限、审批/付款状态机、企业微信、SMTP、OCR provider 或部署环境变量。
|
||||||
|
- 不执行公网部署、真实业务写入、真实付款、ERP 写入、数据库重置或企业微信写入。
|
||||||
|
|
||||||
|
## Design Direction
|
||||||
|
|
||||||
|
- Tone:finance operations console,克制、紧凑、风险清晰。
|
||||||
|
- Composition:状态摘要 + 命令面板 + 主列表/主表格 + 次级说明。
|
||||||
|
- Copy:保留行动、风险、状态和不可逆操作提示;删除解释系统如何工作的长文案。
|
||||||
|
- Density:PC 页面可更密,但必须有清晰分区和稳定按钮宽度。
|
||||||
|
- Safety:付款、导出、配置、功能开关、删除、导入等动作保持原确认与权限判断。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Wave4 页面必须与 Wave2/Wave3 的工作台语言一致。
|
||||||
|
- 财务高频页必须让用户快速判断:待处理、异常、可执行动作、下一步。
|
||||||
|
- 管理页必须减少“工具堆叠感”,统一为可扫描的操作台。
|
||||||
|
- 现有业务能力不能丢失;若入口降级为次级工具,必须仍可达。
|
||||||
|
- 测试和证据不得保存密码、token、Secret、完整账号、真实票据、合同正文、银行资料、完整本地路径或 raw payload。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] 财务高频页完成统一视觉与操作层级收敛。
|
||||||
|
- [x] C 类低频管理页完成统一 header、命令区和内容容器收敛。
|
||||||
|
- [x] Wave2/Wave3 页面不发生明显回退。
|
||||||
|
- [x] `cd client && npm run typecheck` 通过。
|
||||||
|
- [x] `cd client && npm run build` 通过。
|
||||||
|
- [x] Focused Playwright 覆盖财务高频页和 C 类管理页,并生成截图证据。
|
||||||
|
- [x] `node scripts/qa/check-ui-design-rules.mjs` 通过。
|
||||||
|
- [x] `node scripts/qa/check-trellis-governance.mjs` 与 `git diff --check` 通过。
|
||||||
|
|
||||||
|
## Completion Notes
|
||||||
|
|
||||||
|
- 2026-05-27 本地完成 Wave4 代码整改和自动化验证。
|
||||||
|
- 覆盖页面:`/payment-batches`、`/ap-operations`、`/invoice-pool`、`/system/users`、`/system/master-data`、`/system/notification-channels`、`/system/settings`、`/system/feature-flags`、`/system/workflow-definitions`、`/audit-logs`。
|
||||||
|
- 截图证据:`output/playwright/pc-finance-admin-visual-wave4/*.png`。
|
||||||
|
- 尚未执行公网部署;面向用户可测仍需单独批准部署并完成公网检查。
|
||||||
|
|
||||||
|
## 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
|
||||||
|
node scripts/qa/check-trellis-governance.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
发布前后按需补充:
|
||||||
|
|
||||||
|
```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
|
||||||
|
E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 前端设计 skill:`frontend-design`
|
||||||
|
- 前端 spec:`.trellis/spec/hyb-oa/frontend-react-vite-design.md`
|
||||||
|
- 前端基础 spec:`.trellis/spec/frontend/`
|
||||||
|
- 部署/试运行 spec:`.trellis/spec/hyb-oa/deployment-trialrun.md`
|
||||||
|
- 安全与证据 spec:`.trellis/spec/hyb-oa/security-privacy.md`、`.trellis/spec/hyb-oa/docs-and-evidence.md`
|
||||||
|
- Wave2 参考任务:`.trellis/tasks/archive/2026-05/05-27-pc-core-workflow-visual-wave2/`
|
||||||
|
- Wave3 参考任务:`.trellis/tasks/archive/2026-05/05-27-pc-detail-mobile-visual-wave3/`
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "pc-finance-admin-visual-wave4",
|
||||||
|
"name": "pc-finance-admin-visual-wave4",
|
||||||
|
"title": "PC财务与管理页视觉重塑 Wave4",
|
||||||
|
"description": "用 frontend-design 统一 PC 财务/付款/AP/票据池和 C 类管理页的视觉层级与操作密度。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": "client",
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-05-27",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -527,6 +527,78 @@
|
|||||||
@apply text-xs font-medium;
|
@apply text-xs font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-status-strip {
|
||||||
|
@apply grid gap-3 md:grid-cols-2 xl:grid-cols-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-metric-card {
|
||||||
|
border-color: rgba(203, 213, 225, 0.88);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.88));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
@apply border p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-metric-card__label {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
@apply text-xs font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-metric-card__value {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
@apply mt-2 text-2xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-workspace-grid {
|
||||||
|
@apply grid gap-5 xl:grid-cols-[340px,minmax(0,1fr)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-command-panel {
|
||||||
|
border-color: rgba(203, 213, 225, 0.92);
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
@apply border p-4 md:p-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-list-card {
|
||||||
|
border-color: rgba(226, 232, 240, 0.94);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
@apply w-full border px-3 py-3 text-left transition-colors hover:border-slate-300 hover:bg-slate-50/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-list-card--active {
|
||||||
|
border-color: rgba(0, 57, 138, 0.34);
|
||||||
|
background: rgba(0, 57, 138, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-command-panel {
|
||||||
|
border-color: rgba(203, 213, 225, 0.92);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
@apply border p-4 md:p-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tile-grid {
|
||||||
|
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tile-card {
|
||||||
|
border-color: rgba(226, 232, 240, 0.94);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
@apply border p-4 transition-colors hover:border-slate-300 hover:bg-slate-50/60 md:p-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-workspace-grid {
|
||||||
|
@apply grid gap-5 xl:grid-cols-[220px,280px,minmax(0,1fr)];
|
||||||
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
@apply inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/15;
|
@apply inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/15;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,9 +248,9 @@ function SummaryCard({
|
|||||||
: "text-zinc-900";
|
: "text-zinc-900";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-std p-5">
|
<div className="finance-metric-card">
|
||||||
<div className="text-sm text-zinc-500">{title}</div>
|
<div className="finance-metric-card__label">{title}</div>
|
||||||
<div className={`mt-2 text-2xl font-bold ${toneClass}`}>{value}</div>
|
<div className={`finance-metric-card__value ${toneClass}`}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -618,7 +618,7 @@ export function ApOperationsPage() {
|
|||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="财务付款与对账"
|
title="财务付款与对账"
|
||||||
subtitle="待付款、异常付款、风险和对账。"
|
subtitle="待付款、异常付款、风险、对账和台账集中处理。"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -661,7 +661,7 @@ export function ApOperationsPage() {
|
|||||||
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
|
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-5">
|
<div className="finance-status-strip" data-testid="ap-operations-summary">
|
||||||
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
|
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
|
||||||
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
|
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
@@ -677,9 +677,9 @@ export function ApOperationsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std p-4 md:p-5">
|
<div className="finance-command-panel">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
|
||||||
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
|
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
|
||||||
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
|
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
|
||||||
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
||||||
@@ -688,7 +688,7 @@ export function ApOperationsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="ledger" className="mt-6 space-y-4">
|
<TabsContent value="ledger" className="mt-6 space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-6">
|
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium text-zinc-700">审批状态</div>
|
<div className="text-sm font-medium text-zinc-700">审批状态</div>
|
||||||
<select
|
<select
|
||||||
@@ -791,7 +791,7 @@ export function ApOperationsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200" data-testid="finance-ledger-table">
|
<div className="ops-table-focus overflow-x-auto" data-testid="finance-ledger-table">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -901,7 +901,7 @@ export function ApOperationsPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="pending" className="mt-6 space-y-4">
|
<TabsContent value="pending" className="mt-6 space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium text-zinc-700">实体类型</div>
|
<div className="text-sm font-medium text-zinc-700">实体类型</div>
|
||||||
<select
|
<select
|
||||||
@@ -943,7 +943,7 @@ export function ApOperationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1046,7 +1046,7 @@ export function ApOperationsPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="failed" className="mt-6 space-y-4">
|
<TabsContent value="failed" className="mt-6 space-y-4">
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1170,7 +1170,7 @@ export function ApOperationsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1248,7 +1248,7 @@ export function ApOperationsPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reconciliation" className="mt-6 space-y-4">
|
<TabsContent value="reconciliation" className="mt-6 space-y-4">
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { exportAuditLogs } from "../api/exports";
|
import { exportAuditLogs } from "../api/exports";
|
||||||
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
|
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
@@ -68,10 +68,10 @@ export function AuditLogsPage() {
|
|||||||
}, [actionFilter, page, targetTypeFilter]);
|
}, [actionFilter, page, targetTypeFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ListWorkspace>
|
||||||
<PageHeader
|
<WorkspaceHeader
|
||||||
title="审计工作台"
|
title="审计工作台"
|
||||||
subtitle="只读查看系统关键操作审计记录。"
|
subtitle="关键操作审计只读查询与导出。"
|
||||||
actions={
|
actions={
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -135,7 +135,15 @@ export function AuditLogsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="card-std p-6 space-y-4">
|
<div className="finance-status-strip" data-testid="audit-summary">
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">记录总数</div><div className="finance-metric-card__value">{data?.total ?? 0}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前页</div><div className="finance-metric-card__value">{page}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">Action 过滤</div><div className="finance-metric-card__value text-base">{actionFilter || "全部"}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">Target 过滤</div><div className="finance-metric-card__value text-base">{targetTypeFilter || "全部"}</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>
|
||||||
|
|
||||||
|
<section className="admin-command-panel space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<label className="text-sm text-zinc-700">
|
<label className="text-sm text-zinc-700">
|
||||||
Action
|
Action
|
||||||
@@ -177,7 +185,7 @@ export function AuditLogsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<table className="min-w-full text-left text-sm">
|
<table className="min-w-full text-left text-sm">
|
||||||
<thead className="bg-zinc-50 text-zinc-600">
|
<thead className="bg-zinc-50 text-zinc-600">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -251,6 +259,6 @@ export function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ import {
|
|||||||
type FeatureFlagSnapshot,
|
type FeatureFlagSnapshot,
|
||||||
} from "../api/featureFlags";
|
} from "../api/featureFlags";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
|
||||||
import { Pagination } from "../components/common/Pagination";
|
import { Pagination } from "../components/common/Pagination";
|
||||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
import { ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -261,6 +260,13 @@ export function FeatureFlagsPage() {
|
|||||||
}, [departmentsQuery.data?.items]);
|
}, [departmentsQuery.data?.items]);
|
||||||
|
|
||||||
const items = flagsQuery.data?.items ?? [];
|
const items = flagsQuery.data?.items ?? [];
|
||||||
|
const flagStats = {
|
||||||
|
total: items.length,
|
||||||
|
effectiveOn: items.filter((item) => item.effectiveEnabled).length,
|
||||||
|
forcedOn: items.filter((item) => item.overrideMode === "force_on").length,
|
||||||
|
forcedOff: items.filter((item) => item.overrideMode === "force_off").length,
|
||||||
|
allowlist: items.filter((item) => item.rolloutType === "department_allowlist").length,
|
||||||
|
};
|
||||||
const selectedItems = useMemo(
|
const selectedItems = useMemo(
|
||||||
() => items.filter((item) => selectedFlagKeys.includes(item.flagKey)),
|
() => items.filter((item) => selectedFlagKeys.includes(item.flagKey)),
|
||||||
[items, selectedFlagKeys],
|
[items, selectedFlagKeys],
|
||||||
@@ -316,10 +322,10 @@ export function FeatureFlagsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={batchMode ? "space-y-6 pb-32" : "space-y-6"}>
|
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
|
||||||
<PageHeader
|
<WorkspaceHeader
|
||||||
title="功能开关"
|
title="功能开关"
|
||||||
subtitle="外部桥接、验真和灰度开关。"
|
subtitle="外部桥接、验真、灰度和试运行边界。"
|
||||||
/>
|
/>
|
||||||
<WorkspaceNotice title="安全摘要" tone="warning">
|
<WorkspaceNotice title="安全摘要" tone="warning">
|
||||||
Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。
|
Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。
|
||||||
@@ -329,6 +335,14 @@ export function FeatureFlagsPage() {
|
|||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="finance-status-strip" data-testid="feature-flags-summary">
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">开关总数</div><div className="finance-metric-card__value">{flagStats.total}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">实际开启</div><div className="finance-metric-card__value">{flagStats.effectiveOn}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">强制开启</div><div className="finance-metric-card__value">{flagStats.forcedOn}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">强制关闭</div><div className="finance-metric-card__value">{flagStats.forcedOff}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">部门灰度</div><div className="finance-metric-card__value">{flagStats.allowlist}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SectionPanel
|
<SectionPanel
|
||||||
title="开关列表"
|
title="开关列表"
|
||||||
description="单条编辑或批量切换,必须填写变更说明。"
|
description="单条编辑或批量切换,必须填写变更说明。"
|
||||||
@@ -351,7 +365,7 @@ export function FeatureFlagsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!flagsQuery.isLoading && !flagsQuery.isError ? (
|
{!flagsQuery.isLoading && !flagsQuery.isError ? (
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table data-testid="feature-flags-table">
|
<Table data-testid="feature-flags-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -831,6 +845,6 @@ export function FeatureFlagsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
@@ -436,6 +436,16 @@ export function InvoicePoolPage() {
|
|||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
|
const invoiceStats = useMemo(() => {
|
||||||
|
const items = listQuery.data?.items ?? [];
|
||||||
|
return {
|
||||||
|
total: listQuery.data?.total ?? 0,
|
||||||
|
pending: items.filter((item) => item.status === "pending_ocr").length,
|
||||||
|
failed: items.filter((item) => item.status === "ocr_failed").length,
|
||||||
|
available: items.filter((item) => item.status === "available").length,
|
||||||
|
bound: items.filter((item) => item.status === "bound").length,
|
||||||
|
};
|
||||||
|
}, [listQuery.data?.items, listQuery.data?.total]);
|
||||||
const canEditDetail = Boolean(
|
const canEditDetail = Boolean(
|
||||||
detail &&
|
detail &&
|
||||||
(canManageInvoice ||
|
(canManageInvoice ||
|
||||||
@@ -449,7 +459,7 @@ export function InvoicePoolPage() {
|
|||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
title="票据中心"
|
title="票据中心"
|
||||||
subtitle="上传、识别、绑定。"
|
subtitle="上传、识别、绑定和异常复核集中处理。"
|
||||||
actions={
|
actions={
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -480,6 +490,29 @@ export function InvoicePoolPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="finance-status-strip" data-testid="invoice-pool-summary">
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">票据总数</div>
|
||||||
|
<div className="finance-metric-card__value">{invoiceStats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">识别中</div>
|
||||||
|
<div className="finance-metric-card__value">{invoiceStats.pending}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">识别失败</div>
|
||||||
|
<div className="finance-metric-card__value">{invoiceStats.failed}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">可绑定</div>
|
||||||
|
<div className="finance-metric-card__value">{invoiceStats.available}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">已绑定</div>
|
||||||
|
<div className="finance-metric-card__value">{invoiceStats.bound}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<InvoiceUploadCard
|
<InvoiceUploadCard
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
setSelectedFiles={setSelectedFiles}
|
setSelectedFiles={setSelectedFiles}
|
||||||
|
|||||||
@@ -169,6 +169,15 @@ export function MasterDataPage() {
|
|||||||
const legalEntities = legalEntitiesQuery.data?.items ?? [];
|
const legalEntities = legalEntitiesQuery.data?.items ?? [];
|
||||||
const costCenters = costCentersQuery.data?.items ?? [];
|
const costCenters = costCentersQuery.data?.items ?? [];
|
||||||
const documentCategories = documentCategoriesQuery.data?.items ?? [];
|
const documentCategories = documentCategoriesQuery.data?.items ?? [];
|
||||||
|
const masterDataStats = {
|
||||||
|
legalEntities: legalEntities.length,
|
||||||
|
costCenters: costCenters.length,
|
||||||
|
documentCategories: documentCategories.length,
|
||||||
|
archived:
|
||||||
|
legalEntities.filter((item) => item.archivedAt).length +
|
||||||
|
costCenters.filter((item) => item.archivedAt).length +
|
||||||
|
documentCategories.filter((item) => item.archivedAt).length,
|
||||||
|
};
|
||||||
|
|
||||||
const availableParentCostCenters = useMemo(() => {
|
const availableParentCostCenters = useMemo(() => {
|
||||||
return costCenters.filter(
|
return costCenters.filter(
|
||||||
@@ -315,8 +324,16 @@ export function MasterDataPage() {
|
|||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="finance-status-strip" data-testid="master-data-summary">
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">法体</div><div className="finance-metric-card__value">{masterDataStats.legalEntities}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">成本中心</div><div className="finance-metric-card__value">{masterDataStats.costCenters}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">单据分类</div><div className="finance-metric-card__value">{masterDataStats.documentCategories}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">归档项</div><div className="finance-metric-card__value">{masterDataStats.archived}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前视图</div><div className="finance-metric-card__value text-base">{includeArchived ? "含归档" : "仅启用"}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
|
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
|
||||||
<TabsTrigger value="legal-entities">法体</TabsTrigger>
|
<TabsTrigger value="legal-entities">法体</TabsTrigger>
|
||||||
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
|
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
|
||||||
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
||||||
@@ -325,7 +342,7 @@ export function MasterDataPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="legal-entities" className="space-y-6">
|
<TabsContent value="legal-entities" className="space-y-6">
|
||||||
<FilterBar className="space-y-4">
|
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
|
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
|
||||||
<select
|
<select
|
||||||
@@ -341,7 +358,7 @@ export function MasterDataPage() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建法体</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建法体</h3>
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -377,11 +394,11 @@ export function MasterDataPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
|
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
|
||||||
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
||||||
{!legalEntitiesQuery.isLoading ? (
|
{!legalEntitiesQuery.isLoading ? (
|
||||||
<PrimaryTable>
|
<PrimaryTable className="ops-table-focus">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -438,7 +455,7 @@ export function MasterDataPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="cost-centers" className="space-y-6">
|
<TabsContent value="cost-centers" className="space-y-6">
|
||||||
<FilterBar className="space-y-4">
|
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<select
|
<select
|
||||||
className="select-std"
|
className="select-std"
|
||||||
@@ -467,7 +484,7 @@ export function MasterDataPage() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建成本中心</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建成本中心</h3>
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
@@ -534,11 +551,11 @@ export function MasterDataPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">成本中心列表</h3>
|
<h3 className="text-base font-semibold text-zinc-900">成本中心列表</h3>
|
||||||
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||||
{!costCentersQuery.isLoading ? (
|
{!costCentersQuery.isLoading ? (
|
||||||
<PrimaryTable>
|
<PrimaryTable className="ops-table-focus">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -597,7 +614,7 @@ export function MasterDataPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="document-categories" className="space-y-6">
|
<TabsContent value="document-categories" className="space-y-6">
|
||||||
<FilterBar className="space-y-4">
|
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<select
|
<select
|
||||||
className="select-std"
|
className="select-std"
|
||||||
@@ -626,7 +643,7 @@ export function MasterDataPage() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建单据分类</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建单据分类</h3>
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -697,10 +714,11 @@ export function MasterDataPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">单据分类列表</h3>
|
<h3 className="text-base font-semibold text-zinc-900">单据分类列表</h3>
|
||||||
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||||
{!documentCategoriesQuery.isLoading ? (
|
{!documentCategoriesQuery.isLoading ? (
|
||||||
|
<PrimaryTable className="ops-table-focus">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -757,6 +775,7 @@ export function MasterDataPage() {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</PrimaryTable>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type NotificationChannelWriteConfig,
|
type NotificationChannelWriteConfig,
|
||||||
} from "../api/notificationChannels";
|
} from "../api/notificationChannels";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
@@ -325,14 +325,22 @@ export function NotificationChannelsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channels = channelsQuery.data?.items ?? [];
|
const channels = channelsQuery.data?.items ?? [];
|
||||||
|
const deliveryItems = deliveriesQuery.data?.items ?? [];
|
||||||
|
const channelStats = {
|
||||||
|
total: channels.length,
|
||||||
|
enabled: channels.filter((item) => item.enabled).length,
|
||||||
|
appMessage: channels.filter((item) => item.configJson.mode === "wecom_app_message").length,
|
||||||
|
failedDeliveries: deliveryItems.filter((item) => item.status === "failed").length,
|
||||||
|
pendingDeliveries: deliveryItems.filter((item) => item.status === "pending").length,
|
||||||
|
};
|
||||||
const isWebhookMode = form.mode === "wecom_webhook";
|
const isWebhookMode = form.mode === "wecom_webhook";
|
||||||
const isAppMessageMode = form.mode === "wecom_app_message";
|
const isAppMessageMode = form.mode === "wecom_app_message";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<ListWorkspace>
|
||||||
<PageHeader
|
<WorkspaceHeader
|
||||||
title="通知渠道"
|
title="通知渠道"
|
||||||
subtitle="维护企微通知渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
|
subtitle="渠道、路由、测试发送和投递追踪集中治理。"
|
||||||
actions={
|
actions={
|
||||||
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
|
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
|
||||||
新建渠道
|
新建渠道
|
||||||
@@ -344,7 +352,15 @@ export function NotificationChannelsPage() {
|
|||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="finance-status-strip" data-testid="notification-channels-summary">
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">渠道总数</div><div className="finance-metric-card__value">{channelStats.total}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">启用渠道</div><div className="finance-metric-card__value">{channelStats.enabled}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">应用消息</div><div className="finance-metric-card__value">{channelStats.appMessage}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">失败投递</div><div className="finance-metric-card__value">{channelStats.failedDeliveries}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">处理中</div><div className="finance-metric-card__value">{channelStats.pendingDeliveries}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-command-panel space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">渠道列表</h3>
|
<h3 className="text-base font-semibold text-zinc-900">渠道列表</h3>
|
||||||
<p className="text-sm text-zinc-500">当前共 {channels.length} 条渠道配置</p>
|
<p className="text-sm text-zinc-500">当前共 {channels.length} 条渠道配置</p>
|
||||||
@@ -359,7 +375,7 @@ export function NotificationChannelsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!channelsQuery.isLoading && !channelsQuery.isError ? (
|
{!channelsQuery.isLoading && !channelsQuery.isError ? (
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table data-testid="notification-channels-table">
|
<Table data-testid="notification-channels-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -428,7 +444,7 @@ export function NotificationChannelsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="admin-command-panel space-y-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">最近投递记录</h3>
|
<h3 className="text-base font-semibold text-zinc-900">最近投递记录</h3>
|
||||||
@@ -474,7 +490,7 @@ export function NotificationChannelsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!deliveriesQuery.isLoading && !deliveriesQuery.isError ? (
|
{!deliveriesQuery.isLoading && !deliveriesQuery.isError ? (
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table data-testid="notification-deliveries-table">
|
<Table data-testid="notification-deliveries-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -520,7 +536,7 @@ export function NotificationChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="admin-command-panel space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">
|
<h3 className="text-base font-semibold text-zinc-900">
|
||||||
@@ -818,7 +834,7 @@ export function NotificationChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="admin-command-panel space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">测试发送</h3>
|
<h3 className="text-base font-semibold text-zinc-900">测试发送</h3>
|
||||||
<p className="text-sm text-zinc-500">仅对已保存渠道执行同步测试,不写业务 Outbox。</p>
|
<p className="text-sm text-zinc-500">仅对已保存渠道执行同步测试,不写业务 Outbox。</p>
|
||||||
@@ -866,6 +882,6 @@ export function NotificationChannelsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,18 @@ export function PaymentBatchesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedBatchId = searchParams.get("id") ?? "";
|
const selectedBatchId = searchParams.get("id") ?? "";
|
||||||
|
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||||
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
||||||
const items = listQuery.data?.items ?? [];
|
return batches.find((item) => item.id === selectedBatchId) ?? batches[0] ?? null;
|
||||||
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
|
}, [batches, selectedBatchId]);
|
||||||
}, [listQuery.data?.items, selectedBatchId]);
|
const batchStats = useMemo(() => {
|
||||||
|
return {
|
||||||
|
total: batches.length,
|
||||||
|
actionable: batches.filter((item) => ["draft", "reviewed", "failed"].includes(item.status)).length,
|
||||||
|
highRisk: batches.filter((item) => item.highRisk).length,
|
||||||
|
failed: batches.filter((item) => item.status === "failed").length,
|
||||||
|
};
|
||||||
|
}, [batches]);
|
||||||
|
|
||||||
const receiptAttachmentsQuery = useQuery({
|
const receiptAttachmentsQuery = useQuery({
|
||||||
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
||||||
@@ -332,12 +340,35 @@ export function PaymentBatchesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader title="付款批次" subtitle="线下银行付款后的 OA 复核、回单留存和已付款标记;高风险批次达到 5 万元后需完成二次复核。" />
|
<WorkspaceHeader title="付款批次" subtitle="复核、回单和已付款标记集中处理。" />
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
<div className="finance-status-strip" data-testid="payment-batches-summary">
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">批次数</div>
|
||||||
|
<div className="finance-metric-card__value">{batchStats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">可处理</div>
|
||||||
|
<div className="finance-metric-card__value">{batchStats.actionable}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">高风险</div>
|
||||||
|
<div className="finance-metric-card__value">{batchStats.highRisk}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">失败批次</div>
|
||||||
|
<div className="finance-metric-card__value">{batchStats.failed}</div>
|
||||||
|
</div>
|
||||||
|
<div className="finance-metric-card">
|
||||||
|
<div className="finance-metric-card__label">当前批次</div>
|
||||||
|
<div className="finance-metric-card__value text-base">{selectedBatch?.batchNo ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="finance-workspace-grid">
|
||||||
|
<div className="finance-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
||||||
{!canManageBatch ? (
|
{!canManageBatch ? (
|
||||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||||
@@ -411,15 +442,15 @@ export function PaymentBatchesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="card-std p-4 md:p-5">
|
<div className="finance-command-panel">
|
||||||
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
|
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(listQuery.data?.items ?? []).map((item) => (
|
{batches.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={selectedBatch?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"}
|
className={selectedBatch?.id === item.id ? "finance-list-card finance-list-card--active" : "finance-list-card"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
next.set("id", item.id);
|
next.set("id", item.id);
|
||||||
@@ -443,8 +474,8 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedBatch ? (
|
{selectedBatch ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="card-std space-y-3 p-4 md:p-5">
|
<div className="finance-command-panel space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
|
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
|
||||||
@@ -499,13 +530,13 @@ export function PaymentBatchesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
|
<div className="detail-fact-grid text-sm xl:grid-cols-6">
|
||||||
<div>状态:{batchStatusLabel(selectedBatch.status)}</div>
|
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{batchStatusLabel(selectedBatch.status)}</div></div>
|
||||||
<div>实体:{entityTypeLabel(selectedBatch.entityType)}</div>
|
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
|
||||||
<div>总金额:{selectedBatch.totalAmount}</div>
|
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{selectedBatch.totalAmount}</div></div>
|
||||||
<div>默认日期:{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div>
|
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
|
||||||
<div>默认渠道:{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div>
|
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div>
|
||||||
<div>复核进度:{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div>
|
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
|
||||||
当前要求:{selectedBatch.reviewRequirement.requiredReviewCount === 2 ? `批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},需两轮复核` : "单轮复核后可执行"}。
|
当前要求:{selectedBatch.reviewRequirement.requiredReviewCount === 2 ? `批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},需两轮复核` : "单轮复核后可执行"}。
|
||||||
@@ -515,7 +546,7 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="finance-command-panel space-y-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
|
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
|
||||||
@@ -562,7 +593,7 @@ export function PaymentBatchesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => (
|
{(receiptAttachmentsQuery.data?.items ?? []).map((attachment) => (
|
||||||
<div key={attachment.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-3">
|
<div key={attachment.id} className="detail-evidence-card flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</div>
|
<div className="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
@@ -602,11 +633,11 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="finance-command-panel space-y-4">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
|
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedBatch.items.map((item) => (
|
{selectedBatch.items.map((item) => (
|
||||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
<div key={item.id} className="detail-evidence-card">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-zinc-900">{item.entityCode}</div>
|
<div className="font-medium text-zinc-900">{item.entityCode}</div>
|
||||||
|
|||||||
@@ -15,10 +15,17 @@ const ITEMS = [
|
|||||||
export function SystemSettingsPage() {
|
export function SystemSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
|
<WorkspaceHeader title="系统设置" subtitle="后台能力入口按管理职责收拢。" />
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="finance-status-strip" data-testid="system-settings-summary">
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">入口总数</div><div className="finance-metric-card__value">{ITEMS.length}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">业务配置</div><div className="finance-metric-card__value">5</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">外部桥接</div><div className="finance-metric-card__value">2</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">人员相关</div><div className="finance-metric-card__value">1</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">模式</div><div className="finance-metric-card__value text-base">小团队</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-tile-grid">
|
||||||
{ITEMS.map((item) => (
|
{ITEMS.map((item) => (
|
||||||
<Link key={item.to} to={item.to} className="card-std p-4 hover:bg-zinc-50 md:p-5">
|
<Link key={item.to} to={item.to} className="admin-tile-card">
|
||||||
<div className="text-base font-semibold text-zinc-900">{item.title}</div>
|
<div className="text-base font-semibold text-zinc-900">{item.title}</div>
|
||||||
<div className="mt-2 text-sm text-zinc-600">{item.description}</div>
|
<div className="mt-2 text-sm text-zinc-600">{item.description}</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type UserStatus,
|
type UserStatus,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from "../api/users";
|
} from "../api/users";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
@@ -76,6 +76,13 @@ export function UserManagementPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
const users = listQuery.data?.items ?? [];
|
||||||
|
const userStats = {
|
||||||
|
total: listQuery.data?.total ?? 0,
|
||||||
|
active: users.filter((item) => item.status === "active").length,
|
||||||
|
archived: users.filter((item) => item.status === "archived").length,
|
||||||
|
roles: new Set(users.flatMap((item) => item.roleCodes)).size,
|
||||||
|
};
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (payload: CreateUserFormData) => createUser(payload),
|
mutationFn: (payload: CreateUserFormData) => createUser(payload),
|
||||||
@@ -150,10 +157,10 @@ export function UserManagementPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<ListWorkspace>
|
||||||
<PageHeader
|
<WorkspaceHeader
|
||||||
title="系统用户管理"
|
title="系统用户管理"
|
||||||
subtitle="创建/启用/禁用账号,系统自动生成工号并展示一次性口令,管理员只负责最小开通与角色分配。"
|
subtitle="账号开通、停用、角色和一次性口令集中处理。"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<UserImportDialog
|
<UserImportDialog
|
||||||
@@ -213,8 +220,16 @@ export function UserManagementPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="finance-status-strip" data-testid="users-summary">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="finance-metric-card"><div className="finance-metric-card__label">用户总数</div><div className="finance-metric-card__value">{userStats.total}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前页生效</div><div className="finance-metric-card__value">{userStats.active}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前页停用</div><div className="finance-metric-card__value">{userStats.archived}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前页角色</div><div className="finance-metric-card__value">{userStats.roles}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前页</div><div className="finance-metric-card__value">{page}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-command-panel space-y-4">
|
||||||
|
<div className="ops-command-bar__row">
|
||||||
<Input
|
<Input
|
||||||
data-testid="users-search"
|
data-testid="users-search"
|
||||||
value={q}
|
value={q}
|
||||||
@@ -252,7 +267,7 @@ export function UserManagementPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<div className="ops-table-focus overflow-x-auto">
|
||||||
<Table data-testid="users-table">
|
<Table data-testid="users-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -264,8 +279,8 @@ export function UserManagementPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(listQuery.data?.items ?? []).length ? (
|
{users.length ? (
|
||||||
listQuery.data?.items.map((item) => (
|
users.map((item) => (
|
||||||
<TableRow data-testid="users-row" key={item.id}>
|
<TableRow data-testid="users-row" key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">
|
<div className="font-medium text-zinc-900">
|
||||||
@@ -352,6 +367,6 @@ export function UserManagementPage() {
|
|||||||
}}
|
}}
|
||||||
isPending={rolesMutation.isPending}
|
isPending={rolesMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export function WorkflowDefinitionsPage() {
|
|||||||
const items = listQuery.data?.items ?? [];
|
const items = listQuery.data?.items ?? [];
|
||||||
const scopedItems = useMemo(() => items.filter((item) => item.entityType === entityType), [items, entityType]);
|
const scopedItems = useMemo(() => items.filter((item) => item.entityType === entityType), [items, entityType]);
|
||||||
const selected = useMemo(() => scopedItems.find((item) => item.id === selectedId) ?? scopedItems[0] ?? null, [scopedItems, selectedId]);
|
const selected = useMemo(() => scopedItems.find((item) => item.id === selectedId) ?? scopedItems[0] ?? null, [scopedItems, selectedId]);
|
||||||
|
const workflowStats = {
|
||||||
|
total: items.length,
|
||||||
|
scoped: scopedItems.length,
|
||||||
|
draft: items.filter((item) => item.status === "draft").length,
|
||||||
|
published: items.filter((item) => item.status === "published").length,
|
||||||
|
archived: items.filter((item) => item.status === "archived").length,
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
@@ -86,9 +93,16 @@ export function WorkflowDefinitionsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
|
<WorkspaceHeader title="流程定义" subtitle="线性流程版本治理,发布动作需确认。" />
|
||||||
<div className="grid gap-6 xl:grid-cols-[220px,280px,1fr]">
|
<div className="finance-status-strip" data-testid="workflow-definitions-summary">
|
||||||
<div className="card-std space-y-3 p-4 md:p-5">
|
<div className="finance-metric-card"><div className="finance-metric-card__label">定义总数</div><div className="finance-metric-card__value">{workflowStats.total}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前业务域</div><div className="finance-metric-card__value">{workflowStats.scoped}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">草稿</div><div className="finance-metric-card__value">{workflowStats.draft}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">已发布</div><div className="finance-metric-card__value">{workflowStats.published}</div></div>
|
||||||
|
<div className="finance-metric-card"><div className="finance-metric-card__label">已归档</div><div className="finance-metric-card__value">{workflowStats.archived}</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-workspace-grid">
|
||||||
|
<div className="admin-command-panel space-y-3">
|
||||||
<Label>业务域</Label>
|
<Label>业务域</Label>
|
||||||
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}>
|
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}>
|
||||||
{ENTITY_TYPES.map((item) => <option key={item} value={item}>{item}</option>)}
|
{ENTITY_TYPES.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
@@ -96,11 +110,11 @@ export function WorkflowDefinitionsPage() {
|
|||||||
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
|
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4 md:p-5">
|
<div className="admin-command-panel space-y-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">版本列表</h3>
|
<h3 className="text-base font-semibold text-zinc-900">版本列表</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{scopedItems.map((item) => (
|
{scopedItems.map((item) => (
|
||||||
<button key={item.id} type="button" className={selected?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"} onClick={() => setSelectedId(item.id)}>
|
<button key={item.id} type="button" className={selected?.id === item.id ? "finance-list-card finance-list-card--active" : "finance-list-card"} onClick={() => setSelectedId(item.id)}>
|
||||||
<div className="font-medium text-zinc-900">v{item.version} · {item.name}</div>
|
<div className="font-medium text-zinc-900">v{item.version} · {item.name}</div>
|
||||||
<div className="text-xs text-zinc-500">{item.status} · {item.source}</div>
|
<div className="text-xs text-zinc-500">{item.status} · {item.source}</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -109,7 +123,7 @@ export function WorkflowDefinitionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="admin-command-panel space-y-4">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -157,7 +171,7 @@ export function WorkflowDefinitionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<div key={step.id} className="rounded-lg border border-zinc-200 p-4">
|
<div key={step.id} className="detail-evidence-card">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<Input value={step.id} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, id: event.target.value } : item))} />
|
<Input value={step.id} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, id: event.target.value } : item))} />
|
||||||
<Input value={step.name} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.target.value } : item))} />
|
<Input value={step.name} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.target.value } : item))} />
|
||||||
|
|||||||
@@ -0,0 +1,798 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Page, Route } from "@playwright/test";
|
||||||
|
|
||||||
|
import { expect, test } from "./helpers/fixtures";
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: "block" });
|
||||||
|
|
||||||
|
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-finance-admin-visual-wave4");
|
||||||
|
|
||||||
|
const visualUser = {
|
||||||
|
identifier: "[email protected]",
|
||||||
|
password: "VisualWave4Password1234",
|
||||||
|
name: "Wave4 Admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const allPermissions = [
|
||||||
|
"expense.create",
|
||||||
|
"expense.submit",
|
||||||
|
"expense.view_own",
|
||||||
|
"expense.view_all",
|
||||||
|
"expense.approve",
|
||||||
|
"invoice.upload",
|
||||||
|
"invoice.view_own",
|
||||||
|
"invoice.manage",
|
||||||
|
"invoice.verify",
|
||||||
|
"expense.finance.override_duplicate",
|
||||||
|
"expense.proxy_submit",
|
||||||
|
"payment.batch.manage",
|
||||||
|
"risk.review.manage",
|
||||||
|
"travel.payment.manage",
|
||||||
|
"system.user.manage",
|
||||||
|
"system.settings",
|
||||||
|
"system.audit.view",
|
||||||
|
];
|
||||||
|
|
||||||
|
const visualMe = {
|
||||||
|
id: "employee-wave4",
|
||||||
|
employeeNo: "HYD-W4",
|
||||||
|
name: visualUser.name,
|
||||||
|
email: visualUser.identifier,
|
||||||
|
phone: null,
|
||||||
|
roleCodes: ["admin", "finance", "finance_mgr", "administration", "manager"],
|
||||||
|
permissions: allPermissions,
|
||||||
|
profileCompleteness: {
|
||||||
|
hasEmergencyContact: true,
|
||||||
|
hasBankAccount: true,
|
||||||
|
hasAvatar: false,
|
||||||
|
hasIdNumber: false,
|
||||||
|
score: 92,
|
||||||
|
},
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
departmentId: "dept-wave4",
|
||||||
|
};
|
||||||
|
|
||||||
|
function envelope<T>(data: T) {
|
||||||
|
return { requestId: "mock-pc-wave4", data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson<T>(route: Route, data: T) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(data)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||||
|
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const riskFlag = {
|
||||||
|
id: "risk-wave4-1",
|
||||||
|
entityType: "reimbursement",
|
||||||
|
entityId: "reim-wave4-1",
|
||||||
|
riskCode: "budget_overspend",
|
||||||
|
level: "medium",
|
||||||
|
title: "预算超额待复核",
|
||||||
|
detail: "Wave4 视觉验证用风险摘要",
|
||||||
|
source: "mock",
|
||||||
|
status: "open",
|
||||||
|
payloadJson: {},
|
||||||
|
createdAt: "2026-05-27T09:20:00.000Z",
|
||||||
|
createdBy: visualMe.id,
|
||||||
|
resolvedAt: null,
|
||||||
|
resolvedBy: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function paymentBatch(status: "draft" | "reviewed" | "failed", suffix: string) {
|
||||||
|
const failed = status === "failed";
|
||||||
|
return {
|
||||||
|
id: `batch-wave4-${suffix}`,
|
||||||
|
batchNo: `PB20260527-${suffix}`,
|
||||||
|
entityType: "reimbursement",
|
||||||
|
name: failed ? "异常付款批次" : "五月报销付款批次",
|
||||||
|
status,
|
||||||
|
defaultPaymentDate: "2026-05-27",
|
||||||
|
defaultPaymentChannel: "bank",
|
||||||
|
voucherPrefix: "PB",
|
||||||
|
totalAmount: failed ? "860.00" : "2860.00",
|
||||||
|
highRisk: failed,
|
||||||
|
reviewRequirement: {
|
||||||
|
thresholdAmount: "5000.00",
|
||||||
|
requiredReviewCount: status === "draft" ? 1 : 2,
|
||||||
|
completedReviewCount: status === "draft" ? 0 : 1,
|
||||||
|
nextStep: status === "reviewed" ? "ready_for_execute" : "first_review",
|
||||||
|
makerCheckerRequired: false,
|
||||||
|
reviewerPermission: "risk.review.manage",
|
||||||
|
},
|
||||||
|
reviewComment: null,
|
||||||
|
secondaryReviewComment: null,
|
||||||
|
executionSummaryJson: null,
|
||||||
|
createdAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
createdBy: visualMe.id,
|
||||||
|
reviewedAt: status === "draft" ? null : "2026-05-27T09:30:00.000Z",
|
||||||
|
reviewedBy: status === "draft" ? null : visualMe.name,
|
||||||
|
secondaryReviewedAt: null,
|
||||||
|
secondaryReviewedBy: null,
|
||||||
|
executedAt: null,
|
||||||
|
executedBy: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
cancelledBy: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: `batch-item-wave4-${suffix}`,
|
||||||
|
entityId: `reim-wave4-${suffix}`,
|
||||||
|
entityCode: `BX20260527${suffix}`,
|
||||||
|
entityTitle: "Wave4 报销付款",
|
||||||
|
amount: failed ? "860.00" : "2860.00",
|
||||||
|
currencyCode: "CNY",
|
||||||
|
paymentVoucherNoOverride: null,
|
||||||
|
paymentDateOverride: null,
|
||||||
|
paymentChannelOverride: null,
|
||||||
|
status: failed ? "failed" : "pending",
|
||||||
|
failureCode: failed ? "BANK_RECEIPT_REQUIRED" : null,
|
||||||
|
failureMessage: failed ? "缺少银行回单附件" : null,
|
||||||
|
snapshotJson: {},
|
||||||
|
createdAt: "2026-05-27T09:05:00.000Z",
|
||||||
|
openRiskFlags: failed ? [riskFlag] : [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentBatches = [paymentBatch("draft", "001"), paymentBatch("reviewed", "002"), paymentBatch("failed", "003")];
|
||||||
|
|
||||||
|
const financeLedgerItem = {
|
||||||
|
id: "ledger-wave4-1",
|
||||||
|
formNumber: "BX20260527001",
|
||||||
|
reimbursementDate: "2026-05-26",
|
||||||
|
status: "approved",
|
||||||
|
totalAmount: "2860.00",
|
||||||
|
description: "Wave4 财务台账验证",
|
||||||
|
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
submitter: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
department: { id: "dept-wave4", name: "研发部" },
|
||||||
|
project: { id: "project-wave4", projectNo: "HYB-W4", name: "OA 试运行" },
|
||||||
|
payment: {
|
||||||
|
status: "unpaid",
|
||||||
|
paymentVoucherNo: null,
|
||||||
|
paymentDate: null,
|
||||||
|
paymentChannel: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
status: "available",
|
||||||
|
counts: { total: 2, pendingOcr: 0, ocrFailed: 0, available: 2, bound: 0, archived: 0, usedComplete: 0 },
|
||||||
|
},
|
||||||
|
attachmentCount: 1,
|
||||||
|
createdAt: "2026-05-27T08:50:00.000Z",
|
||||||
|
updatedAt: "2026-05-27T09:30:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoiceListItem = {
|
||||||
|
id: "invoice-wave4-1",
|
||||||
|
status: "available",
|
||||||
|
availabilityReason: null,
|
||||||
|
availabilityMessage: null,
|
||||||
|
ocrType: "vat",
|
||||||
|
ocrProvider: "pdf",
|
||||||
|
ocrAttempts: 1,
|
||||||
|
ocrLastError: null,
|
||||||
|
ocrCompletedAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
invoiceCode: "0440026",
|
||||||
|
invoiceNumber: "00012890",
|
||||||
|
invoiceDate: "2026-05-26",
|
||||||
|
amount: "1000.00",
|
||||||
|
taxAmount: "60.00",
|
||||||
|
totalAmount: "1060.00",
|
||||||
|
linkedReimbursementId: null,
|
||||||
|
linkedItemId: null,
|
||||||
|
linkedTravelOrderId: null,
|
||||||
|
originalName: "wave4-invoice.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: 128000,
|
||||||
|
verification: {
|
||||||
|
id: "verify-wave4-1",
|
||||||
|
provider: "mock",
|
||||||
|
status: "success",
|
||||||
|
resultCode: "OK",
|
||||||
|
resultMessage: "核验通过",
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: null,
|
||||||
|
durationMs: 260,
|
||||||
|
requestedBy: visualMe.id,
|
||||||
|
requestedAt: "2026-05-27T09:05:00.000Z",
|
||||||
|
completedAt: "2026-05-27T09:06:00.000Z",
|
||||||
|
retryOfId: null,
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
uploadedBy: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
createdAt: "2026-05-27T08:55:00.000Z",
|
||||||
|
updatedAt: "2026-05-27T09:06:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const departments = [
|
||||||
|
{ id: "dept-wave4", name: "研发部", parentId: null, archivedAt: null, createdAt: null, updatedAt: null },
|
||||||
|
{ id: "dept-finance-wave4", name: "财务部", parentId: null, archivedAt: null, createdAt: null, updatedAt: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const employees = [
|
||||||
|
{
|
||||||
|
id: visualMe.id,
|
||||||
|
employeeNo: visualMe.employeeNo,
|
||||||
|
name: visualMe.name,
|
||||||
|
phone: "13800000000",
|
||||||
|
email: visualUser.identifier,
|
||||||
|
status: "active",
|
||||||
|
departmentId: "dept-wave4",
|
||||||
|
department: departments[0],
|
||||||
|
directManagerId: null,
|
||||||
|
directManager: null,
|
||||||
|
title: "管理员",
|
||||||
|
employmentStatus: "active",
|
||||||
|
hireDate: "2026-01-01",
|
||||||
|
avatarUrl: null,
|
||||||
|
roleCodes: visualMe.roleCodes,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const legalEntities = [
|
||||||
|
{
|
||||||
|
id: "legal-wave4",
|
||||||
|
code: "HYD",
|
||||||
|
name: "海音达科技",
|
||||||
|
shortName: "海音达",
|
||||||
|
currencyCode: "CNY",
|
||||||
|
taxNo: null,
|
||||||
|
enabled: true,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const costCenters = [
|
||||||
|
{
|
||||||
|
id: "cost-wave4",
|
||||||
|
legalEntityId: "legal-wave4",
|
||||||
|
code: "RD",
|
||||||
|
name: "研发中心",
|
||||||
|
parentId: null,
|
||||||
|
ownerEmployeeId: visualMe.id,
|
||||||
|
enabled: true,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
legalEntity: { id: "legal-wave4", code: "HYD", name: "海音达科技" },
|
||||||
|
parent: null,
|
||||||
|
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const documentCategories = [
|
||||||
|
{
|
||||||
|
id: "doc-wave4",
|
||||||
|
code: "BX",
|
||||||
|
name: "报销单",
|
||||||
|
entityType: "reimbursement",
|
||||||
|
sequencePrefix: "BX",
|
||||||
|
numberLength: 5,
|
||||||
|
resetPeriod: "daily",
|
||||||
|
defaultWorkflowKey: "reimbursement-default",
|
||||||
|
enabled: true,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureFlags = [
|
||||||
|
{
|
||||||
|
id: "flag-payment",
|
||||||
|
flagKey: "payment_batch_v1",
|
||||||
|
overrideMode: "force_on",
|
||||||
|
rolloutType: "global",
|
||||||
|
departmentIds: [],
|
||||||
|
note: "Wave4 视觉验证",
|
||||||
|
version: 3,
|
||||||
|
createdAt: null,
|
||||||
|
createdBy: null,
|
||||||
|
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
updatedBy: visualMe.id,
|
||||||
|
envEnabled: true,
|
||||||
|
effectiveEnabled: true,
|
||||||
|
effectiveReason: "force_on",
|
||||||
|
matchedDepartmentId: null,
|
||||||
|
requiresDepartment: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "flag-erp",
|
||||||
|
flagKey: "erp_sync",
|
||||||
|
overrideMode: "force_off",
|
||||||
|
rolloutType: "global",
|
||||||
|
departmentIds: [],
|
||||||
|
note: "保持冻结",
|
||||||
|
version: 2,
|
||||||
|
createdAt: null,
|
||||||
|
createdBy: null,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
envEnabled: false,
|
||||||
|
effectiveEnabled: false,
|
||||||
|
effectiveReason: "force_off",
|
||||||
|
matchedDepartmentId: null,
|
||||||
|
requiresDepartment: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowDefinitions = [
|
||||||
|
{
|
||||||
|
id: "workflow-wave4-1",
|
||||||
|
entityType: "leave",
|
||||||
|
name: "请假审批默认流程",
|
||||||
|
version: 1,
|
||||||
|
status: "published",
|
||||||
|
steps: [
|
||||||
|
{ id: "manager-review", name: "直属主管审批", assigneeRule: { type: "department_manager" } },
|
||||||
|
{ id: "admin-archive", name: "行政留档", assigneeRule: { type: "role", value: "administration" } },
|
||||||
|
],
|
||||||
|
notes: "Wave4 视觉验证",
|
||||||
|
source: "manual",
|
||||||
|
createdAt: "2026-05-27T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
publishedAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const notificationChannels = [
|
||||||
|
{
|
||||||
|
id: "channel-wave4-1",
|
||||||
|
channelType: "wecom",
|
||||||
|
name: "企业微信工作台应用消息",
|
||||||
|
enabled: true,
|
||||||
|
configJson: {
|
||||||
|
mode: "wecom_app_message",
|
||||||
|
webhookUrl: null,
|
||||||
|
timeoutMs: 3000,
|
||||||
|
routing: { roleCodes: ["manager", "finance"], domainScopes: ["workflow", "reimbursement"], allowedTypes: [] },
|
||||||
|
secretConfigured: true,
|
||||||
|
secretMasked: "******",
|
||||||
|
},
|
||||||
|
templateJson: {
|
||||||
|
title: "{{title}}",
|
||||||
|
body: "{{body}}",
|
||||||
|
footer: "进入 OA 查看详情",
|
||||||
|
deepLinkLabel: "查看",
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-27T08:30:00.000Z",
|
||||||
|
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const notificationDeliveries = [
|
||||||
|
{
|
||||||
|
notificationId: "notify-wave4-1",
|
||||||
|
channelId: "channel-wave4-1",
|
||||||
|
channelName: "企业微信工作台应用消息",
|
||||||
|
channelType: "wecom",
|
||||||
|
notificationType: "workflow.task_assigned",
|
||||||
|
notificationTitle: "你有新的审批",
|
||||||
|
status: "failed",
|
||||||
|
attemptCount: 2,
|
||||||
|
latestAttemptAt: "2026-05-27T09:20:00.000Z",
|
||||||
|
resultCode: "MOCK_FAILED",
|
||||||
|
errorMessage: "Wave4 mock delivery",
|
||||||
|
responseBody: null,
|
||||||
|
notificationCreatedAt: "2026-05-27T09:10:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function mockWave4Apis(page: Page) {
|
||||||
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathName = url.pathname;
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/capabilities") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
emailDelivery: { ready: true, statusReasons: [] },
|
||||||
|
emailLoginCode: { enabled: true },
|
||||||
|
passwordReset: { enabled: true },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/sso/providers") {
|
||||||
|
await fulfillJson(route, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/wecom/config") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
enabled: true,
|
||||||
|
entryEnabled: true,
|
||||||
|
oauthEnabled: true,
|
||||||
|
corpId: "wx-wave4",
|
||||||
|
agentId: "1000002",
|
||||||
|
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||||
|
bindEnabled: true,
|
||||||
|
defaultReturnTo: "/portal",
|
||||||
|
status: "ready",
|
||||||
|
statusReasons: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||||
|
body: JSON.stringify(envelope({ accessToken: "mock-pc-wave4-token", expiresIn: 900, mustChangePassword: false })),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/me") {
|
||||||
|
await fulfillJson(route, visualMe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/system/navigation") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: {
|
||||||
|
id: "workspace-wave4",
|
||||||
|
persisted: true,
|
||||||
|
profile: "small_team_internal",
|
||||||
|
orgMode: "single_company",
|
||||||
|
primaryEntryMode: "password",
|
||||||
|
notificationStrategy: "in_app",
|
||||||
|
devicePriority: "desktop",
|
||||||
|
moduleVisibility: {},
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
main: [
|
||||||
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||||
|
navigationItem("payment-batches", "/payment-batches", "付款批次", "payments"),
|
||||||
|
navigationItem("ap-operations", "/ap-operations", "AP 运营", "account_balance_wallet"),
|
||||||
|
navigationItem("invoice-pool", "/invoice-pool", "票据中心", "receipt_long"),
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
navigationItem("users", "/system/users", "用户管理", "group", "admin"),
|
||||||
|
navigationItem("master-data", "/system/master-data", "主数据", "database", "admin"),
|
||||||
|
navigationItem("notification-channels", "/system/notification-channels", "通知渠道", "notifications", "admin"),
|
||||||
|
navigationItem("settings", "/system/settings", "系统设置", "settings", "admin"),
|
||||||
|
navigationItem("feature-flags", "/system/feature-flags", "功能开关", "toggle_on", "admin"),
|
||||||
|
navigationItem("workflow-definitions", "/system/workflow-definitions", "流程定义", "schema", "admin"),
|
||||||
|
navigationItem("audit-logs", "/audit-logs", "审计日志", "manage_search", "admin"),
|
||||||
|
],
|
||||||
|
backup: [],
|
||||||
|
},
|
||||||
|
recommendedRolePresets: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/notifications") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 5, total: 0, unreadOnly: false, unreadCount: 0, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/portal/home") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: { id: "workspace-wave4", persisted: true, profile: "small_team_internal" },
|
||||||
|
greeting: { name: visualMe.name },
|
||||||
|
cards: [],
|
||||||
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||||
|
myApplications: [],
|
||||||
|
riskSummary: null,
|
||||||
|
announcements: [],
|
||||||
|
quickLinks: [],
|
||||||
|
todayPresence: null,
|
||||||
|
teamPresence: null,
|
||||||
|
weeklyReport: null,
|
||||||
|
weeklySummary: null,
|
||||||
|
todoSummary: null,
|
||||||
|
projectSummary: null,
|
||||||
|
peopleLifecycle: null,
|
||||||
|
financeSummary: null,
|
||||||
|
adoptionSummary: null,
|
||||||
|
roleCapabilities: {
|
||||||
|
canSubmitApplications: true,
|
||||||
|
canApproveTasks: true,
|
||||||
|
canUseFinance: true,
|
||||||
|
canManageSystem: true,
|
||||||
|
canViewTeam: true,
|
||||||
|
isAdminOnly: false,
|
||||||
|
},
|
||||||
|
recentActivities: [],
|
||||||
|
teamStatusBoard: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/payment-batches") {
|
||||||
|
await fulfillJson(route, { items: paymentBatches });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentReceiptMatch = pathName.match(/^\/api\/payment-batches\/([^/]+)\/receipt-attachments$/);
|
||||||
|
if (paymentReceiptMatch) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: `receipt-${paymentReceiptMatch[1]}`,
|
||||||
|
originalName: "bank-receipt-wave4.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: 248000,
|
||||||
|
fileSha256: "hash-wave4",
|
||||||
|
uploadedBy: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
createdAt: "2026-05-27T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentBatchDetailMatch = pathName.match(/^\/api\/payment-batches\/([^/]+)$/);
|
||||||
|
if (paymentBatchDetailMatch) {
|
||||||
|
const item = paymentBatches.find((batch) => batch.id === paymentBatchDetailMatch[1]) ?? paymentBatches[0];
|
||||||
|
await fulfillJson(route, { item });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/ap-operations") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
summary: {
|
||||||
|
pendingPaymentCount: 2,
|
||||||
|
partialMatchCount: 1,
|
||||||
|
exceptionCount: 1,
|
||||||
|
budgetOverspendCount: 1,
|
||||||
|
},
|
||||||
|
pendingPaymentBatches: [paymentBatches[0]],
|
||||||
|
failedPaymentBatchItems: [],
|
||||||
|
exceptionRiskFlags: [riskFlag],
|
||||||
|
budgetOverspendRisks: [riskFlag],
|
||||||
|
partialMatchItems: [
|
||||||
|
{
|
||||||
|
id: "recon-item-wave4-1",
|
||||||
|
travelReconciliationBatchId: "recon-wave4-1",
|
||||||
|
travelOrderId: "travel-order-wave4-1",
|
||||||
|
travelRequestId: "travel-wave4-1",
|
||||||
|
matchStatus: "amount_mismatch",
|
||||||
|
resolutionStatus: "pending",
|
||||||
|
anomalyCode: "amount_mismatch",
|
||||||
|
orderAmount: "860.00",
|
||||||
|
transactionAmount: "900.00",
|
||||||
|
currency: "CNY",
|
||||||
|
createdAt: "2026-05-27T08:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/ap-operations/reimbursement-ledger") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, items: [financeLedgerItem] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/risk-flags") {
|
||||||
|
await fulfillJson(route, { items: [riskFlag] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/invoice-pool") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceListItem] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/invoice-pool/${invoiceListItem.id}`) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
...invoiceListItem,
|
||||||
|
archivedReason: null,
|
||||||
|
archivedAt: null,
|
||||||
|
ownerEmployeeId: visualMe.id,
|
||||||
|
uploadedByEmployeeId: visualMe.id,
|
||||||
|
fileSha256: "hash-wave4",
|
||||||
|
remarks: "Wave4 票据详情",
|
||||||
|
sellerName: "示例服务有限公司",
|
||||||
|
buyerName: "海音达科技",
|
||||||
|
isDuplicateOverride: false,
|
||||||
|
overrideRefFormNumber: null,
|
||||||
|
overrideReason: null,
|
||||||
|
overriddenBy: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/invoice-pool/${invoiceListItem.id}/verification`) {
|
||||||
|
await fulfillJson(route, { invoiceId: invoiceListItem.id, items: [invoiceListItem.verification] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/reimbursements") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 1,
|
||||||
|
scope: "own",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "reim-wave4-1",
|
||||||
|
formNumber: "BX20260527001",
|
||||||
|
status: "approved",
|
||||||
|
reimbursementDate: "2026-05-26",
|
||||||
|
totalAmount: "2860.00",
|
||||||
|
description: "Wave4 绑定验证",
|
||||||
|
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
submitter: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||||
|
project: null,
|
||||||
|
pendingWorkflow: null,
|
||||||
|
createdAt: "2026-05-27T08:50:00.000Z",
|
||||||
|
updatedAt: "2026-05-27T09:30:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/travel-requests") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 50, total: 0, mine: false, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/delegations/mine") {
|
||||||
|
await fulfillJson(route, { status: "active", initiated: [], received: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/users") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: visualMe.id,
|
||||||
|
employeeNo: visualMe.employeeNo,
|
||||||
|
name: visualMe.name,
|
||||||
|
phone: "13800000000",
|
||||||
|
email: visualUser.identifier,
|
||||||
|
status: "active",
|
||||||
|
departmentId: "dept-wave4",
|
||||||
|
title: "管理员",
|
||||||
|
roleCodes: visualMe.roleCodes,
|
||||||
|
createdAt: "2026-05-27T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/departments") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: departments, tree: departments.map((item) => ({ ...item, children: [] })) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/employees") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 200, total: employees.length, items: employees });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/legal-entities") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: legalEntities });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/cost-centers") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: costCenters });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/master-data/document-categories") {
|
||||||
|
await fulfillJson(route, { includeArchived: false, items: documentCategories });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/notifications/channels") {
|
||||||
|
await fulfillJson(route, { items: notificationChannels });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/notifications/deliveries") {
|
||||||
|
await fulfillJson(route, { items: notificationDeliveries });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/system/feature-flags") {
|
||||||
|
await fulfillJson(route, { items: featureFlags });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/workflow-definitions") {
|
||||||
|
await fulfillJson(route, { items: workflowDefinitions });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/audit-logs") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "audit-wave4-1",
|
||||||
|
requestId: "mock-wave4-request",
|
||||||
|
actorId: visualMe.id,
|
||||||
|
actorName: visualMe.name,
|
||||||
|
actorEmail: visualUser.identifier,
|
||||||
|
action: "PAYMENT_BATCH_REVIEW",
|
||||||
|
targetType: "payment_batch",
|
||||||
|
targetId: "batch-wave4-001",
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
userAgent: "Playwright",
|
||||||
|
extra: {},
|
||||||
|
createdAt: "2026-05-27T09:30:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
|
||||||
|
await page.getByTestId("login-identifier").fill(visualUser.identifier);
|
||||||
|
await page.getByTestId("login-password").fill(visualUser.password);
|
||||||
|
await page.getByTestId("login-submit").click();
|
||||||
|
await expect(page).toHaveURL(/\/portal/);
|
||||||
|
await expect(page.getByTestId("topbar-profile-trigger").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(page: Page, name: string) {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
await page.screenshot({ path: path.join(outputDir, `${name}.png`), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("PC 财务与管理页 Wave4 视觉冒烟", async ({ page }) => {
|
||||||
|
await mockWave4Apis(page);
|
||||||
|
await page.setViewportSize({ width: 1440, height: 960 });
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
for (const target of pages) {
|
||||||
|
await page.goto(target.path);
|
||||||
|
await expect(page.getByTestId(target.summary)).toBeVisible();
|
||||||
|
await expect(page.locator(target.proof).first()).toBeVisible();
|
||||||
|
await expect(page.locator(".finance-status-strip").first()).toBeVisible();
|
||||||
|
await capture(page, target.screenshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户