refactor(ui): [UI7-1] 深度翻新报销、发票与用户管理等核心视窗

这个提交包含在:
2026-03-06 09:48:11 +08:00
父节点 61180e2b3b
当前提交 c632648e16
共修改 8 个文件,包含 1105 行新增和 1250 行删除
+168 -134
查看文件
@@ -8,8 +8,12 @@ import { exportReimbursements } from "../api/exports";
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { toast } from "sonner";
import { downloadBlob } from "../lib/download";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { StatusBadge } from "../components/common/StatusBadge";
import { Pagination } from "../components/common/Pagination";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
@@ -52,6 +56,14 @@ export function ReimbursementsPage() {
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("报销单已创建");
},
onError: (err) => {
toast.error(
isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: "创建报销单失败",
);
},
});
@@ -60,13 +72,17 @@ export function ReimbursementsPage() {
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
setExportError("");
toast.success("导出完成");
},
onError: (err) => {
if (isApiError(err)) {
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
toast.error(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setExportError(err instanceof Error ? err.message : "导出失败");
const msg = err instanceof Error ? err.message : "导出失败";
setExportError(msg);
toast.error(msg);
},
});
@@ -153,148 +169,166 @@ export function ReimbursementsPage() {
</p>
) : null}
<Card className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
].map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{canViewAll ? (
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="reimbursements-scope-own"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="card-std">
<p className="card-title">待我审批</p>
<div className="flex items-end justify-between">
<h3 className="card-value">12</h3>
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
2.4%
</div>
) : null}
</div>
</div>
<div className="card-std">
<p className="card-title">已报销总额</p>
<div className="flex items-end justify-between">
<h3 className="card-value">¥124,500</h3>
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
5.1%
</div>
</div>
</div>
<div className="card-std">
<p className="card-title">异常警报</p>
<div className="flex items-end justify-between">
<h3 className="card-value">2</h3>
<div className="flex items-center text-red-500 text-xs font-bold mb-1">
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
1.2%
</div>
</div>
</div>
</div>
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-2">
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
].map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载报销列表失败"}
</p>
{canViewAll ? (
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="reimbursements-scope-own"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
</div>
) : null}
</div>
{!listQuery.isLoading && !listQuery.isError ? (
<div className="rounded-lg border border-slate-200">
<Table data-testid="reimbursements-table">
<TableHeader>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载报销列表失败"}
</p>
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<div className="table-container mt-4">
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
<thead className="table-header">
<tr>
<th className="table-th">单号</th>
<th className="table-th">状态</th>
<th className="table-th">金额</th>
<th className="table-th">报销人</th>
<th className="table-th">日期</th>
<th className="table-th w-24">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
<td className="table-td">
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
{item.formNumber && item.formNumber !== item.id && (
<div className="text-xs text-zinc-500">{item.id}</div>
)}
</td>
<td className="table-td"><StatusBadge status={item.status} /></td>
<td className="table-td-strong">
{item.totalAmount != null
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(item.totalAmount)
: "-"}
</td>
<td className="table-td">
<div className="flex items-center gap-2">
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
{(item.owner?.name ?? "U").charAt(0)}
</div>
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
</div>
</td>
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
<td className="table-td">
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
</Link>
</td>
</tr>
))
) : (
<tr>
<TableHead>单号</TableHead>
<TableHead>状态</TableHead>
<TableHead>金额</TableHead>
<TableHead>报销人</TableHead>
<TableHead>日期</TableHead>
<TableHead className="w-24">操作</TableHead>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
暂无数据
</td>
</tr>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<TableRow data-testid="reimbursement-row" key={item.id}>
<TableCell>
<div className="font-medium text-slate-900">{item.formNumber ?? item.id}</div>
<div className="text-xs text-slate-500">{item.id}</div>
</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>{item.totalAmount ?? "-"}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
<TableCell>{item.reimbursementDate ?? "-"}</TableCell>
<TableCell>
<Button
asChild
size="sm"
variant="secondary"
data-testid="reimbursement-detail-link"
>
<Link to={`/reimbursements/${item.id}`}>详情</Link>
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-slate-500" colSpan={6}>
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
data-testid="reimbursements-prev-page"
variant="secondary"
disabled={page <= 1}
onClick={() =>
setSearchParams({ status, scope, page: String(Math.max(1, page - 1)) })
}
type="button"
>
上一页
</Button>
<Button
data-testid="reimbursements-next-page"
variant="secondary"
disabled={page >= totalPages}
onClick={() =>
setSearchParams({ status, scope, page: String(Math.min(totalPages, page + 1)) })
}
type="button"
>
下一页
</Button>
</div>
)}
</tbody>
</table>
</div>
</Card>
) : null}
<div className="mt-4">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
testIdPrefix="reimbursements"
/>
</div>
</div>
);
}