统一业务台账首屏设计(05-30-unified-page-design-review)

这个提交包含在:
2026-05-30 19:54:22 +08:00
父节点 8423ca63b5
当前提交 d5ef8189f1
共修改 15 个文件,包含 121 行新增和 446 行删除
@@ -74,6 +74,23 @@ reference, not a copy of Feishu or Ant Design branding.
action, visible common filters, table/card list and right-side operation
column. `台账工具` contains export, batch, statistics, templates, full-scope
views and advanced filters.
- Business ledger first screens are single-column list pages. Do not use a
default left-list/right-detail workspace for procurement, contracts, projects,
assets, payments, AP or invoices. Open details through a detail route, drawer
or explicit detail/edit area after the user chooses a row; the initial list
page must not auto-select the first item just to fill a second column.
- Ledger status summaries use tabs, segmented controls or compact chips with
counts. Do not place four or five large status cards above ordinary business
lists, and do not put explanatory sentences inside status cards.
- Ledger filters fit the first PC row around `search`, `status` and `scope`
when those capabilities exist. Supplier, legal entity, cost center, date
ranges, contract type, sensitivity, export and test-data toggles belong in
`筛选`, `台账工具` or a tab-specific advanced area; default-visible filters
should not occupy multiple rows.
- Business list columns show object identity, counterpart/person, amount/date,
state and operation. `下一步` is not a default list column or right-side help
panel. Use short state labels such as `待补材料` or `待你处理`; longer guidance
belongs in detail pages, exception blocks or confirmations.
- PC detail pages use an Ant Design Pro style detail workspace: top summary,
main facts/details/evidence, right-side state and action panel, and bottom
history. Do not build PC details as a free stack of equal-weight large cards.
@@ -93,10 +110,10 @@ reference, not a copy of Feishu or Ant Design branding.
| Surface | Template | First-screen content | Primary action rule |
| --- | --- | --- | --- |
| `工作台` | 工作台总览 | 待办摘要、常用业务入口、必要提醒 | No forced header action. Use `去处理`, `新建报销`, `查看全部` inside relevant sections. |
| `工作台` | 工作台总览 | Compact counts, common entries, real reminders | No forced header action. Use `去处理`, `新建报销`, `查看全部` inside relevant sections; do not show helper sentences for ordinary items. |
| `待我处理` | 任务处理列表 | `待处理` / `已处理` tabs, count, minimal filters, task list | Row action is `处理`; `已处理记录` is a tab, not a separate header button. |
| `审批详情` | 详情工作区 | Summary, facts/evidence, right summary/action panel, history | One action bar: primary `通过`, secondary `退回`/`驳回`, `转审` in `更多`. |
| `报销` / `采购` / `合同` / `项目` / `资产` / `付款` / `应付账款` / `票据` | 业务台账列表 | Common filters plus table/card list | Header creation only when the route owns creation; risky tools in `台账工具` or detail. |
| `报销` / `采购` / `合同` / `项目` / `资产` / `付款` / `应付账款` / `票据` | 业务台账列表 | One-row common filters plus table/card list | Header creation only when the route owns creation; risky tools in `台账工具` or detail; no default left-list/right-detail split. |
| Business details | 详情工作区 | Summary, facts, evidence, relations, history | Right-side state/action panel on PC; bottom primary action on mobile. |
| `管理` | 管理配置页 | Grouped landing: `人员组织`, `审批流程`, `基础资料`, `系统设置`, `审计排障` | Management subpages use search/filter, table and right operation column. |
| `个人设置` | 管理配置页 variant | `个人资料`, `账号安全`, `通知偏好`, `企业微信绑定` | No employee dossier, permission matrix or org maintenance in the default profile path. |
@@ -14,11 +14,15 @@
3. 页头主按钮只用于创建类或真实页面级主动作,例如 `新建报销`、`新建采购`、`新增合同`、`新增资产`。
4. 按钮使用成熟系统常规文案:`处理`、`去处理`、`查看详情`、`查看全部`、`查看进度`、`继续编辑`、`新建`、`新增`、`提交`、`保存`、`更多`。禁止 `处理最急的一件` 这类口号型按钮。
5. 列表页右侧固定操作列;低频或高风险动作进入 `更多`、`台账工具` 或详情页。
6. PC 详情页采用顶部摘要、中部事实/明细/证据、右侧状态与动作栏、底部历史;移动端采用单列分区和底部安全区主动作。
7. 审批详情不使用竖向动作卡片。PC 使用一个动作条:`通过` 主按钮,`退回`、`驳回` 次按钮,`转审` 默认进 `更多`;需要理由的动作点击后再展开输入和确认。
8. 移动端底部只保留一个主动作,次动作进入 `更多` 或确认面板。
9. 通知中心只做事件收件箱,不复制待办,不做快速上手或流程解释。
10. 请假、补卡、外出、加班和打卡由企业微信主办;OA 只做记录、镜像、异常、审计和应急入口。
6. 业务台账首屏统一为单列列表页,不默认使用左列表/右详情,不自动选中第一条记录填充右侧详情。
7. 状态统计统一降级为 Tab、分段控件或 Chip + 数量;普通业务列表上方不放多张大状态卡片和说明句。
8. 筛选区默认一行,只放搜索、状态、范围;供应商、法体、成本中心、日期区间、合同类型、敏感级别、导出和测试数据开关进入 `筛选`、`台账工具` 或 Tab 内高级区域。
9. 业务列表默认不放 `下一步` 列或右侧提示区;异常用短状态标签表达,长说明进入详情、异常块或确认弹窗。
10. PC 详情页采用顶部摘要、中部事实/明细/证据、右侧状态与动作栏、底部历史;移动端采用单列分区和底部安全区主动作。
11. 审批详情不使用竖向动作卡片。PC 使用一个动作条:`通过` 主按钮,`退回`、`驳回` 次按钮,`转审` 默认进 `更多`;需要理由的动作点击后再展开输入和确认。
12. 移动端底部只保留一个主动作,次动作进入 `更多` 或确认面板。
13. 通知中心只做事件收件箱,不复制待办,不做快速上手或流程解释。
14. 请假、补卡、外出、加班和打卡由企业微信主办;OA 只做记录、镜像、异常、审计和应急入口。
## 页面归类
@@ -27,7 +31,7 @@
| 工作台 | 工作台总览 | 待办摘要、常用业务入口、必要提醒;不强行放页头主按钮。 |
| 待我处理 | 任务处理列表 | `待处理` / `已处理` 两个 Tab;行操作为 `处理`。 |
| 审批详情 | 详情工作区 | 摘要、证据、右侧动作栏、历史;禁止竖向动作卡片。 |
| 报销、采购、合同、项目、资产、付款、应付账款、票据 | 业务台账列表 | 标题、创建按钮、常用筛选、表格/卡片列表、右侧操作列;低频工具进 `台账工具`。 |
| 报销、采购、合同、项目、资产、付款、应付账款、票据 | 业务台账列表 | 标题、创建按钮、一行常用筛选、表格/卡片列表、右侧操作列;低频工具进 `台账工具`;首屏不做左列表/右详情。 |
| 业务详情 | 详情工作区 | 摘要、事实、证据、关联、右侧状态/动作、底部历史。 |
| 管理 | 管理配置页 | 管理首页分 `人员组织`、`审批流程`、`基础资料`、`系统设置`、`审计排障`;子页表格配置。 |
| 个人设置 | 账号设置页 | `个人资料`、`账号安全`、`通知偏好`、`企业微信绑定`;组织角色只读折叠,维护入口进管理。 |
@@ -54,6 +54,10 @@
- 侧边栏入口已确认:常用业务直接展示,管理/低频/冻结/阶段性页面进入 `更多`。默认直接入口包括 `工作台`、`待我处理`、`报销`、`采购`、`合同`、`项目`、`资产`、`付款`、`应付账款`、`票据`、`团队`、`请假记录`、`管理`,但仍需按角色和权限收敛,不能按权限把所有可见模块全铺出来。
- `待我处理` 页面已确认:采用飞书审批式任务列表,主 Tab 为 `待处理` / `已处理`。顶部只显示数量和必要筛选;列表行展示事项、申请人/发起人、等待时间、状态、业务编号和右侧 `处理` 操作。`已处理记录` 不作为孤立右上角按钮,页面不显示解释性副标题或大段说明。
- 报销、采购、合同、项目、资产、付款、应付账款、票据等业务页已确认统一采用 `业务台账列表`:页头标题与创建按钮、常用筛选区、表格/卡片列表、右侧操作列。低频或高风险工具统一进入 `台账工具`、`更多` 或详情页,不在列表首屏铺开。
- 二次线上反馈后确认业务台账页继续收紧:首屏统一为单列列表,不默认使用左列表/右详情,不自动选中第一条记录;状态摘要改为 Tab/Chip + 数量;筛选区默认只放搜索、状态、范围,超过一行的筛选进入 `筛选` 或 `台账工具`;列表默认删除 `下一步` 列和右侧提示区。
- 工作台已确认进一步极简:只显示紧凑数量、入口、真实提醒和动作,不再显示解释普通流程的副文案。
- 详情页动作区已确认:PC 详情页可保留右侧固定动作区,移动端底部固定主动作;列表页不承担详情和流程说明。
- 本轮追加实现范围已确认:修审批详情 `更多` 遮挡、工作台去解释文案、采购改单列列表、合同筛选压一行、项目删除右侧提示、资产/应付账款去首屏双列和大卡片。
- 管理配置页已确认:侧边栏只保留 `管理`,管理首页按 `人员组织`、`审批流程`、`基础资料`、`系统设置`、`审计排障` 分组进入;子页采用搜索/筛选、表格和右侧操作列。普通业务页面、工作台和个人设置不得混入管理配置矩阵。
- 个人设置页已确认:采用常见网站账号设置结构,只包含 `个人资料`、`账号安全`、`通知偏好`、`企业微信绑定`。组织、角色、直属汇报只做折叠只读摘要;人员档案、权限、组织维护和角色管理全部进入 `管理`。
- 通知中心已确认:只做事件收件箱,显示状态变化、提醒、失败、同步结果和流转消息;点击通知必须跳到业务详情、审批任务或失败说明页。通知中心不复制 `待我处理` 列表,不承担快速上手或流程解释职责。
+5 -5
查看文件
@@ -650,7 +650,7 @@
}
.detail-action-stack {
@apply grid gap-3;
@apply grid gap-3 overflow-visible;
}
.detail-action-card {
@@ -661,7 +661,7 @@
}
.workflow-action-panel {
@apply xl:sticky xl:top-20;
@apply relative z-30 xl:sticky xl:top-20;
}
.workflow-action-bar {
@@ -669,7 +669,7 @@
}
.workflow-action-more {
@apply relative;
@apply relative z-40;
}
.workflow-action-more > summary {
@@ -694,7 +694,7 @@
background: #fff;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
@apply absolute right-0 top-10 z-20 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
@apply absolute right-0 top-10 z-50 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
}
.notification-inbox {
@@ -889,7 +889,7 @@
.portal-task-row {
background: rgba(255, 255, 255, 0.88);
color: var(--color-text-primary);
@apply grid min-h-[68px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-3 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto];
@apply grid min-h-[56px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto];
}
.portal-task-row--warning {
-49
查看文件
@@ -229,32 +229,6 @@ function prettyJson(value: unknown) {
}
}
function SummaryCard({
title,
value,
tone = "zinc",
}: {
title: string;
value: number | string;
tone?: "zinc" | "amber" | "red" | "emerald";
}) {
const toneClass =
tone === "amber"
? "text-amber-700"
: tone === "red"
? "text-red-700"
: tone === "emerald"
? "text-emerald-700"
: "text-zinc-900";
return (
<div className="finance-metric-card">
<div className="finance-metric-card__label">{title}</div>
<div className={`finance-metric-card__value ${toneClass}`}>{value}</div>
</div>
);
}
function SectionError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -619,7 +593,6 @@ export function ApOperationsPage() {
<ListWorkspace>
<WorkspaceHeader
title="应付账款"
subtitle="优先处理待付款、异常付款、风险和对账差异;导出和代账交接进入台账工具。"
actions={
<>
<Button
@@ -654,30 +627,11 @@ export function ApOperationsPage() {
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
) : null}
<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
title="部分匹配"
value={summaryQuery.data?.summary.partialMatchCount ?? "-"}
tone="amber"
/>
<SummaryCard title="台账记录" value={financeLedgerQuery.data?.total ?? "-"} />
<SummaryCard
title="预算超支"
value={summaryQuery.data?.summary.budgetOverspendCount ?? "-"}
tone="amber"
/>
</div>
{showLedgerTools ? (
<div className="finance-command-panel space-y-3" data-testid="ap-ledger-tools">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
<p className="mt-1 text-sm leading-6 text-zinc-500">
导出只用于财务复核和代账交接,范围按当前筛选条件生成;文件可能包含付款、票据和员工摘要信息。
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
@@ -698,9 +652,6 @@ export function ApOperationsPage() {
</Button>
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
当前工具不会执行真实付款,也不会生成正式总账凭证;付款仍由线下银行或既有财务流程完成。
</div>
</div>
) : null}
+7 -73
查看文件
@@ -364,24 +364,6 @@ export function AssetRegistryPage() {
}
}, [searchParams, selectedAssetId]);
useEffect(() => {
if (isCreatingAsset) {
return;
}
if (selectedAssetId) {
if (assetDetailQuery.isLoading) {
return;
}
if (!assetDetailQuery.isError) {
return;
}
}
const nextId = assetsQuery.data?.items?.[0]?.id ?? "";
if (nextId !== selectedAssetId) {
setSelectedAssetId(nextId);
}
}, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, selectedAssetId]);
useEffect(() => {
const currentId = searchParams.get("id") ?? "";
if (isCreatingAsset) {
@@ -812,20 +794,6 @@ export function AssetRegistryPage() {
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
});
}, [assetItems]);
const assetStats = useMemo(() => {
return assetItems.reduce(
(acc, item) => {
acc.total += 1;
if (item.assetType === "fixed_asset") acc.fixedAsset += 1;
else acc.electronicAsset += 1;
if (item.assetType === "fixed_asset" && !item.assignee) acc.unassigned += 1;
if (!item.lastVerifiedAt) acc.needsVerification += 1;
if ((item.assetType === "fixed_asset" && !item.assignee) || !item.lastVerifiedAt) acc.attention += 1;
return acc;
},
{ total: 0, fixedAsset: 0, electronicAsset: 0, unassigned: 0, needsVerification: 0, attention: 0 },
);
}, [assetItems]);
const selectAsset = (assetId: string) => {
setIsCreatingAsset(false);
setSelectedAssetId(assetId);
@@ -835,7 +803,6 @@ export function AssetRegistryPage() {
<ListWorkspace>
<WorkspaceHeader
title="资产台账"
subtitle="管理固定资产责任链、位置、盘点和电子资产索引;分类维护和高级筛选收在台账工具。"
actions={
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
@@ -848,35 +815,11 @@ export function AssetRegistryPage() {
}
/>
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="asset-registry-summary">
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">当前可见</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.total}</div>
<div className="mt-1 text-xs text-zinc-500">{mine ? "默认我相关资产" : "全范围查看"}</div>
</div>
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">固定资产 FA</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.fixedAsset}</div>
<div className="mt-1 text-xs text-zinc-500">责任、位置、盘点和交接</div>
</div>
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">电子资产索引</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.electronicAsset}</div>
<div className="mt-1 text-xs text-zinc-500">只登记责任和验证摘要</div>
</div>
<div className="finance-metric-card">
<div className="text-sm text-zinc-500">待补责任/验证</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{assetStats.attention}</div>
<div className="mt-1 text-xs text-zinc-500">优先补齐保管和最近盘点</div>
</div>
</section>
<div className={showLedgerTools ? "grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]" : "grid gap-6"}>
{showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
<div className="font-medium text-slate-900">台账工具</div>
<p className="mt-1">新建资产卡片、分类维护、全范围与高级筛选放在这里,普通首屏只保留查找和当前资产处理。</p>
{canManage ? (
<Button type="button" className="mt-3" data-testid="asset-create" onClick={resetAssetEditor}>
新建资产卡片
@@ -927,7 +870,6 @@ export function AssetRegistryPage() {
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3">
<div>
<h4 className="text-sm font-semibold text-zinc-900">高级筛选</h4>
<p className="text-xs text-zinc-500">低频分类、组织、责任人和敏感级别筛选放在工具区。</p>
</div>
<div className="grid gap-3">
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
@@ -1040,7 +982,7 @@ export function AssetRegistryPage() {
</div>
) : null}
<div className={showLedgerTools ? "grid gap-6 2xl:grid-cols-[minmax(0,0.95fr)_minmax(420px,1.05fr)]" : "grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(420px,1.05fr)]"}>
<div className="grid gap-6">
<FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px_auto]">
<Input
@@ -1124,10 +1066,6 @@ export function AssetRegistryPage() {
<div className="text-zinc-400">位置</div>
<div className="mt-0.5 truncate text-zinc-700">{item.location ?? "-"}</div>
</div>
<div className="col-span-2">
<div className="text-zinc-400">下一步</div>
<div className="mt-0.5 truncate text-zinc-700">{assetNextStep(item)}</div>
</div>
</div>
</button>
))
@@ -1144,7 +1082,6 @@ export function AssetRegistryPage() {
<TableHead>责任链</TableHead>
<TableHead>位置</TableHead>
<TableHead>状态</TableHead>
<TableHead>下一步</TableHead>
</tr>
</TableHeader>
<TableBody>
@@ -1171,12 +1108,11 @@ export function AssetRegistryPage() {
<div className="text-xs text-zinc-500">盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}</div>
</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>{assetNextStep(item)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="py-4 text-zinc-500">
<TableCell colSpan={5} className="py-4 text-zinc-500">
暂无资产卡片
</TableCell>
</TableRow>
@@ -1188,13 +1124,8 @@ export function AssetRegistryPage() {
) : null}
</FilterBar>
{selectedAssetId || isCreatingAsset ? (
<div className="card-std space-y-4 p-4 md:p-5">
{!selectedAssetId && !isCreatingAsset ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
请先从列表选择资产卡片,或点击“新建资产卡片”。
</div>
) : null}
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
@@ -1207,9 +1138,11 @@ export function AssetRegistryPage() {
<h3 className="text-lg font-semibold text-zinc-900">
{isCreatingAsset ? "新建资产卡片" : currentAsset ? <CompactAssetNo value={currentAsset.assetNo} /> : "资产卡片"}
</h3>
{!isCreatingAsset ? (
<p className="text-sm text-zinc-500" title={currentAsset?.name ?? undefined}>
{isCreatingAsset ? "创建新的资产台账卡片。" : formatTextWithCompactLongNumbers(currentAsset?.name)}
{formatTextWithCompactLongNumbers(currentAsset?.name)}
</p>
) : null}
</div>
{!isCreatingAsset && currentAsset ? <StatusBadge status={currentAsset.status} /> : null}
</div>
@@ -1626,6 +1559,7 @@ export function AssetRegistryPage() {
</>
) : null}
</div>
) : null}
</div>
</div>
</ListWorkspace>
+40 -91
查看文件
@@ -26,7 +26,6 @@ import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -250,9 +249,6 @@ export function ContractsRegistryPage() {
? "保留现有责任人"
: "默认当前用户";
const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim());
const activeContractCount = visibleContracts.filter((item) => item.status === "active").length;
const draftContractCount = visibleContracts.filter((item) => item.status === "draft").length;
const archivedContractCount = visibleContracts.filter((item) => item.status === "archived").length;
const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length;
const refreshAll = async () => {
@@ -323,21 +319,14 @@ export function ContractsRegistryPage() {
return (
<PageScaffold>
<PageHeader
eyebrow="合同"
title="合同台账"
subtitle="维护合同元数据、生命周期、到期预警与导出,不在 OA 存放涉密正文附件。"
meta={
<>
<span className="info-chip">可见 {visibleContracts.length} 份</span>
<span className="info-chip">30 天到期 {expiringContractCount} 份</span>
<span className="info-chip">私密正文不入库</span>
</>
}
actions={
<div className="flex flex-wrap gap-2">
{initialProjectId ? (
<Button asChild variant="secondary" type="button">
<Link to="/projects">返回项目</Link>
</Button>
) : null}
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
@@ -345,82 +334,21 @@ export function ContractsRegistryPage() {
}
/>
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="contracts-registry-summary">
<MetricCard title="可见合同" value={visibleContracts.length} note={filters.projectId ? "已按项目过滤" : "当前筛选结果"} />
<MetricCard title="生效中" value={activeContractCount} note="需要关注到期和责任人" />
<MetricCard title="草稿" value={draftContractCount} note="补齐摘要、金额和归档位置" tone={draftContractCount > 0 ? "warning" : "neutral"} />
<MetricCard title="已归档" value={archivedContractCount} note="只保留台账和线下档案索引" />
</section>
<div className="detail-command-panel space-y-4" data-testid="contracts-registry-command">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
{showLedgerTools ? (
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm text-zinc-500">生命周期焦点</div>
<div className="mt-1 text-3xl font-semibold text-zinc-900">{expiringQuery.data?.total ?? 0}</div>
<div className="text-sm text-zinc-600">
份合同将在 {EXPIRING_DAYS} 天内到期;续约会生成草稿合同,导出仅用于授权复核。
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={expiringQuery.isLoading}
onClick={() => setShowExpiring((value) => !value)}
>
{showExpiring ? "收起到期列表" : "展开到期列表"}
到期列表 {expiringContractCount}
</Button>
</div>
</div>
{expiringQuery.isError ? (
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
) : null}
{showExpiring ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
{expiringItems.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50/70 p-4 lg:flex-row lg:items-center lg:justify-between"
>
<div>
<div className="font-medium text-zinc-900">
{item.contractNo} · {item.counterpartyName}
</div>
<div className="mt-1 text-sm text-zinc-600">
{contractTypeLabel(item.contractType)} · 到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => openEditor(item)}
>
编辑
</Button>
</div>
</div>
))}
{!expiringQuery.isLoading && !expiringItems.length ? (
<div className="text-sm text-zinc-500">未来 {EXPIRING_DAYS} 天内暂无即将到期合同。</div>
) : null}
</div>
) : null}
</div>
{showLedgerTools ? (
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
<p className="mt-1 text-sm leading-6 text-zinc-500">
新建/编辑、导出和高级到期范围放在这里。导出文件可能包含合同金额、相对方、责任人和归档摘要。
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("csv")}>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
@@ -446,10 +374,31 @@ export function ContractsRegistryPage() {
/>
</div>
</div>
{expiringQuery.isError ? (
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
) : null}
{showExpiring ? (
<div className="space-y-2 border-t border-zinc-200 pt-3">
{expiringItems.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
</div>
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
</div>
))}
{!expiringQuery.isLoading && !expiringItems.length ? (
<div className="text-sm text-zinc-500">暂无到期合同</div>
) : null}
</div>
) : null}
</div>
) : null}
<div className={showLedgerTools || editorId ? "grid gap-6 xl:grid-cols-[400px,minmax(0,1fr)]" : "grid gap-6"}>
<div className="grid gap-6">
{showLedgerTools || editorId ? (
<div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel">
<div className="flex items-start justify-between gap-3">
@@ -735,11 +684,11 @@ export function ContractsRegistryPage() {
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label>项目</Label>
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
<div>
<select
className="select-std"
aria-label="项目"
value={filters.projectId}
onChange={(event) => setFilters((current) => ({ ...current, projectId: event.target.value }))}
>
@@ -752,10 +701,10 @@ export function ContractsRegistryPage() {
</select>
</div>
<div className="space-y-2">
<Label>状态</Label>
<div>
<select
className="select-std"
aria-label="状态"
value={filters.status}
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value as FiltersState["status"] }))}
>
@@ -768,10 +717,10 @@ export function ContractsRegistryPage() {
</select>
</div>
<div className="space-y-2">
<Label>合同类型</Label>
<div>
<select
className="select-std"
aria-label="合同类型"
value={filters.contractType}
onChange={(event) => setFilters((current) => ({ ...current, contractType: event.target.value as FiltersState["contractType"] }))}
>
@@ -784,12 +733,12 @@ export function ContractsRegistryPage() {
</select>
</div>
<div className="space-y-2">
<Label>相对方模糊筛选</Label>
<div>
<Input
aria-label="相对方"
value={filters.counterpartyName}
onChange={(event) => setFilters((current) => ({ ...current, counterpartyName: event.target.value }))}
placeholder="输入相对方名称关键字"
placeholder="搜索相对方"
/>
</div>
+5 -9
查看文件
@@ -515,11 +515,10 @@ function SummaryStrip({
reminderCount: number;
}) {
const attentionCount = blockerCount + reminderCount;
const summaryText = actionCount > 0
? `今天有 ${actionCount} 件待处理${progressCount > 0 ? `,${progressCount} 个进度更新` : ""}${attentionCount > 0 ? `,${attentionCount} 个团队或管理提醒` : ""}。`
: progressCount > 0 || attentionCount > 0
? `今天没有必须处理的事项${progressCount > 0 ? `,有 ${progressCount} 个进度更新` : ""}${attentionCount > 0 ? `,${attentionCount} 个团队或管理提醒` : ""}。`
: "今天没有需要你处理的事项。";
const summaryParts = [`待处理 ${actionCount}`];
if (progressCount > 0) summaryParts.push(`进度 ${progressCount}`);
if (attentionCount > 0) summaryParts.push(`提醒 ${attentionCount}`);
const summaryText = summaryParts.join(" · ");
return (
<section className="portal-work-summary" data-testid="portal-work-summary">
@@ -545,7 +544,6 @@ function WorkItemRow({ item }: { item: WorkItem }) {
</span>
<span className="min-w-0 flex-1">
<span className="portal-task-row__title">{item.title}</span>
<span className="portal-task-row__reason">{item.reason}</span>
</span>
<span className="portal-task-row__meta">{item.meta}</span>
<span className="portal-task-row__action">{item.actionLabel}</span>
@@ -578,7 +576,6 @@ function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
</span>
<span className="min-w-0">
<span className="portal-quick-entry__title">{entry.label}</span>
<span className="portal-quick-entry__action">{entry.actionLabel}</span>
</span>
</Link>
))}
@@ -601,8 +598,7 @@ function TodayActionPanel({
) : (
<div className="portal-empty-start">
<div>
<div className="portal-empty-start__title">今天没有需要你处理的事项。</div>
<div className="portal-empty-start__desc">需要发起新申请或回看进度时,从下面的常用动作进入。</div>
<div className="portal-empty-start__title">暂无待处理</div>
</div>
{emptyActions.length ? (
<div className="portal-inline-actions">
+3 -39
查看文件
@@ -14,8 +14,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { FilterBar, ListWorkspace, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { FilterBar, ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -123,20 +122,6 @@ export function ProjectsPage() {
});
}, [projectItems, showTrialData]);
const hiddenTrialProjectCount = projectItems.length - visibleProjectItems.length;
const projectStats = useMemo(() => {
return visibleProjectItems.reduce(
(acc, item) => {
acc.total += 1;
if (item.status === "active") {
acc.active += 1;
}
acc.overdueMilestones += item.counts.overdueMilestones;
acc.openRisks += item.counts.openRisks;
return acc;
},
{ total: 0, active: 0, overdueMilestones: 0, openRisks: 0 },
);
}, [visibleProjectItems]);
const createMutation = useMutation({
mutationFn: () =>
@@ -188,7 +173,6 @@ export function ProjectsPage() {
<ListWorkspace>
<WorkspaceHeader
title="项目"
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
actions={
<div className="flex gap-2">
<Button asChild variant="secondary" type="button">
@@ -201,15 +185,8 @@ export function ProjectsPage() {
}
/>
<StatStrip columns={4}>
<MetricCard title="我相关项目" value={projectStats.total} note={mine ? "默认只看我负责/我参与" : "已切换到全范围"} />
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
</StatStrip>
{showLedgerTools ? (
<SectionPanel title="台账工具" description="新建项目、显示测试数据和全范围查看放在这里,避免普通首屏变成配置台。">
<SectionPanel title="台账工具">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr),260px]">
<div>
{!canManageProjects ? (
@@ -274,13 +251,11 @@ export function ProjectsPage() {
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
<p>试运行默认隐藏 TEST/SMOKE 项目。全范围查看请使用筛选区的范围按钮,避免普通项目负责人默认进入公司全量项目。</p>
</div>
</div>
</SectionPanel>
) : null}
<div className="page-shell-grid">
<div className="space-y-6">
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
@@ -302,10 +277,7 @@ export function ProjectsPage() {
</WorkspaceNotice>
) : null}
<SectionPanel
title="项目列表"
description={visibleProjectItems.length ? `当前显示 ${visibleProjectItems.length} 个项目` : "按项目名、客户或状态快速缩小范围"}
>
<SectionPanel title="项目列表">
<div className="space-y-4">
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
@@ -342,14 +314,6 @@ export function ProjectsPage() {
</div>
</SectionPanel>
</div>
<SectionPanel title="下一步提示" description="项目页只保留轻台账和详情入口。">
<div className="space-y-3 text-sm text-zinc-600">
<p>项目列表用于看状态、风险、负责人和进入详情;成员、里程碑、风险、交付记录和关联业务在详情页处理。</p>
<p>合同、采购、报销和付款只展示摘要与跳转,不在项目列表直接处理对方模块动作。</p>
</div>
</SectionPanel>
</div>
</ListWorkspace>
);
}
+7 -155
查看文件
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { Archive, ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
import { Wrench } from "lucide-react";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
@@ -117,48 +117,6 @@ function statusOptions(): Array<{ value: PurchaseRequestStatus | ""; label: stri
];
}
const PURCHASE_TASK_STATUS_OPTIONS = [
{
value: "rejected",
title: "退回待补材料",
idleNote: "暂无被退回的采购",
activeNote: "按审批意见补材料后重新提交",
icon: RotateCcw,
accentClass: "border-red-200 bg-red-50 text-red-700",
},
{
value: "draft",
title: "草稿待完善",
idleNote: "还没有采购草稿",
activeNote: "继续补供应商、明细和附件",
icon: FilePenLine,
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
},
{
value: "reviewing",
title: "审批中进度",
idleNote: "暂无审批中的采购",
activeNote: "查看当前节点和审批进度",
icon: Clock3,
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
},
{
value: "approved",
title: "待验收 / 归档",
idleNote: "暂无已通过待跟进采购",
activeNote: "核对到货并登记 PO / 合同摘要",
icon: Archive,
accentClass: "border-emerald-200 bg-emerald-50 text-emerald-700",
},
] satisfies Array<{
value: PurchaseRequestStatus;
title: string;
idleNote: string;
activeNote: string;
icon: typeof RotateCcw;
accentClass: string;
}>;
function formatPurchaseCurrency(value: string | number | null | undefined, currencyCode = "CNY") {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount)) {
@@ -486,26 +444,6 @@ export function PurchaseRequestsPage() {
}),
});
const draftSummaryQuery = useQuery({
queryKey: ["purchase-requests", "task-summary", "mine", "draft"],
queryFn: () => fetchPurchaseRequests({ status: "draft", mine: true, page: 1, pageSize: 1 }),
});
const rejectedSummaryQuery = useQuery({
queryKey: ["purchase-requests", "task-summary", "mine", "rejected"],
queryFn: () => fetchPurchaseRequests({ status: "rejected", mine: true, page: 1, pageSize: 1 }),
});
const reviewingSummaryQuery = useQuery({
queryKey: ["purchase-requests", "task-summary", "mine", "reviewing"],
queryFn: () => fetchPurchaseRequests({ status: "reviewing", mine: true, page: 1, pageSize: 1 }),
});
const approvedSummaryQuery = useQuery({
queryKey: ["purchase-requests", "task-summary", "mine", "approved"],
queryFn: () => fetchPurchaseRequests({ status: "approved", mine: true, page: 1, pageSize: 1 }),
});
const detailQuery = useQuery({
enabled: Boolean(selectedId),
queryKey: ["purchase-requests", "detail", selectedId],
@@ -534,22 +472,6 @@ export function PurchaseRequestsPage() {
}
}, [searchParams, selectedId]);
useEffect(() => {
if (selectedId) {
if (detailQuery.isLoading) {
return;
}
if (!detailQuery.isError) {
return;
}
}
const nextId = listQuery.data?.items?.[0]?.id ?? "";
if (nextId !== selectedId) {
setSelectedId(nextId);
}
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
useEffect(() => {
const currentId = searchParams.get("id") ?? "";
if (!selectedId && !currentId) {
@@ -645,12 +567,6 @@ export function PurchaseRequestsPage() {
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const purchaseItems = listQuery.data?.items ?? [];
const taskSummaryTotalByStatus = {
draft: draftSummaryQuery.data?.total,
rejected: rejectedSummaryQuery.data?.total,
reviewing: reviewingSummaryQuery.data?.total,
approved: approvedSummaryQuery.data?.total,
};
const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine));
const canShowLedgerTools =
canViewAll ||
@@ -1000,15 +916,6 @@ export function PurchaseRequestsPage() {
}
};
function focusPurchaseStatus(nextStatus: PurchaseRequestStatus | "") {
setStatus(nextStatus);
setMine(true);
setSupplierFilter("");
setLegalEntityFilter("");
setCostCenterFilter("");
setPage(1);
}
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
@@ -1033,9 +940,7 @@ export function PurchaseRequestsPage() {
<ListWorkspace>
{downloadReasonDialog}
<WorkspaceHeader
eyebrow="采购"
title="采购申请"
subtitle={mine ? "先看与你相关的采购任务,再进入单据核对材料和进度。" : "当前为全范围台账视角,处理动作仍回到详情或审批任务。"}
actions={
canCreate ? (
<Button
@@ -1056,47 +961,9 @@ export function PurchaseRequestsPage() {
</p>
) : null}
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4" aria-label="我的采购任务">
{PURCHASE_TASK_STATUS_OPTIONS.map((item) => {
const Icon = item.icon;
const count = taskSummaryTotalByStatus[item.value];
const active = status === item.value && mine && !hasAdvancedFilters;
const displayCount = typeof count === "number" ? count : "-";
return (
<button
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
active ? "border-primary/40 bg-primary/5" : ""
}`}
data-testid={`purchase-task-card-${item.value}`}
key={item.value}
onClick={() => focusPurchaseStatus(item.value)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
<Icon aria-hidden className="size-4" />
</span>
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
</div>
</div>
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
</div>
</button>
);
})}
</section>
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<div className="min-w-0 space-y-2">
<div className="text-xs font-semibold text-zinc-500">采购记录</div>
<div className="min-w-0">
<Tabs
data-testid="purchase-status-tabs"
value={status}
@@ -1230,7 +1097,7 @@ export function PurchaseRequestsPage() {
) : null}
</FilterBar>
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="grid gap-6">
<div className="card-std space-y-4 p-4 md:p-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
@@ -1296,9 +1163,6 @@ export function PurchaseRequestsPage() {
</div>
<div className={`mt-0.5 ${evidenceHint.className}`}>{evidenceHint.label}</div>
</div>
<div className="col-span-2 rounded-md bg-slate-50 px-2 py-1.5 text-zinc-700">
下一步:{resolvePurchaseNextStep(item)}
</div>
</div>
</button>
);
@@ -1318,7 +1182,7 @@ export function PurchaseRequestsPage() {
<TableHead>金额 / 供应商</TableHead>
<TableHead>项目 / 合同</TableHead>
<TableHead>状态 / 证据</TableHead>
<TableHead>下一步</TableHead>
<TableHead className="w-28">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
@@ -1329,12 +1193,8 @@ export function PurchaseRequestsPage() {
<TableRow
key={item.id}
className={item.id === selectedId ? "bg-zinc-50" : undefined}
onClick={() => {
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
<TableCell className="cursor-pointer">
<TableCell>
<div className="font-medium text-zinc-900">
<CompactBusinessCode value={resolvePurchaseDisplayCode(item)} />
</div>
@@ -1368,11 +1228,7 @@ export function PurchaseRequestsPage() {
<div className={`mt-1 text-xs ${evidenceHint.className}`}>{evidenceHint.label}</div>
</TableCell>
<TableCell>
<div className="max-w-[160px] text-xs leading-5 text-zinc-600" data-testid="purchase-row-next-step">
{resolvePurchaseNextStep(item)}
</div>
<Button
className="mt-2"
data-testid="purchase-row-open"
size="sm"
type="button"
@@ -1413,13 +1269,8 @@ export function PurchaseRequestsPage() {
/>
</div>
{selectedId ? (
<div className="card-std space-y-5 p-4 md:p-5">
{!selectedId ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
请先在左侧选择采购单,或新建一张采购草稿。
</div>
) : null}
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={3} rows={8} /> : null}
{selectedId && detailQuery.isError ? (
@@ -2199,6 +2050,7 @@ export function PurchaseRequestsPage() {
</>
) : null}
</div>
) : null}
</div>
<ActionConfirmDialog
open={Boolean(submitPrecheckConfirm)}
+1 -1
查看文件
@@ -83,7 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await login(page, FINANCE_MGR);
await (await openNavLink(page, "资产")).click();
await expect(page.getByTestId("asset-registry-summary")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "资产台账" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("asset-ledger-tools")).toBeVisible();
@@ -1085,10 +1085,10 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/projects");
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
await expect(page.getByText("默认只看我负责/我参与")).toBeVisible();
await expect(page.getByText("新建项目")).toHaveCount(0);
await expect(page.getByPlaceholder("搜索项目编号、项目名、客户名")).toBeVisible();
await expect(page.getByRole("button", { name: "创建项目" })).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByText("新建项目")).toBeVisible();
await expect(page.getByRole("button", { name: "创建项目" })).toBeVisible();
await page.goto("/projects/project-wave3");
await expect(page.getByRole("heading", { name: /HYB-W3/ })).toBeVisible();
@@ -1099,7 +1099,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/contracts-registry?projectId=project-wave3");
await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible();
await expect(page.getByTestId("contracts-registry-summary")).toBeVisible();
await expect(page.getByTestId("contracts-registry-row")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("contracts-ledger-tools")).toBeVisible();
@@ -1001,7 +1001,7 @@ async function capture(page: Page, name: string) {
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: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool" },
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
@@ -1023,9 +1023,13 @@ test("PC 财务与管理页 Wave4-5 视觉冒烟", async ({ page }) => {
for (const target of pages) {
await page.goto(target.path);
if ("summary" in target) {
await expect(page.getByTestId(target.summary)).toBeVisible();
}
await expect(page.locator(target.proof).first()).toBeVisible();
if (!("expectStatusStrip" in target) || target.expectStatusStrip !== false) {
await expect(page.locator(".finance-status-strip").first()).toBeVisible();
}
await capture(page, target.screenshot);
}
});
+1 -1
查看文件
@@ -106,7 +106,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await page.getByRole("combobox").first().selectOption("approved");
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
await purchaseRow.click();
await purchaseRow.getByTestId("purchase-row-open").click();
await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible();
await expect(page.getByTestId("purchase-status-hint")).toContainText("已通过", { timeout: 30_000 });
@@ -348,9 +348,9 @@ test.describe("R103 cross page UI consistency", () => {
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购申请")).toBeVisible();
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible();
await expect(page.getByTestId("purchase-task-card-draft")).toBeVisible();
await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
await expect(page.getByTestId("purchase-ledger-tools")).toBeVisible();
await expect(page.getByTestId("purchase-row-next-step")).toContainText("继续完善材料并提交");
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
await expect(page.getByText("付款处理")).toHaveCount(0);
await expect(page.getByText("/purchase-requests")).toHaveCount(0);
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");