优化财务与管理页视觉 Wave4(05-27-pc-finance-admin-visual-wave4)

这个提交包含在:
2026-05-27 22:23:45 +08:00
父节点 8b3db8fa4e
当前提交 c5eb27eac8
共修改 18 个文件,包含 1379 行新增和 96 行删除
+72
查看文件
@@ -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;
}
+14 -14
查看文件
@@ -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>
+15 -7
查看文件
@@ -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>
);
}
+21 -7
查看文件
@@ -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>
);
}
+35 -2
查看文件
@@ -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}
+31 -12
查看文件
@@ -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>
+27 -11
查看文件
@@ -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>
);
}
+54 -23
查看文件
@@ -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>
+10 -3
查看文件
@@ -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>
+25 -10
查看文件
@@ -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>
);
}
+21 -7
查看文件
@@ -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);
}
});