A2 收口统一审批中心入口与状态

这个提交包含在:
2026-05-09 21:39:47 +08:00
父节点 644852f333
当前提交 d871a40d95
共修改 6 个文件,包含 337 行新增和 39 行删除
+8
查看文件
@@ -17,6 +17,14 @@ export type WorkflowTaskSummary = {
entityId: string;
currentStatus: string;
} | null;
businessSummary: {
reference: string | null;
title: string | null;
applicantName: string | null;
amount: string | null;
duration: string | null;
businessStatus: string | null;
} | null;
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
};
+62 -11
查看文件
@@ -50,6 +50,9 @@ type VisibleTaskCard = {
entityType: string | null;
entityId: string | null;
reference: string | null;
applicantName: string | null;
metric: string | null;
businessStatus: string | null;
allowedActions: MobileAction[];
completedAt: string | null;
isCached: boolean;
@@ -99,6 +102,22 @@ function workflowStatusLabel(status: string) {
return status;
}
function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
draft: "草稿",
pending: "待处理",
reviewing: "审批中",
submitted: "审批中",
approved: "已通过",
rejected: "已驳回",
returned: "已退回",
cancelled: "已取消",
completed: "已完成",
paid: "已付款",
};
return status ? labels[status] ?? status : "-";
}
function isInternalId(value: string | null | undefined) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
}
@@ -120,6 +139,10 @@ function resolveErrorMessage(error: unknown) {
return "动作执行失败";
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
function classifyReplayError(error: unknown): ReplayClassification {
if (!isApiError(error)) {
return "failed";
@@ -181,17 +204,23 @@ function matchesEntityFilter(entityType: string | null, filter: EntityFilter) {
function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
const entityType = task.workflowInstance?.entityType ?? null;
const summary = task.businessSummary;
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
const metric = summary?.amount ?? summary?.duration ?? null;
return {
id: task.id,
title: task.stepName,
title: summary?.title ?? task.stepName,
subtitle:
task.status === "completed"
? `业务:${entityLabel(entityType)} · 完成于 ${formatDateTime(task.completedAt)}`
: `业务:${entityLabel(entityType)}`,
? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}`
: `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`,
status: task.status,
entityType,
entityId: task.workflowInstance?.entityId ?? null,
reference: publicReference(task.workflowInstance?.entityId),
reference,
applicantName: summary?.applicantName ?? null,
metric,
businessStatus: summary?.businessStatus ?? task.workflowInstance?.currentStatus ?? null,
allowedActions: task.allowedActions,
completedAt: task.completedAt,
isCached: false,
@@ -211,6 +240,9 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
card.id,
card.entityId,
card.reference,
card.applicantName,
card.metric,
card.businessStatus,
]
.map((item) => normalizeSearchValue(item))
.join(" ");
@@ -362,6 +394,9 @@ export function MobileApprovalsPage() {
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
entityId: item.taskSnapshot.entityId,
reference: publicReference(item.taskSnapshot.reference),
applicantName: null,
metric: null,
businessStatus: item.detail.workflowInstance?.currentStatus ?? null,
allowedActions: item.detail.allowedActions,
completedAt: item.detail.task.completedAt,
isCached: true,
@@ -386,10 +421,14 @@ export function MobileApprovalsPage() {
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
[filteredTaskCards, selectedTaskId],
);
const selectedTaskCard = useMemo(
() => filteredTaskCards.find((task) => task.id === selectedTaskId) ?? null,
[filteredTaskCards, selectedTaskId],
);
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
const selectedDisplayReference = publicReference(selectedReference?.reference);
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
@@ -528,6 +567,9 @@ export function MobileApprovalsPage() {
toast.success("操作成功");
},
onError: (actionError) => {
if (isAlreadyProcessedTaskError(actionError)) {
void invalidateWorkflowQueries(selectedTaskId);
}
const nextError = resolveErrorMessage(actionError);
setError(nextError);
setMessage("");
@@ -754,7 +796,7 @@ export function MobileApprovalsPage() {
{!isOnline ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并起草通过 / 驳回草稿。
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
@@ -857,7 +899,7 @@ export function MobileApprovalsPage() {
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")}
>
{canUseRemote ? "批量通过" : "加入通过草稿"}
{canUseRemote ? "批量通过" : "保存通过草稿"}
</Button>
<Button
type="button"
@@ -867,7 +909,7 @@ export function MobileApprovalsPage() {
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
onClick={() => handleBatchAction("reject")}
>
{canUseRemote ? "批量驳回" : "加入驳回草稿"}
{canUseRemote ? "批量驳回" : "保存驳回草稿"}
</Button>
</div>
</div>
@@ -940,6 +982,9 @@ export function MobileApprovalsPage() {
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
<div className="mt-1 text-xs text-zinc-500">
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
</div>
@@ -962,6 +1007,9 @@ export function MobileApprovalsPage() {
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
<div className="mt-1 text-xs text-zinc-500">
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
);
@@ -997,6 +1045,9 @@ export function MobileApprovalsPage() {
<div>步骤:{selectedTask.task.stepName}</div>
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
<div>业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
<div>申请人:{selectedTaskCard?.applicantName ?? "-"}</div>
<div>金额/天数:{selectedTaskCard?.metric ?? "-"}</div>
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
{selectedTask.reimbursement ? (
@@ -1046,7 +1097,7 @@ export function MobileApprovalsPage() {
/>
{!canUseRemote ? (
<p className="text-xs text-zinc-500">离线仅支持通过 / 驳回草稿。</p>
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null}
<div className="grid gap-2 sm:grid-cols-3">
@@ -1063,7 +1114,7 @@ export function MobileApprovalsPage() {
}}
type="button"
>
{canUseRemote ? "通过" : "离线加入通过草稿"}
{canUseRemote ? "通过" : "保存通过草稿"}
</Button>
<Button
className="min-h-12 w-full"
@@ -1079,7 +1130,7 @@ export function MobileApprovalsPage() {
type="button"
variant="secondary"
>
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
{canUseRemote ? "驳回" : "保存驳回草稿"}
</Button>
<Button
className="min-h-12 w-full"
+53 -7
查看文件
@@ -53,8 +53,8 @@ function workflowStatusLabel(status: string | undefined) {
}
const labels: Record<string, string> = {
pending: "待派发",
completed: "已完成",
pending: "待处理",
completed: "已处理",
rejected: "已驳回",
cancelled: "已取消",
skipped: "已跳过",
@@ -63,6 +63,46 @@ function workflowStatusLabel(status: string | undefined) {
return labels[status] ?? status;
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
if (!status) {
return "-";
}
const labels: Record<string, string> = {
active: "审批中",
approved: "已通过",
rejected: "已驳回",
terminated: "已终止",
cancelled: "已取消",
completed: "已完成",
};
return labels[status] ?? status;
}
function workflowActionLabel(action: string | undefined | null) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
export function WorkflowTaskDetailPage() {
const { taskId = "" } = useParams();
const navigate = useNavigate();
@@ -125,6 +165,12 @@ export function WorkflowTaskDetailPage() {
toast.success(messages[action] ?? "操作成功");
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
}
let msg: string;
if (error instanceof Error && !isApiError(error)) {
msg = error.message;
@@ -178,8 +224,8 @@ export function WorkflowTaskDetailPage() {
}
const detail = detailQuery.data;
const isLeaveTask = detail.workflowInstance?.entityType === "leave";
const canTransfer = allowedActions.has("transfer");
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
return (
<PageScaffold>
@@ -220,7 +266,7 @@ export function WorkflowTaskDetailPage() {
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">可执行动作</div>
<div className="mt-2 font-medium text-zinc-900">{detail.allowedActions.length ? detail.allowedActions.join(" / ") : "-"}</div>
<div className="mt-2 font-medium text-zinc-900">{allowedActionLabels || "-"}</div>
</div>
</div>
@@ -374,7 +420,7 @@ export function WorkflowTaskDetailPage() {
</Button>
</div>
{canTransfer && !isLeaveTask ? (
{canTransfer ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">转审</p>
<select
@@ -421,7 +467,7 @@ export function WorkflowTaskDetailPage() {
detail.history.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm" key={item.id}>
<div className="font-medium text-zinc-900">
{item.action} · {item.actorName || item.actorId}
{workflowActionLabel(item.action)} · {item.actorName || item.actorId}
</div>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{item.createdAt || "-"}</div>
@@ -437,7 +483,7 @@ export function WorkflowTaskDetailPage() {
<div className="space-y-6">
<DetailSideMeta title="任务摘要">
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{detail.workflowInstance?.currentStatus ?? "-"}</div>
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
+71 -20
查看文件
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { approveWorkflowTask, fetchWorkflowTasks } from "../api/workflow";
import { approveWorkflowTask, fetchWorkflowTasks, type WorkflowTaskSummary } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -31,6 +31,53 @@ function entityLabel(entityType: string | undefined) {
return entityType || "-";
}
function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
draft: "草稿",
pending: "待处理",
reviewing: "审批中",
submitted: "审批中",
approved: "已通过",
rejected: "已驳回",
returned: "已退回",
cancelled: "已取消",
completed: "已完成",
paid: "已付款",
};
return status ? labels[status] ?? status : "-";
}
function isInternalId(value: string | null | undefined) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
}
function publicReference(value: string | null | undefined) {
if (!value || isInternalId(value)) {
return null;
}
return value;
}
function displayReference(task: WorkflowTaskSummary) {
return publicReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId) ?? "未生成业务编号";
}
function displayTitle(task: WorkflowTaskSummary) {
return task.businessSummary?.title ?? task.stepName;
}
function displayApplicant(task: WorkflowTaskSummary) {
return task.businessSummary?.applicantName ?? "-";
}
function displayMetric(task: WorkflowTaskSummary) {
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
@@ -61,6 +108,9 @@ export function WorkflowTasksPage() {
toast.success("审批已通过");
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] });
}
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
@@ -86,7 +136,7 @@ export function WorkflowTasksPage() {
<TabsList className="flex w-full flex-wrap justify-start gap-1">
{[
{ value: "pending", label: "待处理" },
{ value: "completed", label: "已通过" },
{ value: "completed", label: "已处理" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
{ value: "skipped", label: "已跳过" },
@@ -102,7 +152,7 @@ export function WorkflowTasksPage() {
</TabsList>
</Tabs>
{query.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{query.isLoading ? <TableSkeleton columns={7} 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">
@@ -124,15 +174,17 @@ export function WorkflowTasksPage() {
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" key={task.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{task.stepName}</div>
<div className="truncate text-sm font-semibold text-zinc-900">{displayTitle(task)}</div>
<div className="mt-1 text-xs text-zinc-500">
{entityLabel(task.workflowInstance?.entityType)} · {new Date(task.createdAt).toLocaleString()}
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div>
</div>
<StatusBadge className="shrink-0" status={task.status} />
</div>
<div className="mt-3 truncate rounded-lg bg-zinc-50 px-3 py-2 font-mono text-xs text-zinc-500">
{task.workflowInstance?.entityId ?? task.id}
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
<div>申请人:{displayApplicant(task)}</div>
<div>金额/天数:{displayMetric(task)}</div>
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
@@ -172,9 +224,11 @@ 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>任务状态</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-24">操作</TableHead>
</tr>
@@ -184,17 +238,14 @@ export function WorkflowTasksPage() {
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 className="font-medium text-zinc-900">{displayTitle(task)}</div>
<div className="text-xs text-zinc-500">
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div>
</TableCell>
<TableCell>{displayApplicant(task)}</TableCell>
<TableCell>{displayMetric(task)}</TableCell>
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
<TableCell><StatusBadge status={task.status} /></TableCell>
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
<TableCell>
@@ -224,7 +275,7 @@ export function WorkflowTasksPage() {
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={5}>
<TableCell className="py-3 text-zinc-500" colSpan={7}>
暂无数据
</TableCell>
</TableRow>