统一业务列表 Ant Design 风格(05-31-unify-business-list-ant-design)

这个提交包含在:
2026-05-31 11:39:46 +08:00
父节点 59c942da54
当前提交 40c23c5cf3
共修改 19 个文件,包含 905 行新增和 578 行删除
+67 -147
查看文件
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
import { Wrench } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
@@ -25,7 +25,14 @@ import { Pagination } from "../components/common/Pagination";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import {
BusinessEmptyRow,
BusinessEmptyState,
BusinessListToolbar,
BusinessMobileList,
BusinessTable,
} from "../components/patterns/BusinessListTemplate";
import { Button } from "../components/ui/button";
import {
DropdownMenu,
@@ -49,32 +56,6 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s
{ value: "cancelled", label: "已取消" },
];
const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
{
value: "rejected",
title: "被驳回待补正",
icon: RotateCcw,
accentClass: "border-red-200 bg-red-50 text-red-700",
},
{
value: "draft",
title: "草稿待完善",
icon: FilePenLine,
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
},
{
value: "reviewing",
title: "审批中进度",
icon: Clock3,
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
},
] satisfies Array<{
value: ReimbursementStatus;
title: string;
icon: typeof RotateCcw;
accentClass: string;
}>;
function formatCurrency(value: number) {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
}
@@ -198,21 +179,6 @@ export function ReimbursementsPage() {
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
});
const rejectedSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "rejected"],
queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }),
});
const draftSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "draft"],
queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }),
});
const reviewingSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "reviewing"],
queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }),
});
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
@@ -251,11 +217,6 @@ export function ReimbursementsPage() {
const templateOptions = templateOptionsQuery.data?.items ?? [];
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
const taskSummaryTotalByStatus = {
rejected: rejectedSummaryQuery.data?.total,
draft: draftSummaryQuery.data?.total,
reviewing: reviewingSummaryQuery.data?.total,
} satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>;
const canShowCreateOptionsMenu =
canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0);
const emptyState = resolveEmptyStateCopy(status);
@@ -352,73 +313,22 @@ export function ReimbursementsPage() {
</p>
) : null}
<section className="flex flex-wrap gap-2" aria-label="我的报销任务">
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
const Icon = item.icon;
const count = taskSummaryTotalByStatus[item.value];
const active = status === item.value && scope === "own";
const displayCount = typeof count === "number" ? count : "-";
return (
<button
className={`inline-flex min-h-10 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition hover:border-primary/40 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
active ? "border-primary/40 bg-primary/5 text-primary" : "border-zinc-200 bg-white text-zinc-700"
}`}
data-testid={`reimbursements-task-card-${item.value}`}
key={item.value}
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
<BusinessListToolbar
actions={
canShowLedgerEntry ? (
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
<span className={`flex size-7 items-center justify-center rounded-full border ${item.accentClass}`}>
<Icon aria-hidden className="size-3.5" />
</span>
<span className="font-medium">{item.title}</span>
<span className="font-semibold tabular-nums text-zinc-950">{displayCount}</span>
</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>
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="ops-command-bar__actions">
{canShowLedgerEntry ? (
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null}
</div>
</div>
{showAdvancedTools ? (
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null
}
advanced={showAdvancedTools ? (
<>
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
@@ -563,11 +473,28 @@ export function ReimbursementsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</>
) : null}
</FilterBar>
>
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</BusinessListToolbar>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
@@ -585,7 +512,7 @@ export function ReimbursementsPage() {
{!listQuery.isLoading && !listQuery.isError ? (
<>
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
<BusinessMobileList testId="reimbursements-mobile-list">
{items.length ? (
items.map((item) => {
const listAction = resolveListAction(item);
@@ -669,12 +596,11 @@ export function ReimbursementsPage() {
);
})
) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
{status === "draft" && canCreateExpense ? (
<BusinessEmptyState
title={emptyState.title}
description={emptyState.description}
action={status === "draft" && canCreateExpense ? (
<Button
className="mt-3"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
@@ -683,12 +609,11 @@ export function ReimbursementsPage() {
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
</div>
/>
)}
</div>
</BusinessMobileList>
<PrimaryTable className="hidden md:block ops-table-focus">
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
<BusinessTable testId="reimbursements-table">
<thead className="table-header">
<tr>
<th className="table-th">单号</th>
@@ -783,29 +708,24 @@ export function ReimbursementsPage() {
);
})
) : (
<tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
<div className="mx-auto max-w-sm py-3">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
{status === "draft" && canCreateExpense ? (
<Button
className="mt-3"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
</div>
</td>
</tr>
<BusinessEmptyRow
colSpan={6}
title={emptyState.title}
description={emptyState.description}
action={status === "draft" && canCreateExpense ? (
<Button
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
/>
)}
</tbody>
</table>
</PrimaryTable>
</BusinessTable>
<div className="mt-4">
<Pagination