refactor(ui): 提取表单原生组件公共样式并修复面板边距bug

这个提交包含在:
2026-03-05 18:38:56 +08:00
父节点 1412fead5d
当前提交 dc0feb60b2
共修改 4 个文件,包含 661 行新增和 0 行删除
@@ -0,0 +1,69 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { InvoicePoolListResponse } from "../../api/invoicePool";
import type { ReimbursementListResponse } from "../../api/reimbursements";
import { Button } from "../../components/ui/button";
export type InvoiceBindCardProps = {
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
reimbursementQuery: UseQueryResult<ReimbursementListResponse, Error>;
bindMutation: UseMutationResult<Record<string, unknown>, Error, void>;
selectedInvoiceId: string;
setSelectedInvoiceId: (id: string) => void;
selectedReimbursementId: string;
setSelectedReimbursementId: (id: string) => void;
};
export function InvoiceBindCard({
listQuery,
reimbursementQuery,
bindMutation,
selectedInvoiceId,
setSelectedInvoiceId,
selectedReimbursementId,
setSelectedReimbursementId,
}: InvoiceBindCardProps) {
return (
<div className="card-std space-y-4">
<h3 className="card-title">绑定报销</h3>
<select
className="select-std"
data-testid="invoice-bind-invoice"
onChange={(event) => setSelectedInvoiceId(event.target.value)}
value={selectedInvoiceId}
>
<option value="">选择票据</option>
{(listQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} ({item.status})
</option>
))}
</select>
<select
className="select-std"
data-testid="invoice-bind-reimbursement"
onChange={(event) => setSelectedReimbursementId(event.target.value)}
value={selectedReimbursementId}
>
<option value="">选择报销单</option>
{(reimbursementQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.formNumber ?? item.id} ({item.status})
</option>
))}
</select>
<div className="pt-2">
<Button
data-testid="invoice-bind-submit"
className="btn-primary"
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
onClick={() => bindMutation.mutate()}
type="button"
>
{bindMutation.isPending ? "绑定中..." : "绑定票据"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,375 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool";
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
import { Button } from "../../components/ui/button";
export type InvoiceDetailPanelProps = {
detail: InvoicePoolDetail | undefined;
detailQuery: UseQueryResult<InvoicePoolDetail, Error>;
canEditDetail: boolean;
canVerifyInvoice: boolean;
canUploadInvoice: boolean;
canOverrideDuplicate: boolean;
patchMutation: UseMutationResult<InvoicePoolDetail, Error, void>;
archiveMutation: UseMutationResult<InvoicePoolDetail, Error, string>;
unbindMutation: UseMutationResult<InvoicePoolDetail, Error, string>;
retryOcrMutation: UseMutationResult<InvoicePoolDetail, Error, string>;
triggerVerificationMutation: UseMutationResult<{ invoiceId: string; verificationId: string; provider: string; triggerMode: string }, Error, string>;
retryVerificationMutation: UseMutationResult<{ invoiceId: string; verificationId: string; status: string }, Error, string>;
overrideDuplicateMutation: UseMutationResult<InvoicePoolDetail, Error, { invoiceId: string; refFormNumber: string; reason: string }>;
verificationQuery: UseQueryResult<{ invoiceId: string; items: InvoiceVerificationRecord[] }, Error>;
editOpen: boolean;
setEditOpen: (open: boolean) => void;
editSetAvailable: boolean;
setEditSetAvailable: (value: boolean) => void;
editRemarks: string;
setEditRemarks: (value: string) => void;
editInvoiceCode: string;
setEditInvoiceCode: (value: string) => void;
editInvoiceNumber: string;
setEditInvoiceNumber: (value: string) => void;
editInvoiceDate: string;
setEditInvoiceDate: (value: string) => void;
editTotalAmount: string;
setEditTotalAmount: (value: string) => void;
confirmAction: { type: string; invoiceId: string } | null;
setConfirmAction: (action: { type: string; invoiceId: string } | null) => void;
};
export function InvoiceDetailPanel({
detail,
canEditDetail,
canVerifyInvoice,
canUploadInvoice,
canOverrideDuplicate,
patchMutation,
archiveMutation,
unbindMutation,
retryOcrMutation,
triggerVerificationMutation,
retryVerificationMutation,
overrideDuplicateMutation,
verificationQuery,
editOpen,
setEditOpen,
editSetAvailable,
setEditSetAvailable,
editRemarks,
setEditRemarks,
editInvoiceCode,
setEditInvoiceCode,
editInvoiceNumber,
setEditInvoiceNumber,
editInvoiceDate,
setEditInvoiceDate,
editTotalAmount,
setEditTotalAmount,
confirmAction,
setConfirmAction,
}: InvoiceDetailPanelProps) {
return (
<>
<div className="card-std space-y-4" data-testid="invoice-detail-panel">
<h3 className="card-title">详情轮询</h3>
{detail ? (
<>
<div className="grid grid-cols-2 gap-y-3 pt-2 text-sm text-zinc-700">
<div><span className="text-zinc-500">票据ID:</span>{detail.id}</div>
<div><span className="text-zinc-500">状态:</span>{detail.status}</div>
<div className="col-span-2">
<span className="text-zinc-500">发票号:</span>
{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
</div>
<div className="col-span-2">
<span className="text-zinc-500">总金额:</span>
{detail.totalAmount ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(detail.totalAmount)) : "-"}
</div>
<div><span className="text-zinc-500">开票日期:</span>{detail.invoiceDate || "-"}</div>
<div><span className="text-zinc-500">关联报销:</span>{detail.linkedReimbursementId || "-"}</div>
<div className="col-span-2 flex flex-col gap-1">
<div>
<span className="text-zinc-500">验真状态:</span>
{detail.verification ? `${detail.verification.status} (${detail.verification.provider})` : "未触发"}
</div>
{detail.verification?.resultMessage ? (
<div className="text-zinc-900 bg-zinc-50 px-2 py-1.5 rounded-md mt-1 border border-zinc-100">{detail.verification.resultMessage}</div>
) : null}
{detail.verification?.errorMessage ? (
<div className="text-red-700 bg-red-50 px-2 py-1.5 rounded-md mt-1 border border-red-100 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px]">error</span>
{detail.verification.errorMessage}
</div>
) : null}
</div>
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{detail.ocrLastError}</div> : null}
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
<div className="col-span-2">
<span className="text-zinc-500">判重例外:</span>
{detail.isDuplicateOverride ? "已例外" : "否"} {detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2 pt-4 border-t border-zinc-100 mt-4">
{canVerifyInvoice ? (
<Button
data-testid="invoice-trigger-verification"
className="btn-secondary"
type="button"
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
onClick={() => triggerVerificationMutation.mutate(detail.id)}
>
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
</Button>
) : null}
{canVerifyInvoice &&
detail.verification &&
["failed", "error"].includes(detail.verification.status) ? (
<Button
data-testid="invoice-retry-verification"
className="btn-secondary"
type="button"
disabled={retryVerificationMutation.isPending}
onClick={() => retryVerificationMutation.mutate(detail.id)}
>
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
</Button>
) : null}
{canEditDetail ? (
<Button
data-testid="invoice-edit-open"
className="btn-secondary"
type="button"
disabled={patchMutation.isPending}
onClick={() => setEditOpen(!editOpen)}
>
{editOpen ? "关闭编辑" : "人工修正"}
</Button>
) : null}
{canEditDetail && detail.status === "ocr_failed" ? (
<Button
data-testid="invoice-retry-ocr"
className="btn-secondary"
type="button"
disabled={retryOcrMutation.isPending}
onClick={() => setConfirmAction({ type: "retryOcr", invoiceId: detail.id })}
>
重试 OCR
</Button>
) : null}
{canEditDetail && canUploadInvoice && detail.status === "bound" ? (
<Button
data-testid="invoice-unbind"
className="btn-secondary"
type="button"
disabled={unbindMutation.isPending}
onClick={() => setConfirmAction({ type: "unbind", invoiceId: detail.id })}
>
解绑
</Button>
) : null}
{canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? (
<Button
data-testid="invoice-archive"
className="btn-secondary text-red-600 hover:text-red-700 hover:bg-red-50"
type="button"
disabled={archiveMutation.isPending}
onClick={() => setConfirmAction({ type: "archive", invoiceId: detail.id })}
>
手工归档
</Button>
) : null}
{canOverrideDuplicate &&
!detail.isDuplicateOverride &&
["available", "ocr_failed"].includes(detail.status) ? (
<Button
data-testid="invoice-override-duplicate"
className="btn-secondary"
type="button"
disabled={overrideDuplicateMutation.isPending}
onClick={() => {
if (!window.confirm("确认执行判重例外?该操作将写入审计并影响判重口径。")) return;
const refFormNumber = window.prompt("请输入参考单号(必填)");
if (!refFormNumber?.trim()) return;
const reason = window.prompt("请输入例外原因(必填)");
if (!reason?.trim()) return;
overrideDuplicateMutation.mutate({
invoiceId: detail.id,
refFormNumber: refFormNumber.trim(),
reason: reason.trim(),
});
}}
>
判重例外
</Button>
) : null}
</div>
{canVerifyInvoice ? (
<div className="mt-4 rounded-lg bg-zinc-50 border border-zinc-200 p-4" data-testid="invoice-verification-records">
<p className="mb-3 text-sm font-semibold text-zinc-900 border-b border-zinc-200/60 pb-2">验真记录</p>
{(verificationQuery.data?.items ?? []).length ? (
<div className="space-y-3 text-xs">
{(verificationQuery.data?.items ?? []).map((record) => (
<div key={record.id} className="rounded-md border border-zinc-200 bg-white px-3 py-2 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 mb-1">
<span className="font-mono text-zinc-500">{record.id}</span>
<span className={`font-semibold ${record.status === "failed" || record.status === "error" ? "text-red-600" : "text-emerald-600"}`}>
{record.status}
</span>
</div>
<div className="text-zinc-600 break-words">
服务商: <strong className="font-medium text-zinc-800">{record.provider}</strong> |
重试次数: <strong className="font-medium text-zinc-800">{record.attempts}</strong> |
验真时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong>
</div>
{record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{record.resultMessage}</div> : null}
{record.errorMessage ? <div className="mt-1 text-red-600 font-medium py-1 px-2 border border-red-100 bg-red-50 rounded">{record.errorMessage}</div> : null}
</div>
))}
</div>
) : (
<p className="text-xs text-zinc-500">暂无验真记录</p>
)}
</div>
) : null}
{editOpen ? (
<div className="mt-4 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50/50 p-4">
<p className="text-sm font-semibold text-zinc-900 border-b border-zinc-200 pb-2">人工修正(仅必要字段)</p>
{detail.status === "ocr_failed" ? (
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 focus:ring-offset-1"
data-testid="invoice-edit-set-available"
checked={editSetAvailable}
onChange={(event) => setEditSetAvailable(event.target.checked)}
type="checkbox"
/>
<span className="text-zinc-700">将状态标记为 available(人工确认可用)</span>
</label>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceCode">
发票代码
</label>
<input
className="input-std"
data-testid="invoice-edit-code"
id="invoiceCode"
onChange={(event) => setEditInvoiceCode(event.target.value)}
value={editInvoiceCode}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceNumber">
发票号码
</label>
<input
className="input-std"
data-testid="invoice-edit-number"
id="invoiceNumber"
onChange={(event) => setEditInvoiceNumber(event.target.value)}
value={editInvoiceNumber}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceDate">
开票日期
</label>
<input
className="input-std"
data-testid="invoice-edit-date"
id="invoiceDate"
onChange={(event) => setEditInvoiceDate(event.target.value)}
type="date"
value={editInvoiceDate}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceTotalAmount">
总金额
</label>
<input
className="input-std"
data-testid="invoice-edit-total-amount"
id="invoiceTotalAmount"
onChange={(event) => setEditTotalAmount(event.target.value)}
value={editTotalAmount}
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceRemarks">
备注
</label>
<textarea
className="input-std resize-none"
data-testid="invoice-edit-remarks"
id="invoiceRemarks"
onChange={(event) => setEditRemarks(event.target.value)}
rows={2}
value={editRemarks}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
data-testid="invoice-edit-save"
className="btn-primary"
disabled={patchMutation.isPending}
onClick={() => patchMutation.mutate()}
type="button"
>
{patchMutation.isPending ? "保存中..." : "保存修正"}
</Button>
<Button
data-testid="invoice-edit-cancel"
className="btn-secondary"
disabled={patchMutation.isPending}
onClick={() => setEditOpen(false)}
type="button"
>
取消
</Button>
</div>
</div>
) : null}
</>
) : (
<p className="text-zinc-500 py-6 text-center bg-zinc-50/50 rounded-lg border border-dashed border-zinc-200 mt-2">请从左侧列表选择一条票据查看详情与修正选项</p>
)}
</div>
<ConfirmDialog
open={Boolean(confirmAction)}
title={
confirmAction?.type === "retryOcr" ? "确认重试 OCR" :
confirmAction?.type === "unbind" ? "确认解绑" :
confirmAction?.type === "archive" ? "确认归档" : "确认操作"
}
description={
confirmAction?.type === "retryOcr" ? "将重新进入 pending_ocr 并消耗一次尝试次数。" :
confirmAction?.type === "unbind" ? "仅草稿/驳回报销允许解绑。" :
confirmAction?.type === "archive" ? "归档后将不再用于绑定与判重。" : ""
}
variant={confirmAction?.type === "archive" ? "destructive" : "default"}
onConfirm={async () => {
if (!confirmAction) return;
if (confirmAction.type === "retryOcr") retryOcrMutation.mutate(confirmAction.invoiceId);
if (confirmAction.type === "unbind") unbindMutation.mutate(confirmAction.invoiceId);
if (confirmAction.type === "archive") archiveMutation.mutate(confirmAction.invoiceId);
}}
onOpenChange={(open) => { if (!open) setConfirmAction(null); }}
/>
</>
);
}
@@ -0,0 +1,144 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { InvoicePoolListResponse, InvoicePoolStatus } from "../../api/invoicePool";
import { StatusBadge } from "../../components/common/StatusBadge";
import { TableSkeleton } from "../../components/common/TableSkeleton";
import { Pagination } from "../../components/common/Pagination";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
export type InvoiceListCardProps = {
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
status: InvoicePoolStatus | "";
setStatus: (status: InvoicePoolStatus | "") => void;
ownerScope: "own" | "all";
setOwnerScope: (scope: "own" | "all") => void;
page: number;
setPage: (page: number) => void;
totalPages: number;
detailInvoiceId: string;
setDetailInvoiceId: (id: string) => void;
signedUrlMutation: UseMutationResult<{ signedUrl: string; expiresAt: string }, Error, string>;
};
export function InvoiceListCard({
listQuery,
status,
setStatus,
ownerScope,
setOwnerScope,
page,
setPage,
totalPages,
setDetailInvoiceId,
signedUrlMutation,
}: InvoiceListCardProps) {
return (
<div className="card-std space-y-4">
<div className="flex items-center justify-between">
<h3 className="card-title">筛选与列表</h3>
<div className="flex items-center gap-2">
<select
className="select-sm"
data-testid="invoice-filter-status"
onChange={(event) => {
setStatus(event.target.value as InvoicePoolStatus | "");
setPage(1);
}}
value={status}
>
<option value="">全部状态</option>
<option value="pending_ocr">识别中</option>
<option value="available">可用</option>
<option value="ocr_failed">识别失败</option>
<option value="bound">已绑定</option>
<option value="archived">已归档</option>
</select>
<select
className="select-sm"
data-testid="invoice-filter-owner-scope"
onChange={(event) => {
setOwnerScope(event.target.value as "own" | "all");
setPage(1);
}}
value={ownerScope}
>
<option value="own">仅看自己</option>
<option value="all">查看全部</option>
</select>
</div>
</div>
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{!listQuery.isLoading ? (
<>
<div className="table-container">
<table className="w-full text-left text-sm text-zinc-600" data-testid="invoice-pool-table">
<thead className="bg-zinc-50/50">
<tr>
<th className="table-th w-[280px]">票据流水号</th>
<th className="table-th w-[120px]">状态</th>
<th className="table-th w-[200px]">发票号码</th>
<th className="table-th">识别情况 (OCR)</th>
<th className="table-th w-[80px] text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<tr className="table-row" data-testid="invoice-row" key={item.id}>
<td className="table-td">
<div className="font-mono text-xs text-zinc-500">{item.id}</div>
</td>
<td className="table-td"><StatusBadge status={item.status} /></td>
<td className="table-td font-medium text-zinc-900">
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</td>
<td className="table-td">
{item.status === "pending_ocr" ? (
<span className="flex items-center gap-1.5 text-amber-600 font-medium">
<span className="size-1.5 rounded-full bg-amber-500 animate-pulse" />
识别中 <span className="text-zinc-400 font-normal text-xs">(重试: {item.ocrAttempts})</span>
</span>
) : item.status === "ocr_failed" ? (
<span className="flex items-center gap-1.5 text-red-600">
<span className="size-1.5 rounded-full bg-red-500" />
识别失败 <span className="text-zinc-400 text-xs">({item.ocrLastError || "未知错误"})</span>
</span>
) : (
<span className="text-zinc-400 text-xs">解析成功</span>
)}
</td>
<td className="table-td text-right">
<DropdownMenu>
<DropdownMenuTrigger className="rounded-md p-1 hover:bg-zinc-100 focus:outline-none transition-colors">
<span className="material-symbols-outlined text-[20px] text-zinc-400">more_horiz</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem data-testid="invoice-detail-button" onSelect={() => setDetailInvoiceId(item.id)}>
查看详情
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-preview-button" onSelect={() => signedUrlMutation.mutate(item.id)}>
下载 / 预览
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))
) : (
<tr>
<td className="table-td text-center text-zinc-500 py-8" colSpan={5}>
暂无票据数据
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} testIdPrefix="invoice" />
</>
) : null}
</div>
);
}
@@ -0,0 +1,73 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { FetchMyDelegationsResponse } from "../../api/delegations";
import { Button } from "../../components/ui/button";
export type InvoiceUploadCardProps = {
uploadMutation: UseMutationResult<{ invoicePoolId: string; status: string; fileSha256: string }, Error, void>;
selectedFile: File | null;
setSelectedFile: (file: File | null) => void;
showProxyOwnerSelector: boolean;
proxyDelegationsQuery: UseQueryResult<FetchMyDelegationsResponse, Error>;
uploadOwnerEmployeeId: string;
setUploadOwnerEmployeeId: (id: string) => void;
};
export function InvoiceUploadCard({
uploadMutation,
selectedFile,
setSelectedFile,
showProxyOwnerSelector,
proxyDelegationsQuery,
uploadOwnerEmployeeId,
setUploadOwnerEmployeeId,
}: InvoiceUploadCardProps) {
return (
<div className="card-std space-y-4">
<h3 className="card-title">上传票据(VAT OCR)</h3>
{showProxyOwnerSelector ? (
<select
className="select-std"
data-testid="invoice-upload-owner"
onChange={(event) => setUploadOwnerEmployeeId(event.target.value)}
value={uploadOwnerEmployeeId}
>
<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}
<div className="flex items-center gap-3">
<label
htmlFor="file-upload"
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-zinc-50 px-4 py-2 text-sm font-medium text-zinc-600 shadow-sm transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-within:ring-1 focus-within:ring-zinc-400"
>
<span className="material-symbols-outlined mr-2 text-[18px]">cloud_upload</span>
{selectedFile ? selectedFile.name : "选择文件"}
</label>
<input
id="file-upload"
className="hidden"
accept="application/pdf,image/jpeg,image/png,image/webp"
data-testid="invoice-upload-file"
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
type="file"
/>
<Button
data-testid="invoice-upload-submit"
className={selectedFile ? "" : "opacity-50"}
disabled={uploadMutation.isPending || !selectedFile}
onClick={() => uploadMutation.mutate()}
type="button"
>
{uploadMutation.isPending ? "上传中..." : "上传并触发 OCR"}
</Button>
</div>
</div>
);
}