R97 B1-B5 精修高频页面操作密度
这个提交包含在:
@@ -177,9 +177,9 @@ export function InvoicePoolPage() {
|
|||||||
if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") {
|
if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
if (currentStatus === "available") {
|
if (currentStatus === "available") {
|
||||||
toast.success("票据 OCR 已完成,可继续绑定或验真");
|
toast.success("票据识别已完成,可继续绑定或验真");
|
||||||
} else if (currentStatus === "ocr_failed") {
|
} else if (currentStatus === "ocr_failed") {
|
||||||
toast.error(`票据 OCR 未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`);
|
toast.error(`票据识别未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export function InvoicePoolPage() {
|
|||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
const hasPending = result.successes.some((item) => item.data.status === "pending_ocr");
|
const hasPending = result.successes.some((item) => item.data.status === "pending_ocr");
|
||||||
toast.success(hasPending ? `已上传 ${result.successes.length} 个,OCR 识别中` : `已上传 ${result.successes.length} 个,可直接处理`);
|
toast.success(hasPending ? `已上传 ${result.successes.length} 个,识别中` : `已上传 ${result.successes.length} 个,可直接处理`);
|
||||||
},
|
},
|
||||||
onSettled: () => setUploadProgress(null),
|
onSettled: () => setUploadProgress(null),
|
||||||
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
||||||
@@ -368,9 +368,9 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
toast.success("OCR 重试已触发");
|
toast.success("已开始重新识别");
|
||||||
},
|
},
|
||||||
onError: (err) => handleError(err, "OCR 重试失败,请稍后再试"),
|
onError: (err) => handleError(err, "重新识别失败,请稍后再试"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerVerificationMutation = useMutation({
|
const triggerVerificationMutation = useMutation({
|
||||||
|
|||||||
@@ -771,66 +771,73 @@ export function MobileApprovalsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
|
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
|
||||||
{ENTITY_FILTER_OPTIONS.map((option) => (
|
|
||||||
<TabsTrigger key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
data-testid="mobile-approvals-search"
|
data-testid="mobile-approvals-search"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(event) => setSearchInput(event.target.value)}
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
placeholder="搜索步骤名、业务类型、任务 ID 或业务实体 ID"
|
placeholder="搜索待办"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{taskTab === "pending" ? (
|
<details className="rounded-xl border border-zinc-200 bg-zinc-50/70 px-3 py-2 text-sm">
|
||||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
|
<summary className="cursor-pointer select-none font-medium text-zinc-700">
|
||||||
<Button
|
筛选与批量
|
||||||
type="button"
|
</summary>
|
||||||
size="sm"
|
<div className="mt-3 space-y-3">
|
||||||
variant={batchMode ? "default" : "secondary"}
|
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
|
||||||
className="min-h-11 w-full sm:w-auto"
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||||
disabled={!filteredTaskCards.length}
|
{ENTITY_FILTER_OPTIONS.map((option) => (
|
||||||
onClick={() => {
|
<TabsTrigger key={option.value} value={option.value}>
|
||||||
setBatchMode((current) => !current);
|
{option.label}
|
||||||
setSelectedBatchTaskIds([]);
|
</TabsTrigger>
|
||||||
setBatchRejectComment("");
|
))}
|
||||||
}}
|
</TabsList>
|
||||||
>
|
</Tabs>
|
||||||
{batchMode ? "退出批量处理" : "批量处理"}
|
|
||||||
</Button>
|
{taskTab === "pending" ? (
|
||||||
{batchMode ? (
|
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant={batchMode ? "default" : "secondary"}
|
||||||
className="min-h-11 w-full sm:w-auto"
|
className="min-h-11 w-full sm:w-auto"
|
||||||
disabled={!filteredTaskCards.length}
|
disabled={!filteredTaskCards.length}
|
||||||
onClick={toggleSelectAllVisibleTasks}
|
onClick={() => {
|
||||||
>
|
setBatchMode((current) => !current);
|
||||||
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
|
setSelectedBatchTaskIds([]);
|
||||||
</Button>
|
setBatchRejectComment("");
|
||||||
) : null}
|
}}
|
||||||
<span className="text-sm text-zinc-500 sm:ml-1">
|
>
|
||||||
当前结果 {filteredTaskCards.length} 条
|
{batchMode ? "退出批量" : "批量"}
|
||||||
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
|
</Button>
|
||||||
</span>
|
{batchMode ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
|
disabled={!filteredTaskCards.length}
|
||||||
|
onClick={toggleSelectAllVisibleTasks}
|
||||||
|
>
|
||||||
|
{allVisibleSelected ? "清空选择" : "全选当前"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<span className="text-sm text-zinc-500 sm:ml-1">
|
||||||
|
当前 {filteredTaskCards.length} 条
|
||||||
|
{batchMode ? `,已选 ${selectedBatchTaskIds.length}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">仅显示最近 30 天。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</details>
|
||||||
<p className="text-sm text-zinc-500">已处理页仅展示最近 30 天内的 completed 任务。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{batchMode ? (
|
{batchMode ? (
|
||||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
|
<div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div>
|
||||||
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -839,7 +846,7 @@ export function MobileApprovalsPage() {
|
|||||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
||||||
onClick={() => handleBatchAction("approve")}
|
onClick={() => handleBatchAction("approve")}
|
||||||
>
|
>
|
||||||
{canUseRemote ? "批量通过" : "离线批量加入通过草稿"}
|
{canUseRemote ? "批量通过" : "加入通过草稿"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -849,7 +856,7 @@ export function MobileApprovalsPage() {
|
|||||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
||||||
onClick={() => handleBatchAction("reject")}
|
onClick={() => handleBatchAction("reject")}
|
||||||
>
|
>
|
||||||
{canUseRemote ? "批量驳回" : "离线批量加入驳回草稿"}
|
{canUseRemote ? "批量驳回" : "加入驳回草稿"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -920,7 +927,7 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="font-medium text-zinc-900">{task.title}</div>
|
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">任务:{task.id}</div>
|
<div className="mt-1 text-xs text-zinc-500">任务:{task.id}</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
</button>
|
</button>
|
||||||
@@ -955,7 +962,7 @@ export function MobileApprovalsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
|
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">详情与处理</h3>
|
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
|
||||||
|
|
||||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||||
|
|
||||||
@@ -1025,13 +1032,13 @@ export function MobileApprovalsPage() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
data-testid="mobile-approvals-comment"
|
data-testid="mobile-approvals-comment"
|
||||||
onChange={(event) => setComment(event.target.value)}
|
onChange={(event) => setComment(event.target.value)}
|
||||||
placeholder="驳回 / 退回意见;离线仅支持驳回意见入队。"
|
placeholder="意见(驳回/退回必填)"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={comment}
|
value={comment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!canUseRemote ? (
|
{!canUseRemote ? (
|
||||||
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
<p className="text-xs text-zinc-500">离线仅支持通过 / 驳回草稿。</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
@@ -1123,8 +1130,8 @@ export function MobileApprovalsPage() {
|
|||||||
|
|
||||||
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">离线审批草稿队列</h3>
|
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
|
||||||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待处理 ${currentQueueItems.length} 项` : "无待处理项"}</span>
|
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentQueueItems.length ? (
|
{currentQueueItems.length ? (
|
||||||
@@ -1137,12 +1144,12 @@ export function MobileApprovalsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{syncMutation.isPending ? "同步中..." : `同步草稿队列 (${retryableQueueItems.length})`}
|
{syncMutation.isPending ? "同步中..." : `同步草稿 (${retryableQueueItems.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!currentQueueItems.length ? <p className="text-sm text-zinc-500">暂无待同步审批草稿。</p> : null}
|
{!currentQueueItems.length ? <p className="text-sm text-zinc-500">暂无待同步草稿。</p> : null}
|
||||||
|
|
||||||
{currentQueueItems.map((item) => (
|
{currentQueueItems.map((item) => (
|
||||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export function ReimbursementsPage() {
|
|||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="我的报销"
|
title="我的报销"
|
||||||
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
|
subtitle="草稿、审批、支付。"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
{showProxyOwnerSelector ? (
|
{showProxyOwnerSelector ? (
|
||||||
@@ -491,10 +491,10 @@ export function ReimbursementsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
|
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
|
||||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
|
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
|
||||||
<MetricCard title="审批中" value={stats.reviewing} note="待继续推进的报销单" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
||||||
<MetricCard title="已支付" value={stats.paid} note={`当前范围:${scope === "all" ? "全范围" : "仅自己"}`} />
|
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<FilterBar className="space-y-4">
|
<FilterBar className="space-y-4">
|
||||||
@@ -566,7 +566,7 @@ export function ReimbursementsPage() {
|
|||||||
|
|
||||||
{batchMode ? (
|
{batchMode ? (
|
||||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
当前只可勾选你本人可处理的 reviewing 报销待办。批量通过可不填意见,批量驳回必须填写统一理由。
|
仅勾选你可处理的审批中报销;驳回需填写理由。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
@@ -636,7 +636,7 @@ export function ReimbursementsPage() {
|
|||||||
|
|
||||||
{item.pendingWorkflow?.task?.id ? (
|
{item.pendingWorkflow?.task?.id ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
当前待办:{item.pendingWorkflow.task.stepName}
|
待办:{item.pendingWorkflow.task.stepName}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -653,7 +653,12 @@ export function ReimbursementsPage() {
|
|||||||
选择
|
选择
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<Button asChild className="min-h-10 flex-1" size="sm" variant="secondary">
|
<Button
|
||||||
|
asChild
|
||||||
|
className="min-h-11 flex-1"
|
||||||
|
size="sm"
|
||||||
|
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/reimbursements/${item.id}`}
|
to={`/reimbursements/${item.id}`}
|
||||||
data-testid="reimbursement-mobile-detail-link"
|
data-testid="reimbursement-mobile-detail-link"
|
||||||
@@ -720,7 +725,7 @@ export function ReimbursementsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{item.pendingWorkflow?.task?.id ? (
|
{item.pendingWorkflow?.task?.id ? (
|
||||||
<div className="text-xs text-amber-700">
|
<div className="text-xs text-amber-700">
|
||||||
当前待办:{item.pendingWorkflow.task.stepName} · task {item.pendingWorkflow.task.id}
|
待办:{item.pendingWorkflow.task.stepName}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
@@ -744,8 +749,12 @@ export function ReimbursementsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||||
<td className="table-td">
|
<td className="table-td">
|
||||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
<Link
|
||||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
to={`/reimbursements/${item.id}`}
|
||||||
|
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||||
|
data-testid="reimbursement-detail-link"
|
||||||
|
>
|
||||||
|
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -71,12 +71,13 @@ export function WorkflowTasksPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
const taskItems = query.data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销、请假、采购与差旅流程。" />
|
<PageHeader title="待我处理" subtitle="待处理审批。" />
|
||||||
|
|
||||||
<div className="card-std p-6 space-y-4">
|
<div className="card-std space-y-4 p-4 md:p-5">
|
||||||
<Tabs
|
<Tabs
|
||||||
data-testid="workflow-tasks-status-tabs"
|
data-testid="workflow-tasks-status-tabs"
|
||||||
value={status}
|
value={status}
|
||||||
@@ -116,70 +117,122 @@ export function WorkflowTasksPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!query.isLoading && !query.isError ? (
|
{!query.isLoading && !query.isError ? (
|
||||||
<div className="rounded-lg border border-zinc-200">
|
<>
|
||||||
<Table data-testid="workflow-tasks-table">
|
<div className="space-y-3 md:hidden" data-testid="workflow-tasks-mobile-list">
|
||||||
<TableHeader>
|
{taskItems.length ? (
|
||||||
<tr>
|
taskItems.map((task) => (
|
||||||
<TableHead>任务</TableHead>
|
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" key={task.id}>
|
||||||
<TableHead>业务对象</TableHead>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<TableHead>状态</TableHead>
|
<div className="min-w-0">
|
||||||
<TableHead>创建时间</TableHead>
|
<div className="truncate text-sm font-semibold text-zinc-900">{task.stepName}</div>
|
||||||
<TableHead className="w-24">操作</TableHead>
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
</tr>
|
{entityLabel(task.workflowInstance?.entityType)} · {new Date(task.createdAt).toLocaleString()}
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(query.data?.items ?? []).length ? (
|
|
||||||
query.data?.items.map((task) => (
|
|
||||||
<TableRow data-testid="workflow-task-row" key={task.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
|
||||||
<div className="text-xs text-zinc-500">{task.id}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium text-zinc-900">
|
|
||||||
{entityLabel(task.workflowInstance?.entityType)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs text-zinc-500">
|
</div>
|
||||||
{task.workflowInstance?.entityId ?? "-"}
|
<StatusBadge className="shrink-0" status={task.status} />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
<div className="mt-3 truncate rounded-lg bg-zinc-50 px-3 py-2 font-mono text-xs text-zinc-500">
|
||||||
<TableCell><StatusBadge status={task.status} /></TableCell>
|
{task.workflowInstance?.entityId ?? task.id}
|
||||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
</div>
|
||||||
<TableCell>
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
|
||||||
{status === "pending" && task.allowedActions.includes("approve") ? (
|
<Button
|
||||||
|
className="min-h-11"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
data-testid={TEST_IDS.workflow.quickApprove}
|
||||||
|
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||||
|
onClick={() => approveMutation.mutate(task.id)}
|
||||||
|
>
|
||||||
|
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className={`min-h-11 ${
|
||||||
|
status === "pending" && (task.allowedActions ?? []).includes("approve") ? "" : "col-span-2"
|
||||||
|
}`}
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
data-testid={TEST_IDS.workflow.detailLink}
|
||||||
|
>
|
||||||
|
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden rounded-lg border border-zinc-200 md:block">
|
||||||
|
<Table data-testid="workflow-tasks-table">
|
||||||
|
<TableHeader>
|
||||||
|
<tr>
|
||||||
|
<TableHead>任务</TableHead>
|
||||||
|
<TableHead>业务对象</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="w-24">操作</TableHead>
|
||||||
|
</tr>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{taskItems.length ? (
|
||||||
|
taskItems.map((task) => (
|
||||||
|
<TableRow data-testid="workflow-task-row" key={task.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{task.id}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">
|
||||||
|
{entityLabel(task.workflowInstance?.entityType)}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-zinc-500">
|
||||||
|
{task.workflowInstance?.entityId ?? "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><StatusBadge status={task.status} /></TableCell>
|
||||||
|
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
data-testid={TEST_IDS.workflow.quickApprove}
|
||||||
|
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||||
|
onClick={() => approveMutation.mutate(task.id)}
|
||||||
|
>
|
||||||
|
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
variant="secondary"
|
||||||
data-testid={TEST_IDS.workflow.quickApprove}
|
data-testid={TEST_IDS.workflow.detailLink}
|
||||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
|
||||||
onClick={() => approveMutation.mutate(task.id)}
|
|
||||||
>
|
>
|
||||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
<Button
|
</TableCell>
|
||||||
asChild
|
</TableRow>
|
||||||
size="sm"
|
))
|
||||||
variant="secondary"
|
) : (
|
||||||
data-testid={TEST_IDS.workflow.detailLink}
|
<TableRow>
|
||||||
>
|
<TableCell className="py-3 text-zinc-500" colSpan={5}>
|
||||||
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
暂无数据
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
)}
|
||||||
) : (
|
</TableBody>
|
||||||
<TableRow>
|
</Table>
|
||||||
<TableCell className="py-3 text-zinc-500" colSpan={5}>
|
</div>
|
||||||
暂无数据
|
</>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ export function InvoiceDetailPanel({
|
|||||||
}: InvoiceDetailPanelProps) {
|
}: InvoiceDetailPanelProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card-std p-6 space-y-4" data-testid="invoice-detail-panel">
|
<div className="card-std space-y-4 p-4 md:p-5" data-testid="invoice-detail-panel">
|
||||||
<h3 className="card-title">详情轮询</h3>
|
<h3 className="card-title">票据详情</h3>
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-y-3 pt-2 text-sm text-zinc-700">
|
<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">票据 ID:</span>{detail.id}</div>
|
||||||
<div><span className="text-zinc-500">状态:</span>{detail.status}</div>
|
<div><span className="text-zinc-500">状态:</span>{detail.status}</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="text-zinc-500">发票号:</span>
|
<span className="text-zinc-500">发票号:</span>
|
||||||
@@ -120,7 +120,7 @@ export function InvoiceDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{formatInvoiceOcrErrorMessage(detail.ocrLastError)}</div> : null}
|
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">识别错误:</span>{formatInvoiceOcrErrorMessage(detail.ocrLastError)}</div> : null}
|
||||||
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
|
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="text-zinc-500">判重例外:</span>
|
<span className="text-zinc-500">判重例外:</span>
|
||||||
@@ -130,13 +130,13 @@ export function InvoiceDetailPanel({
|
|||||||
|
|
||||||
{detail.status === "pending_ocr" ? (
|
{detail.status === "pending_ocr" ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700" data-testid="invoice-ocr-status-banner">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700" data-testid="invoice-ocr-status-banner">
|
||||||
OCR 正在处理中,当前页面会自动轮询;完成后可直接继续绑定、验真或查看详情。
|
正在识别,完成后自动刷新。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{detail.status === "ocr_failed" ? (
|
{detail.status === "ocr_failed" ? (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="invoice-ocr-status-banner">
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="invoice-ocr-status-banner">
|
||||||
{formatInvoiceOcrErrorMessage(detail.ocrLastError)} 可在下方直接重试 OCR,或补齐关键字段后标记为可用。
|
{formatInvoiceOcrErrorMessage(detail.ocrLastError)} 可重新识别,或人工修正后标记可用。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ export function InvoiceDetailPanel({
|
|||||||
disabled={retryOcrMutation.isPending}
|
disabled={retryOcrMutation.isPending}
|
||||||
onClick={() => setConfirmAction({ type: "retryOcr", invoiceId: detail.id })}
|
onClick={() => setConfirmAction({ type: "retryOcr", invoiceId: detail.id })}
|
||||||
>
|
>
|
||||||
重试 OCR
|
重新识别
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -431,12 +431,12 @@ export function InvoiceDetailPanel({
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(confirmAction)}
|
open={Boolean(confirmAction)}
|
||||||
title={
|
title={
|
||||||
confirmAction?.type === "retryOcr" ? "确认重试 OCR" :
|
confirmAction?.type === "retryOcr" ? "确认重新识别" :
|
||||||
confirmAction?.type === "unbind" ? "确认解绑" :
|
confirmAction?.type === "unbind" ? "确认解绑" :
|
||||||
confirmAction?.type === "archive" ? "确认归档" : "确认操作"
|
confirmAction?.type === "archive" ? "确认归档" : "确认操作"
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
confirmAction?.type === "retryOcr" ? "将重新进入 pending_ocr 并消耗一次尝试次数。" :
|
confirmAction?.type === "retryOcr" ? "票据会重新进入识别中,并消耗一次尝试次数。" :
|
||||||
confirmAction?.type === "unbind" ? "仅草稿/驳回报销允许解绑。" :
|
confirmAction?.type === "unbind" ? "仅草稿/驳回报销允许解绑。" :
|
||||||
confirmAction?.type === "archive" ? "归档后将不再用于绑定与判重。" : ""
|
confirmAction?.type === "archive" ? "归档后将不再用于绑定与判重。" : ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export function InvoiceListCard({
|
|||||||
signedUrlMutation,
|
signedUrlMutation,
|
||||||
}: InvoiceListCardProps) {
|
}: InvoiceListCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="card-std p-6 space-y-4">
|
<div className="card-std space-y-4 p-4 md:p-5">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h3 className="card-title">筛选与列表</h3>
|
<h3 className="card-title">票据列表</h3>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<select
|
<select
|
||||||
className="select-sm"
|
className="select-sm"
|
||||||
@@ -100,10 +100,10 @@ export function InvoiceListCard({
|
|||||||
|
|
||||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
{item.status === "pending_ocr"
|
{item.status === "pending_ocr"
|
||||||
? `OCR 识别中,重试 ${item.ocrAttempts} 次`
|
? `识别中,已尝试 ${item.ocrAttempts} 次`
|
||||||
: item.status === "ocr_failed"
|
: item.status === "ocr_failed"
|
||||||
? `OCR 失败:${formatInvoiceOcrErrorMessage(item.ocrLastError)}`
|
? `识别失败:${formatInvoiceOcrErrorMessage(item.ocrLastError)}`
|
||||||
: "OCR 已完成或无需处理"}
|
: "已识别"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
@@ -113,7 +113,6 @@ export function InvoiceListCard({
|
|||||||
onClick={() => setDetailInvoiceId(item.id)}
|
onClick={() => setDetailInvoiceId(item.id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
|
||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</Button>
|
</Button>
|
||||||
@@ -145,7 +144,7 @@ export function InvoiceListCard({
|
|||||||
<th className="table-th w-[280px]">票据流水号</th>
|
<th className="table-th w-[280px]">票据流水号</th>
|
||||||
<th className="table-th w-[120px]">状态</th>
|
<th className="table-th w-[120px]">状态</th>
|
||||||
<th className="table-th w-[200px]">发票号码</th>
|
<th className="table-th w-[200px]">发票号码</th>
|
||||||
<th className="table-th">识别情况 (OCR)</th>
|
<th className="table-th">识别状态</th>
|
||||||
<th className="table-th w-[80px] text-right">操作</th>
|
<th className="table-th w-[80px] text-right">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -172,7 +171,7 @@ export function InvoiceListCard({
|
|||||||
识别失败 <span className="text-zinc-400 text-xs">({formatInvoiceOcrErrorMessage(item.ocrLastError)})</span>
|
识别失败 <span className="text-zinc-400 text-xs">({formatInvoiceOcrErrorMessage(item.ocrLastError)})</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-zinc-400 text-xs">解析成功</span>
|
<span className="text-zinc-400 text-xs">已识别</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="table-td text-right">
|
<td className="table-td text-right">
|
||||||
@@ -185,7 +184,7 @@ export function InvoiceListCard({
|
|||||||
查看详情
|
查看详情
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem data-testid="invoice-preview-button" onSelect={() => signedUrlMutation.mutate(item.id)}>
|
<DropdownMenuItem data-testid="invoice-preview-button" onSelect={() => signedUrlMutation.mutate(item.id)}>
|
||||||
下载 / 预览
|
预览
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
B1,R97 高频页面精修范围冻结,冻结本轮只处理报销列表 票据中心 移动审批 审批待办的页面密度和操作显著性,CSV 校验通过且不改后端 API 状态机 权限模型,命令 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_17-37-53-r97-high-frequency-ui-polish.csv 业务复核本轮仅做 UI 精修,manual,DONE,DONE,DONE,issues/2026-05-06_17-37-53-r97-high-frequency-ui-polish.csv,[],已按范围冻结 回滚路径 删除本合同
|
||||||
|
B2,报销列表密度与草稿入口优化,优化报销列表首屏指标 筛选 草稿继续编辑入口和手机卡片可读性,手机和桌面都能快速找到草稿继续编辑 已提交单据状态和主操作清楚 无横向溢出,Playwright 跑 r92 手机报销草稿用例并补视觉截图,playwright|typescript,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx|client/tests/e2e/r92-mobile-invoice-usability.spec.ts,["B1"],r92 通过 不改报销业务状态 回滚路径 恢复报销列表改动
|
||||||
|
B3,票据中心列表详情减噪,优化票据中心上传 列表 绑定 详情区的主次层级和移动端文案,上传区更短 列表和详情主操作更明显 OCR 技术词减少 手机票据中心无出屏,Playwright 跑 r92 手机票据中心用例并检查上传与详情可见性,playwright|typescript,DONE,DONE,DONE,client/src/pages/InvoicePoolPage.tsx|client/src/pages/invoice-pool,["B1"],r92 通过 不改 OCR provider 和状态机 回滚路径 恢复票据页面改动
|
||||||
|
B4,移动审批操作区精简,优化移动审批的筛选 搜索 批量处理和详情动作展示,默认只显示待处理与搜索 高级筛选和批量处理收起 详情动作清楚可点,Playwright 跑 r89 移动审批路径并截图,playwright|typescript,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx|client/tests/e2e/r89-mobile-login-navigation.spec.ts,["B1"],r89 通过 不改离线队列语义 回滚路径 恢复移动审批页面改动
|
||||||
|
B5,审批待办桌面列表降噪,优化审批待办桌面列表标题 文案和操作密度,桌面待办页标题短 页面不再说明书化 快捷审批入口保持可用,Playwright 或 typecheck/build 验证待办页可渲染,playwright|typescript,DONE,DONE,DONE,client/src/pages/WorkflowTasksPage.tsx,["B1"],visual-shell 通过 不改 workflow API 回滚路径 恢复审批待办页面文案
|
||||||
|
B6,R97 视觉回归与中文提交,补充本轮高频页面视觉回归报告并分批中文提交,typecheck build r89 r92 visual-shell 通过 视觉报告写入 artifacts/r97-ui git status 干净,命令 cd client && npm run typecheck && npm run build 加 r89 r92 visual-shell Playwright 回归,playwright|git,DONE,DONE,DONE,artifacts/r97-ui|client/tests/e2e,"[""B2"",""B3"",""B4"",""B5""]",typecheck build r89 r92 visual-shell 均通过 截图产物不纳入 git 回滚路径 删除报告并 revert 对应提交
|
||||||
|
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 433 列。
|
在新工单中引用
屏蔽一个用户