优化财务与管理页视觉 Wave4(05-27-pc-finance-admin-visual-wave4)
这个提交包含在:
@@ -299,3 +299,45 @@ onNeedRefresh() {
|
||||
<PasswordInput id="login-password" />
|
||||
<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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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";
|
||||
|
||||
return (
|
||||
<div className="card-std p-5">
|
||||
<div className="text-sm text-zinc-500">{title}</div>
|
||||
<div className={`mt-2 text-2xl font-bold ${toneClass}`}>{value}</div>
|
||||
<div className="finance-metric-card">
|
||||
<div className="finance-metric-card__label">{title}</div>
|
||||
<div className={`finance-metric-card__value ${toneClass}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -618,7 +618,7 @@ export function ApOperationsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="财务付款与对账"
|
||||
subtitle="待付款、异常付款、风险和对账。"
|
||||
subtitle="待付款、异常付款、风险、对账和台账集中处理。"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@@ -661,7 +661,7 @@ export function ApOperationsPage() {
|
||||
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
|
||||
) : 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.exceptionCount ?? "-"} tone="red" />
|
||||
<SummaryCard
|
||||
@@ -677,9 +677,9 @@ export function ApOperationsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-4 md:p-5">
|
||||
<div className="finance-command-panel">
|
||||
<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="failed">异常付款 {failedItems.length}</TabsTrigger>
|
||||
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
||||
@@ -688,7 +688,7 @@ export function ApOperationsPage() {
|
||||
</TabsList>
|
||||
|
||||
<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="text-sm font-medium text-zinc-700">审批状态</div>
|
||||
<select
|
||||
@@ -791,7 +791,7 @@ export function ApOperationsPage() {
|
||||
</Button>
|
||||
</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>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -901,7 +901,7 @@ export function ApOperationsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<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="text-sm font-medium text-zinc-700">实体类型</div>
|
||||
<select
|
||||
@@ -943,7 +943,7 @@ export function ApOperationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<div className="ops-table-focus overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -1046,7 +1046,7 @@ export function ApOperationsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -1170,7 +1170,7 @@ export function ApOperationsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<div className="ops-table-focus overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -1248,7 +1248,7 @@ export function ApOperationsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { isApiError } from "../api/types";
|
||||
import { exportAuditLogs } from "../api/exports";
|
||||
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
@@ -68,10 +68,10 @@ export function AuditLogsPage() {
|
||||
}, [actionFilter, page, targetTypeFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="审计工作台"
|
||||
subtitle="只读查看系统关键操作审计记录。"
|
||||
subtitle="关键操作审计只读查询与导出。"
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<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">
|
||||
<label className="text-sm text-zinc-700">
|
||||
Action
|
||||
@@ -177,7 +185,7 @@ export function AuditLogsPage() {
|
||||
</p>
|
||||
) : 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">
|
||||
<thead className="bg-zinc-50 text-zinc-600">
|
||||
<tr>
|
||||
@@ -251,6 +259,6 @@ export function AuditLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
type FeatureFlagSnapshot,
|
||||
} from "../api/featureFlags";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
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 {
|
||||
Dialog,
|
||||
@@ -261,6 +260,13 @@ export function FeatureFlagsPage() {
|
||||
}, [departmentsQuery.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(
|
||||
() => items.filter((item) => selectedFlagKeys.includes(item.flagKey)),
|
||||
[items, selectedFlagKeys],
|
||||
@@ -316,10 +322,10 @@ export function FeatureFlagsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={batchMode ? "space-y-6 pb-32" : "space-y-6"}>
|
||||
<PageHeader
|
||||
<ListWorkspace className={batchMode ? "pb-32" : undefined}>
|
||||
<WorkspaceHeader
|
||||
title="功能开关"
|
||||
subtitle="外部桥接、验真和灰度开关。"
|
||||
subtitle="外部桥接、验真、灰度和试运行边界。"
|
||||
/>
|
||||
<WorkspaceNotice title="安全摘要" tone="warning">
|
||||
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>
|
||||
) : 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
|
||||
title="开关列表"
|
||||
description="单条编辑或批量切换,必须填写变更说明。"
|
||||
@@ -351,7 +365,7 @@ export function FeatureFlagsPage() {
|
||||
) : null}
|
||||
|
||||
{!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">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -831,6 +845,6 @@ export function FeatureFlagsPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : 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 { isApiError } from "../api/types";
|
||||
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 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(
|
||||
detail &&
|
||||
(canManageInvoice ||
|
||||
@@ -449,7 +459,7 @@ export function InvoicePoolPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="票据中心"
|
||||
subtitle="上传、识别、绑定。"
|
||||
subtitle="上传、识别、绑定和异常复核集中处理。"
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -480,6 +490,29 @@ export function InvoicePoolPage() {
|
||||
</p>
|
||||
) : 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
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
|
||||
@@ -169,6 +169,15 @@ export function MasterDataPage() {
|
||||
const legalEntities = legalEntitiesQuery.data?.items ?? [];
|
||||
const costCenters = costCentersQuery.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(() => {
|
||||
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>
|
||||
) : 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)}>
|
||||
<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="cost-centers">成本中心</TabsTrigger>
|
||||
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
||||
@@ -325,7 +342,7 @@ export function MasterDataPage() {
|
||||
</TabsList>
|
||||
|
||||
<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">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
|
||||
<select
|
||||
@@ -341,7 +358,7 @@ export function MasterDataPage() {
|
||||
</FilterBar>
|
||||
|
||||
{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>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
@@ -377,11 +394,11 @@ export function MasterDataPage() {
|
||||
</div>
|
||||
) : 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>
|
||||
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
||||
{!legalEntitiesQuery.isLoading ? (
|
||||
<PrimaryTable>
|
||||
<PrimaryTable className="ops-table-focus">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -438,7 +455,7 @@ export function MasterDataPage() {
|
||||
</TabsContent>
|
||||
|
||||
<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">
|
||||
<select
|
||||
className="select-std"
|
||||
@@ -467,7 +484,7 @@ export function MasterDataPage() {
|
||||
</FilterBar>
|
||||
|
||||
{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>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
@@ -534,11 +551,11 @@ export function MasterDataPage() {
|
||||
</div>
|
||||
) : 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>
|
||||
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||
{!costCentersQuery.isLoading ? (
|
||||
<PrimaryTable>
|
||||
<PrimaryTable className="ops-table-focus">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -597,7 +614,7 @@ export function MasterDataPage() {
|
||||
</TabsContent>
|
||||
|
||||
<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">
|
||||
<select
|
||||
className="select-std"
|
||||
@@ -626,7 +643,7 @@ export function MasterDataPage() {
|
||||
</FilterBar>
|
||||
|
||||
{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>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
@@ -697,10 +714,11 @@ export function MasterDataPage() {
|
||||
</div>
|
||||
) : 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>
|
||||
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||
{!documentCategoriesQuery.isLoading ? (
|
||||
<PrimaryTable className="ops-table-focus">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -757,6 +775,7 @@ export function MasterDataPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PrimaryTable>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type NotificationChannelWriteConfig,
|
||||
} from "../api/notificationChannels";
|
||||
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 { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -325,14 +325,22 @@ export function NotificationChannelsPage() {
|
||||
}
|
||||
|
||||
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 isAppMessageMode = form.mode === "wecom_app_message";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="通知渠道"
|
||||
subtitle="维护企微通知渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
|
||||
subtitle="渠道、路由、测试发送和投递追踪集中治理。"
|
||||
actions={
|
||||
<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>
|
||||
) : 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">
|
||||
<h3 className="text-base font-semibold text-zinc-900">渠道列表</h3>
|
||||
<p className="text-sm text-zinc-500">当前共 {channels.length} 条渠道配置</p>
|
||||
@@ -359,7 +375,7 @@ export function NotificationChannelsPage() {
|
||||
) : null}
|
||||
|
||||
{!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">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -428,7 +444,7 @@ export function NotificationChannelsPage() {
|
||||
) : null}
|
||||
</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>
|
||||
<h3 className="text-base font-semibold text-zinc-900">最近投递记录</h3>
|
||||
@@ -474,7 +490,7 @@ export function NotificationChannelsPage() {
|
||||
) : null}
|
||||
|
||||
{!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">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -520,7 +536,7 @@ export function NotificationChannelsPage() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h3 className="text-base font-semibold text-zinc-900">
|
||||
@@ -818,7 +834,7 @@ export function NotificationChannelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="admin-command-panel space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">测试发送</h3>
|
||||
<p className="text-sm text-zinc-500">仅对已保存渠道执行同步测试,不写业务 Outbox。</p>
|
||||
@@ -866,6 +882,6 @@ export function NotificationChannelsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,10 +123,18 @@ export function PaymentBatchesPage() {
|
||||
});
|
||||
|
||||
const selectedBatchId = searchParams.get("id") ?? "";
|
||||
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
|
||||
}, [listQuery.data?.items, selectedBatchId]);
|
||||
return batches.find((item) => item.id === selectedBatchId) ?? batches[0] ?? null;
|
||||
}, [batches, 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({
|
||||
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
||||
@@ -332,12 +340,35 @@ export function PaymentBatchesPage() {
|
||||
|
||||
return (
|
||||
<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}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<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">{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>
|
||||
{!canManageBatch ? (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card-std p-4 md:p-5">
|
||||
<div className="space-y-5">
|
||||
<div className="finance-command-panel">
|
||||
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
|
||||
<div className="space-y-2">
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
{batches.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
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={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("id", item.id);
|
||||
@@ -443,8 +474,8 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
|
||||
{selectedBatch ? (
|
||||
<div className="space-y-6">
|
||||
<div className="card-std space-y-3 p-4 md:p-5">
|
||||
<div className="space-y-5">
|
||||
<div className="finance-command-panel space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
|
||||
@@ -499,13 +530,13 @@ export function PaymentBatchesPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
|
||||
<div>状态:{batchStatusLabel(selectedBatch.status)}</div>
|
||||
<div>实体:{entityTypeLabel(selectedBatch.entityType)}</div>
|
||||
<div>总金额:{selectedBatch.totalAmount}</div>
|
||||
<div>默认日期:{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div>
|
||||
<div>默认渠道:{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div>
|
||||
<div>复核进度:{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div>
|
||||
<div className="detail-fact-grid text-sm xl:grid-cols-6">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{batchStatusLabel(selectedBatch.status)}</div></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 className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{selectedBatch.totalAmount}</div></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 className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></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 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},需两轮复核` : "单轮复核后可执行"}。
|
||||
@@ -515,7 +546,7 @@ export function PaymentBatchesPage() {
|
||||
</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>
|
||||
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
|
||||
@@ -562,7 +593,7 @@ export function PaymentBatchesPage() {
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{(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="truncate text-sm font-medium text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
@@ -602,11 +633,11 @@ export function PaymentBatchesPage() {
|
||||
</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>
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
<div className="font-medium text-zinc-900">{item.entityCode}</div>
|
||||
|
||||
@@ -15,10 +15,17 @@ const ITEMS = [
|
||||
export function SystemSettingsPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<WorkspaceHeader title="系统设置" subtitle="后台能力入口按管理职责收拢。" />
|
||||
<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) => (
|
||||
<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="mt-2 text-sm text-zinc-600">{item.description}</div>
|
||||
</Link>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type UserStatus,
|
||||
type UserSummary,
|
||||
} from "../api/users";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
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 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({
|
||||
mutationFn: (payload: CreateUserFormData) => createUser(payload),
|
||||
@@ -150,10 +157,10 @@ export function UserManagementPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="系统用户管理"
|
||||
subtitle="创建/启用/禁用账号,系统自动生成工号并展示一次性口令,管理员只负责最小开通与角色分配。"
|
||||
subtitle="账号开通、停用、角色和一次性口令集中处理。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<UserImportDialog
|
||||
@@ -213,8 +220,16 @@ export function UserManagementPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="finance-status-strip" data-testid="users-summary">
|
||||
<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
|
||||
data-testid="users-search"
|
||||
value={q}
|
||||
@@ -252,7 +267,7 @@ export function UserManagementPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<div className="ops-table-focus overflow-x-auto">
|
||||
<Table data-testid="users-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -264,8 +279,8 @@ export function UserManagementPage() {
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(listQuery.data?.items ?? []).length ? (
|
||||
listQuery.data?.items.map((item) => (
|
||||
{users.length ? (
|
||||
users.map((item) => (
|
||||
<TableRow data-testid="users-row" key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
@@ -352,6 +367,6 @@ export function UserManagementPage() {
|
||||
}}
|
||||
isPending={rolesMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ export function WorkflowDefinitionsPage() {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
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 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(() => {
|
||||
if (!selected) return;
|
||||
@@ -86,9 +93,16 @@ export function WorkflowDefinitionsPage() {
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
|
||||
<div className="grid gap-6 xl:grid-cols-[220px,280px,1fr]">
|
||||
<div className="card-std space-y-3 p-4 md:p-5">
|
||||
<WorkspaceHeader title="流程定义" subtitle="线性流程版本治理,发布动作需确认。" />
|
||||
<div className="finance-status-strip" data-testid="workflow-definitions-summary">
|
||||
<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>
|
||||
<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>)}
|
||||
@@ -96,11 +110,11 @@ export function WorkflowDefinitionsPage() {
|
||||
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
|
||||
</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>
|
||||
<div className="space-y-2">
|
||||
{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="text-xs text-zinc-500">{item.status} · {item.source}</div>
|
||||
</button>
|
||||
@@ -109,7 +123,7 @@ export function WorkflowDefinitionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<div className="admin-command-panel space-y-4">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -157,7 +171,7 @@ export function WorkflowDefinitionsPage() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
<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))} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户