重构 PC 首页与报销列表降噪(frontend-simplification-refactor)

这个提交包含在:
2026-05-29 11:25:45 +08:00
父节点 a5549bdce6
当前提交 03f31958fa
共修改 18 个文件,包含 689 行新增和 296 行删除
+212 -171
查看文件
@@ -29,8 +29,7 @@ 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, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -66,6 +65,15 @@ type BatchResultState = {
>;
};
const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "rejected", label: "被驳回" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "cancelled", label: "已取消" },
];
function formatCurrency(value: number) {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
}
@@ -108,6 +116,7 @@ export function ReimbursementsPage() {
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -178,6 +187,7 @@ export function ReimbursementsPage() {
);
const canShowBatchEntry =
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
const showAdvancedTools = showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector;
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
@@ -363,67 +373,18 @@ export function ReimbursementsPage() {
<ListWorkspace>
<WorkspaceHeader
eyebrow="报销"
title={scope === "all" ? "报销列表" : "我的报销"}
subtitle="先看状态和金额,再继续编辑、查看详情、导出或批量处理。"
title={scope === "all" ? "报销台账" : "我的报销"}
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
空白报销
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => startCreateReimbursement({ templateId: template.id })}
>
{template.name}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
>
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
canCreateExpense ? (
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null
}
/>
@@ -462,13 +423,6 @@ export function ReimbursementsPage() {
</p>
) : null}
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
</StatStrip>
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
@@ -477,14 +431,7 @@ export function ReimbursementsPage() {
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
].map((item) => (
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
@@ -497,52 +444,144 @@ export function ReimbursementsPage() {
</Tabs>
<div className="ops-command-bar__actions">
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
{canApproveExpense ? (
<Button
data-testid="reimbursements-actionable"
size="sm"
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button"
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
待我处理
</Button>
) : null}
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
高级 / 台账
</Button>
</div>
</div>
{canViewAll ? (
<>
<Button
data-testid="reimbursements-scope-own"
size="sm"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
size="sm"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
</>
) : null}
{showAdvancedTools ? (
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">审批中</div>
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">已支付</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="mt-3 flex flex-wrap items-center gap-2">
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary" type="button">
更多创建方式
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => startCreateReimbursement({ templateId: template.id })}
>
{template.name}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
>
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{canViewAll ? (
<>
<Button
data-testid="reimbursements-scope-own"
size="sm"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
size="sm"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
size="sm"
@@ -552,41 +591,42 @@ export function ReimbursementsPage() {
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canShowBatchEntry ? (
<Button
size="sm"
variant={batchMode ? "default" : "secondary"}
onClick={() => {
setBatchMode((current) => !current);
setSelectedReimbursementIds([]);
setBatchDialogAction(null);
setBatchComment("");
setBatchError("");
}}
type="button"
>
{batchMode ? "退出批量模式" : "批量操作"}
</Button>
) : null}
{canShowBatchEntry ? (
<Button
size="sm"
variant={batchMode ? "default" : "secondary"}
onClick={() => {
setBatchMode((current) => !current);
setSelectedReimbursementIds([]);
setBatchDialogAction(null);
setBatchComment("");
setBatchError("");
}}
type="button"
>
{batchMode ? "退出批量模式" : "批量操作"}
</Button>
) : null}
</div>
</div>
</div>
) : null}
{batchMode ? (
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
@@ -595,16 +635,6 @@ export function ReimbursementsPage() {
) : null}
</FilterBar>
{status === "draft" ? (
<WorkspaceNotice title="草稿可以继续编辑或删除" tone="neutral">
提交后的单据进入审批链;已通过、已支付单据不再提供删除入口。
</WorkspaceNotice>
) : status === "reviewing" ? (
<WorkspaceNotice title="审批中单据以详情页为主" tone="neutral">
查看当前节点、票据、附件和审批历史后再处理;有批量权限时只会选择你可处理的待办。
</WorkspaceNotice>
) : null}
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
{listQuery.isError ? (
@@ -810,15 +840,26 @@ export function ReimbursementsPage() {
</Link>
</Button>
{canDeleteDraft ? (
<Button
disabled={deleteMutation.isPending}
onClick={() => handleDeleteDraft(item)}
size="sm"
type="button"
variant="destructive"
>
删除
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={deleteMutation.isPending}
size="sm"
type="button"
variant="secondary"
>
更多
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600"
onSelect={() => handleDeleteDraft(item)}
>
删除草稿
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</td>