重构审批中心与报销详情降噪(approval-detail-simplification-refactor)

这个提交包含在:
2026-05-29 13:10:06 +08:00
父节点 29cfea2bbb
当前提交 5f10b83706
共修改 11 个文件,包含 739 行新增和 177 行删除
+361 -95
查看文件
@@ -22,6 +22,7 @@ import {
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import {
@@ -145,6 +146,22 @@ function budgetControlModeLabel(controlMode: string | null | undefined) {
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
}
function reimbursementStatusLabel(status: ReimbursementStatus) {
const labels: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已批准",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? status;
}
function materialCompletenessLabel(count: number, noun: string) {
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -158,6 +175,7 @@ export function ReimbursementDetailPage() {
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
@@ -971,12 +989,159 @@ export function ReimbursementDetailPage() {
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
};
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const attachmentCount = (detail.attachments ?? []).length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
const currentHandlerText = (() => {
if (detail.status === "reviewing") {
return detail.pendingWorkflow?.task?.step_name ?? "当前审批节点";
}
if (detail.status === "approved") {
return canPay ? "财务处理" : "等待财务";
}
if (detail.status === "paid") {
return payment.paidBy?.name ?? "财务已处理";
}
if (["draft", "rejected"].includes(detail.status)) {
return detail.owner?.name ?? "报销人";
}
return "-";
})();
const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) {
if (!itemCount) {
return "补报销明细";
}
if (!invoiceCount) {
return "添加发票";
}
if (hasBlockingPrecheck) {
return "修改超标明细";
}
return "提交审批";
}
if (detail.status === "reviewing") {
return detail.workflowProgress
? `查看第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步`
: "查看审批进度";
}
if (detail.status === "approved") {
return canPay ? "执行付款" : "等待财务付款";
}
if (detail.status === "paid") {
return "查看付款结果";
}
return "查看记录";
})();
const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
if (!itemCount) {
return (
<Button
data-testid="reimbursement-primary-action"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
>
新增明细
</Button>
);
}
if (!invoiceCount) {
return (
<Button
data-testid="reimbursement-primary-action"
disabled={!canOpenInvoiceBindDialog}
onClick={() => handleInvoiceDialogOpenChange(true)}
type="button"
>
添加发票
</Button>
);
}
return (
<Button
data-testid={TEST_IDS.reimbursement.submit}
disabled={mutation.isPending || hasBlockingPrecheck}
onClick={() => void handleSubmitAction()}
type="button"
>
{mutation.isPending ? "提交中..." : "提交报销"}
</Button>
);
}
if (detail.status === "reviewing") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-workflow-progress-panel">看审批进度</a>
</Button>
);
}
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href={canPay ? "#reimbursement-action-panel" : "#reimbursement-payment-panel"}>
{canPay ? "执行支付" : "看付款状态"}
</a>
</Button>
);
}
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-payment-panel">查看付款结果</a>
</Button>
);
}
return (
<Button asChild data-testid="reimbursement-primary-action">
<Link to="/reimbursements">返回列表</Link>
</Button>
);
})();
const secondaryActions = [
detail.status === "reviewing" && canRemindPendingWorkflow && pendingTaskId ? (
<Button
key="remind"
data-testid="reimbursement-remind-workflow"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
type="button"
variant="secondary"
>
{remindMutation.isPending ? "催办中..." : "催办审批"}
</Button>
) : detail.status === "paid" ? (
<Button key="print" asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : (
<Button key="back" asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
),
<Button
key="more"
type="button"
variant="secondary"
data-testid="reimbursement-more-toggle"
onClick={() => setShowSecondaryDetail((value) => !value)}
>
{showSecondaryDetail ? "收起更多" : "更多"}
</Button>,
];
return (
<PageScaffold>
{downloadReasonDialog}
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
subtitle="先确认当前状态和下一步,再处理明细、发票、附件和审批记录。"
meta={(
<>
<StatusBadge status={detail.status} />
@@ -987,43 +1152,134 @@ export function ReimbursementDetailPage() {
</>
)}
actions={(
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
data-testid="reimbursement-remind-workflow"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
type="button"
variant="secondary"
>
{remindMutation.isPending ? "催办中..." : "催办审批"}
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
<div className="flex flex-wrap gap-2" data-testid="reimbursement-first-screen-actions">
{primaryAction}
{secondaryActions}
</div>
)}
/>
<div className="detail-workspace-grid">
<div className="space-y-5">
<SectionPanel title="当前状态与下一步" description="提交、付款、附件和审批依据集中在本页处理。">
<div className="space-y-4" data-testid="reimbursement-overview">
<SectionPanel title="当前状态与下一步">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div className="shrink-0">
<StatusBadge status={detail.status} />
</div>
</div>
<div className="detail-fact-grid xl:grid-cols-4" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">状态</div>
<div className="detail-fact-card__value">状态:{reimbursementStatusLabel(detail.status)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">处理人</div>
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">金额</div>
<div className="detail-fact-card__value">¥{detail.totalAmount ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料完整性</div>
<div className="detail-fact-card__value">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
</div>
</div>
</div>
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="reimbursement-action-error"
>
{actionError}
</p>
) : null}
{submitInvoiceNotice ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
{submitInvoiceNotice}
</p>
) : null}
{hasBlockingPrecheck ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
请修改超标明细后提交。
</p>
) : null}
</div>
{["approved", "paid"].includes(detail.status) ? (
<div id="reimbursement-payment-panel" className="detail-command-panel mt-4" data-testid="reimbursement-payment-status">
<div className="flex items-center justify-between gap-3">
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
detail.status === "paid"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
>
{detail.status === "paid" ? "已付款" : "未付款"}
</span>
</div>
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款日期</div>
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款凭证号</div>
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{payment.paymentChannel ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
</div>
</div>
</div>
</div>
) : null}
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
</div>
</div>
<StatStrip columns={4} className="ops-status-strip">
<MetricCard
title="关联项目"
@@ -1041,7 +1297,7 @@ export function ReimbursementDetailPage() {
/>
</StatStrip>
<div className="detail-command-panel" data-testid="reimbursement-summary">
<div className="detail-command-panel" data-testid="reimbursement-secondary-summary">
<div className="detail-fact-grid">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销编号</div>
@@ -1062,7 +1318,7 @@ export function ReimbursementDetailPage() {
</div>
</div>
<div className="detail-command-panel" data-testid="reimbursement-payment-status">
<div className="detail-command-panel" data-testid="reimbursement-secondary-payment-status">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
@@ -1163,7 +1419,6 @@ export function ReimbursementDetailPage() {
))}
</select>
</div>
</div>
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -1242,7 +1497,9 @@ export function ReimbursementDetailPage() {
) : (
<p className="text-sm text-zinc-500">暂无附件材料</p>
)}
</div>
</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
@@ -1579,75 +1836,84 @@ export function ReimbursementDetailPage() {
</div>
<div className="space-y-5">
<DetailSideMeta title="报销摘要">
<div>报销编号:{displayCode}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
<div>明细数:{detail.items.length}</div>
<div>票据数:{detail.invoices.length}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
{detail.status === "reviewing" && detail.workflowProgress ? (
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
{showSecondaryDetail ? (
<DetailSideMeta title="报销摘要">
<div>报销编号:{displayCode}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
<div>明细数:{detail.items.length}</div>
<div>票据数:{detail.invoices.length}</div>
<div>附件数:{attachmentCount}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
) : null}
<DetailSideMeta title="审批记录">
{detail.approvalHistory.length ? (
<div className="detail-timeline" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
{detail.status === "reviewing" && detail.workflowProgress ? (
<div id="reimbursement-workflow-progress-panel">
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
))}
</div>
</DetailSideMeta>
</div>
) : null}
{showSecondaryDetail ? (
<DetailSideMeta title="审批记录">
{detail.approvalHistory.length ? (
<div className="detail-timeline" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</DetailSideMeta>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
showSubmitAction={false}
showPayAction={detail.status === "approved"}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""}
submitNotice={submitInvoiceNotice}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
+111 -67
查看文件
@@ -21,7 +21,6 @@ import {
StatStrip,
WorkspaceEmptyState,
WorkspaceHeader,
WorkspaceNotice,
} from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button";
@@ -137,12 +136,12 @@ function isAlreadyProcessedTaskError(error: unknown) {
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
const queryClient = useQueryClient();
const status = normalizeStatusFilter(searchParams.get("status"));
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
const page = Number(searchParams.get("page") ?? "1") || 1;
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
const queryClient = useQueryClient();
function updateSearchParams(next: {
status?: WorkflowStatusFilter;
@@ -203,6 +202,15 @@ export function WorkflowTasksPage() {
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
const taskItems = query.data?.items ?? [];
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
const isDefaultTaskView = status === "pending" && entityType === "all";
const advancedVisible = showAdvanced || !isDefaultTaskView;
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
const overdueTotal = summaryQuery.data?.overdueTotal ?? "-";
const pendingEntitySummary =
(summaryQuery.data?.byEntityType ?? [])
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-";
return (
<>
@@ -210,73 +218,107 @@ export function WorkflowTasksPage() {
<WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="先看待处理,再进入详情核对依据;快速通过前仍会弹出确认。"
actions={
<Button
type="button"
variant="secondary"
data-testid="workflow-tasks-advanced-toggle"
onClick={() => setShowAdvanced((value) => !value)}
>
{advancedVisible ? "收起高级" : "高级 / 历史"}
</Button>
}
/>
<div data-testid="workflow-task-summary">
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
<MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
<MetricCard
title="业务分布"
value={
<span className="block truncate text-base">
{(summaryQuery.data?.byEntityType ?? [])
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-"}
</span>
}
/>
</StatStrip>
</div>
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={status}
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{WORKFLOW_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`workflow-tasks-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
<span className="badge-neutral">待处理</span>
<span className="text-2xl font-semibold text-zinc-900">{pendingTotal}</span>
<span className="text-sm text-zinc-500">
最早:{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}
</span>
{summaryQuery.data?.overdueTotal ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
超时 {summaryQuery.data.overdueTotal}
</span>
) : null}
</div>
<div className="ops-command-bar__actions">
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
业务类型
</label>
<select
id="workflow-entity-type-filter"
className="select-sm min-w-32"
data-testid="workflow-tasks-entity-filter"
value={entityType}
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
>
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
{!isDefaultTaskView ? (
<Button
type="button"
size="sm"
onClick={() => {
setShowAdvanced(false);
updateSearchParams({ status: "pending", entityType: "all", page: 1 });
}}
>
回到待处理
</Button>
) : null}
<span className="text-xs text-zinc-500">业务:{pendingEntitySummary}</span>
</div>
</div>
</FilterBar>
{status === "pending" ? (
<WorkspaceNotice title="处理原则" tone="neutral">
先看业务编号、申请人、金额/天数和当前节点;需要查看附件、审批历史或驳回原因时进入详情页。
</WorkspaceNotice>
{advancedVisible ? (
<div className="space-y-3" data-testid="workflow-tasks-advanced-panel">
<div data-testid="workflow-task-summary">
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="待办总数" value={pendingTotal} />
<MetricCard title="超时待办" value={overdueTotal} tone="warning" />
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
<MetricCard
title="业务分布"
value={<span className="block truncate text-base">{pendingEntitySummary}</span>}
/>
</StatStrip>
</div>
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={status}
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{WORKFLOW_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`workflow-tasks-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="ops-command-bar__actions">
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
业务类型
</label>
<select
id="workflow-entity-type-filter"
className="select-sm min-w-32"
data-testid="workflow-tasks-entity-filter"
value={entityType}
onChange={(event) =>
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })
}
>
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
</div>
</FilterBar>
</div>
) : null}
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
@@ -348,13 +390,15 @@ export function WorkflowTasksPage() {
variant="secondary"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
</Button>
</div>
</article>
))
) : (
<WorkspaceEmptyState>当前筛选下没有审批任务。</WorkspaceEmptyState>
<WorkspaceEmptyState>
{isDefaultTaskView ? "当前没有待处理任务。" : "当前筛选下没有审批任务。"}
</WorkspaceEmptyState>
)}
</div>
@@ -405,10 +449,10 @@ export function WorkflowTasksPage() {
<Button
asChild
size="sm"
variant="secondary"
variant={status === "pending" && (task.allowedActions ?? []).includes("approve") ? "secondary" : "default"}
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
</Button>
</div>
</TableCell>
@@ -417,7 +461,7 @@ export function WorkflowTasksPage() {
) : (
<TableRow>
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
当前筛选下没有审批任务
{isDefaultTaskView ? "当前没有待处理任务" : "当前筛选下没有审批任务"}
</TableCell>
</TableRow>
)}
@@ -20,6 +20,9 @@ export interface ReimbursementActionsProps {
submitDisabled?: boolean;
submitHint?: string;
submitNotice?: string;
showSubmitAction?: boolean;
showWithdrawAction?: boolean;
showPayAction?: boolean;
isPending: boolean;
onSubmit: () => void;
onWithdraw: (comment: string) => void;
@@ -33,6 +36,9 @@ export function ReimbursementActions({
submitDisabled = false,
submitHint = "",
submitNotice = "",
showSubmitAction = true,
showWithdrawAction = true,
showPayAction = true,
isPending,
onSubmit,
onWithdraw,
@@ -43,9 +49,16 @@ export function ReimbursementActions({
const [paymentDate, setPaymentDate] = useState("2026-02-08");
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
const [paymentNote, setPaymentNote] = useState("");
const canShowSubmit = showSubmitAction && ["draft", "rejected"].includes(status);
const canShowWithdraw = showWithdrawAction && status === "reviewing";
const canShowPay = showPayAction && canPay && status === "approved";
if (!canShowSubmit && !canShowWithdraw && !canShowPay && !actionError && !submitHint && !submitNotice) {
return null;
}
return (
<div className="card-std space-y-4 p-6">
<div id="reimbursement-action-panel" className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">可执行动作</h3>
{actionError ? (
@@ -57,16 +70,18 @@ export function ReimbursementActions({
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-submit"
disabled={isPending || !["draft", "rejected"].includes(status) || submitDisabled}
onClick={onSubmit}
type="button"
>
提交报销
</Button>
</div>
{canShowSubmit ? (
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-submit"
disabled={isPending || submitDisabled}
onClick={onSubmit}
type="button"
>
提交报销
</Button>
</div>
) : null}
{submitHint ? <p className="text-sm text-zinc-500">{submitHint}</p> : null}
{submitNotice ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
@@ -74,7 +89,7 @@ export function ReimbursementActions({
</p>
) : null}
{status === "reviewing" ? (
{canShowWithdraw ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
<Textarea
@@ -95,7 +110,7 @@ export function ReimbursementActions({
</div>
) : null}
{canPay ? (
{canShowPay ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">财务支付</p>
<Input
@@ -134,7 +149,7 @@ export function ReimbursementActions({
<Button
data-testid="reimbursement-pay"
variant="secondary"
disabled={isPending || !["approved", "paid"].includes(status)}
disabled={isPending}
onClick={() =>
onPay({
paymentVoucherNo: paymentVoucherNo.trim(),
@@ -328,5 +328,11 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
await page.goto("/workflow/tasks");
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
await expect(page.getByTestId("workflow-tasks-focus")).toBeVisible();
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
await expect(page.getByTestId("workflow-tasks-status-tabs")).toHaveCount(0);
await page.getByTestId("workflow-tasks-advanced-toggle").click();
await expect(page.getByTestId("workflow-tasks-advanced-panel")).toBeVisible();
await expect(page.getByTestId("workflow-task-summary")).toBeVisible();
await capture(page, "04-workflow-tasks");
});
@@ -1059,8 +1059,13 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: /报销详情/ })).toBeVisible();
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
await expect(page.getByTestId("reimbursement-payment-status")).toBeVisible();
await expect(page.getByTestId("reimbursement-first-screen-actions").locator("a,button")).toHaveCount(3);
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度");
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
await page.goto("/workflow/tasks/task-wave3");
@@ -337,6 +337,8 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
await expect(page.getByText("报销ID")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
await page.getByTestId("reimbursement-invoice-add").click();