调整待处理页任务优先体验(portal-task-first-redesign)
这个提交包含在:
@@ -26,10 +26,12 @@ import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
|
||||
const WORKFLOW_STATUS_OPTIONS = [
|
||||
{ value: "pending", label: "待处理" },
|
||||
{ value: "completed", label: "已处理" },
|
||||
@@ -37,6 +39,7 @@ const WORKFLOW_STATUS_OPTIONS = [
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
{ value: "skipped", label: "已跳过" },
|
||||
] as const;
|
||||
const WORKFLOW_HISTORY_STATUS_OPTIONS = WORKFLOW_STATUS_OPTIONS.filter((item) => item.value !== "pending");
|
||||
const WORKFLOW_ENTITY_OPTIONS = [
|
||||
{ value: "all", label: "全部业务" },
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
@@ -115,6 +118,18 @@ function displayTitle(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.title ?? task.stepName;
|
||||
}
|
||||
|
||||
function displayTaskTitle(task: WorkflowTaskSummary) {
|
||||
const typeLabel = entityLabel(task.workflowInstance?.entityType);
|
||||
const title = displayTitle(task);
|
||||
if (!title || title === "-") {
|
||||
return typeLabel;
|
||||
}
|
||||
if (title === typeLabel || title.startsWith(`${typeLabel}:`) || title.startsWith(`${typeLabel}:`)) {
|
||||
return title;
|
||||
}
|
||||
return `${typeLabel}:${title}`;
|
||||
}
|
||||
|
||||
function displayApplicant(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.applicantName ?? "-";
|
||||
}
|
||||
@@ -123,11 +138,106 @@ function displayMetric(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
|
||||
}
|
||||
|
||||
function formatSummaryTime(value: string | null | undefined) {
|
||||
function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
return null;
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function formatWaitingDuration(value: string | null | undefined) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return "等待时间未知";
|
||||
}
|
||||
const diff = Math.max(0, Date.now() - timestamp);
|
||||
const minutes = Math.floor(diff / (60 * 1000));
|
||||
if (minutes < 1) {
|
||||
return "刚刚接收";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `已等待 ${minutes} 分钟`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `已等待 ${hours} 小时`;
|
||||
}
|
||||
return `已等待 ${Math.floor(hours / 24)} 天`;
|
||||
}
|
||||
|
||||
function formatReceivedTime(value: string | null | undefined) {
|
||||
const text = formatDisplayDateTime(value);
|
||||
return text === "-" ? "接收时间未知" : `接收于 ${text}`;
|
||||
}
|
||||
|
||||
function formatTaskTimeline(task: WorkflowTaskSummary) {
|
||||
if (task.status !== "pending" && task.completedAt) {
|
||||
const text = formatDisplayDateTime(task.completedAt);
|
||||
return text === "-" ? "完成时间未知" : `完成于 ${text}`;
|
||||
}
|
||||
return formatWaitingDuration(task.createdAt);
|
||||
}
|
||||
|
||||
function isWithinDefaultDueWindow(value: string | null | undefined) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - timestamp < WORKFLOW_DEFAULT_DUE_MS;
|
||||
}
|
||||
|
||||
function isTaskDefaultOverdue(task: WorkflowTaskSummary) {
|
||||
return task.status === "pending" && !isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function parseDurationDays(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = /([0-9]+(?:\.[0-9]+)?)/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const days = Number(match[1]);
|
||||
return Number.isFinite(days) ? days : null;
|
||||
}
|
||||
|
||||
function workflowTaskStatusLabel(status: string) {
|
||||
const match = WORKFLOW_STATUS_OPTIONS.find((item) => item.value === status);
|
||||
return match?.label ?? status;
|
||||
}
|
||||
|
||||
function taskReason(task: WorkflowTaskSummary) {
|
||||
return task.stepName ? `${task.stepName}找你处理` : "当前节点找你处理";
|
||||
}
|
||||
|
||||
function taskLeadMeta(task: WorkflowTaskSummary) {
|
||||
const metric = displayMetric(task);
|
||||
return [displayApplicant(task), metric !== "-" ? metric : null, taskReason(task)].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function taskSecondaryMeta(task: WorkflowTaskSummary) {
|
||||
return [formatReceivedTime(task.createdAt), displayReference(task)].join(" · ");
|
||||
}
|
||||
|
||||
function isLowRiskQuickApproveTask(task: WorkflowTaskSummary) {
|
||||
if (task.status !== "pending" || !(task.allowedActions ?? []).includes("approve")) {
|
||||
return false;
|
||||
}
|
||||
if (!displayTitle(task) || displayApplicant(task) === "-") {
|
||||
return false;
|
||||
}
|
||||
const entityType = task.workflowInstance?.entityType;
|
||||
if (entityType === "leave") {
|
||||
const days = parseDurationDays(task.businessSummary?.duration);
|
||||
if (days === null || days > 1) {
|
||||
return false;
|
||||
}
|
||||
} else if (entityType !== "attendance_correction") {
|
||||
return false;
|
||||
}
|
||||
return isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function isAlreadyProcessedTaskError(error: unknown) {
|
||||
@@ -202,6 +312,9 @@ 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 confirmApproveTask = confirmApproveTaskId
|
||||
? taskItems.find((task) => task.id === confirmApproveTaskId) ?? null
|
||||
: null;
|
||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||
@@ -211,21 +324,34 @@ export function WorkflowTasksPage() {
|
||||
.filter((item) => item.pendingTotal > 0)
|
||||
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
||||
.join(" / ") || "-";
|
||||
const earliestPendingWaiting = summaryQuery.data?.earliestPendingAt
|
||||
? formatWaitingDuration(summaryQuery.data.earliestPendingAt)
|
||||
: "暂无待处理";
|
||||
|
||||
function returnToPendingTasks() {
|
||||
setShowAdvanced(false);
|
||||
updateSearchParams({ status: "pending", entityType: "all", page: 1 });
|
||||
}
|
||||
|
||||
function openProcessedRecords() {
|
||||
setShowAdvanced(true);
|
||||
updateSearchParams({ status: "completed", entityType: "all", page: 1 });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
title="审批中心"
|
||||
title="待我处理"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="workflow-tasks-advanced-toggle"
|
||||
onClick={() => setShowAdvanced((value) => !value)}
|
||||
onClick={advancedVisible ? returnToPendingTasks : openProcessedRecords}
|
||||
>
|
||||
{advancedVisible ? "收起高级" : "高级 / 历史"}
|
||||
{advancedVisible ? "回到待处理" : "已处理记录"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -236,7 +362,7 @@ export function WorkflowTasksPage() {
|
||||
<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)}
|
||||
最早:{earliestPendingWaiting}
|
||||
</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">
|
||||
@@ -245,18 +371,6 @@ export function WorkflowTasksPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ops-command-bar__actions">
|
||||
{!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>
|
||||
@@ -268,7 +382,7 @@ export function WorkflowTasksPage() {
|
||||
<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={earliestPendingWaiting} />
|
||||
<MetricCard
|
||||
title="业务分布"
|
||||
value={<span className="block truncate text-base">{pendingEntitySummary}</span>}
|
||||
@@ -280,11 +394,11 @@ export function WorkflowTasksPage() {
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="workflow-tasks-status-tabs"
|
||||
value={status}
|
||||
value={status === "pending" ? "completed" : 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) => (
|
||||
{WORKFLOW_HISTORY_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||
key={item.value}
|
||||
@@ -321,7 +435,7 @@ export function WorkflowTasksPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
{query.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
|
||||
|
||||
{query.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
@@ -343,12 +457,12 @@ export function WorkflowTasksPage() {
|
||||
<article className="mobile-list-card" key={task.id}>
|
||||
<div className="mobile-list-card__header">
|
||||
<div className="min-w-0">
|
||||
<div className="mobile-list-card__title">{displayTitle(task)}</div>
|
||||
<div className="mobile-list-card__title">{displayTaskTitle(task)}</div>
|
||||
<div className="mobile-list-card__subtitle">
|
||||
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
|
||||
{taskLeadMeta(task)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge className="mobile-status-slot" status={task.status} />
|
||||
<StatusBadge className="mobile-status-slot" status={task.status} label={workflowTaskStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mobile-field-list mt-3 rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="mobile-field-row">
|
||||
@@ -360,19 +474,30 @@ export function WorkflowTasksPage() {
|
||||
<span className="mobile-field-value">{displayMetric(task)}</span>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<span className="mobile-field-label">当前节点</span>
|
||||
<span className="mobile-field-value">{task.stepName}</span>
|
||||
<span className="mobile-field-label">时间</span>
|
||||
<span className="mobile-field-value">{formatTaskTimeline(task)}</span>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<span className="mobile-field-label">业务状态</span>
|
||||
<span className="mobile-field-value">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
|
||||
<span className="mobile-field-label">编号</span>
|
||||
<span className="mobile-field-value">{displayReference(task)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
|
||||
<Button
|
||||
asChild
|
||||
className={`min-h-11 ${isLowRiskQuickApproveTask(task) ? "" : "col-span-2"}`}
|
||||
size="sm"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
@@ -381,17 +506,6 @@ export function WorkflowTasksPage() {
|
||||
{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>
|
||||
))
|
||||
@@ -406,14 +520,10 @@ export function WorkflowTasksPage() {
|
||||
<Table data-testid="workflow-tasks-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>业务类型 / 编号</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>申请人</TableHead>
|
||||
<TableHead>金额/天数</TableHead>
|
||||
<TableHead>当前节点</TableHead>
|
||||
<TableHead>业务状态</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
<TableHead className="min-w-[360px]">待处理事项</TableHead>
|
||||
<TableHead className="min-w-[180px]">状态 / 时间</TableHead>
|
||||
<TableHead className="min-w-[160px]">业务编号</TableHead>
|
||||
<TableHead className="w-36 text-right">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -421,23 +531,46 @@ export function WorkflowTasksPage() {
|
||||
taskItems.map((task) => (
|
||||
<TableRow data-testid="workflow-task-row" key={task.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{entityLabel(task.workflowInstance?.entityType)}</div>
|
||||
<div className="text-xs text-zinc-500">{displayReference(task)}</div>
|
||||
<div className="max-w-[560px] min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{displayTaskTitle(task)}</div>
|
||||
<StatusBadge status={task.status} label={workflowTaskStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">{taskLeadMeta(task)}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">{taskSecondaryMeta(task)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{displayTitle(task)}</div>
|
||||
<div className="mt-1"><StatusBadge status={task.status} /></div>
|
||||
<div className="text-sm font-medium text-zinc-900">{formatTaskTimeline(task)}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{isTaskDefaultOverdue(task) ? (
|
||||
<span className="badge-warning">可能超时</span>
|
||||
) : null}
|
||||
{businessStatusLabel(task.businessSummary?.businessStatus) !== "-" &&
|
||||
businessStatusLabel(task.businessSummary?.businessStatus) !== workflowTaskStatusLabel(task.status) ? (
|
||||
<span className="badge-neutral">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{displayApplicant(task)}</TableCell>
|
||||
<TableCell>{displayMetric(task)}</TableCell>
|
||||
<TableCell>{task.stepName}</TableCell>
|
||||
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
|
||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
|
||||
<div className="text-sm font-medium text-zinc-900">{displayReference(task)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{entityLabel(task.workflowInstance?.entityType)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-stretch gap-2 sm:items-end">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
@@ -446,21 +579,13 @@ export function WorkflowTasksPage() {
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={status === "pending" && (task.allowedActions ?? []).includes("approve") ? "secondary" : "default"}
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
|
||||
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={4}>
|
||||
{isDefaultTaskView ? "当前没有待处理任务" : "当前筛选下没有审批任务"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -486,7 +611,16 @@ export function WorkflowTasksPage() {
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
description={confirmCopy ? (
|
||||
<>
|
||||
<span>{confirmCopy.description}</span>
|
||||
{confirmApproveTask ? (
|
||||
<span className="mt-2 block rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600">
|
||||
当前事项:{displayTaskTitle(confirmApproveTask)} · {taskLeadMeta(confirmApproveTask)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
|
||||
在新工单中引用
屏蔽一个用户