A2 收口统一审批中心入口与状态
这个提交包含在:
@@ -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">;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
A1,MVP主线代码差距盘点,"对照当前 PRD 和 7 份模块规约,盘点 workflow、reimbursements、invoicePool、wecomAuth、leave、purchaseRequests、contractsRegistry、projects 等现有实现、页面、回归脚本和缺口;本 Issue 不改业务代码。","1) 形成可复用实现、待修缺口和不重复造轮子的清单;2) 明确 A2-A8 的实际改动边界;3) 确认首轮默认不新增公共 API、不新增数据库表;4) 若发现必须新增接口、字段或迁移,拆独立后续 Issue。","流程级验证:按 PRD 的审批、报销、企业微信入口、请假、采购、合同、项目七条主线逐项映射到现有 server/client/tests 文件;命令:rg 检索关键路由、页面和测试;命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-09_20-57-19-mvp-mainline-dev-kickoff.csv。","ripgrep|manual|python3",DONE,DONE,DONE,"server/src/routes/*|client/src/pages/*|client/src/api/*|client/tests/e2e/*|docs/*.md","[]","A1 已完成只读差距盘点,不改业务代码;盘点文档:docs/MVP主线代码差距盘点.md;如后续发现需迁移或新增 API,必须另拆 Issue 并写回滚策略。"
|
||||
A2,统一审批中心入口与状态收口,"收口 PC /workflow/tasks、移动 /mobile/approvals、业务详情页审批历史和通知 deeplink 的一致性,确保正式审批动作回到 OA 完成。","1) 待办、已办、业务详情页状态一致;2) 审批动作通过、驳回、退回、撤回、转审仍写入 OA 工作流历史;3) 企业微信消息只 deeplink 回 OA,不出现企业微信原生审批入口;4) 并发或已处理任务有明确提示。","流程级验证:提交一条报销或请假审批 -> PC 待办处理 -> 移动待办同步消失或进入已办 -> 业务详情审批历史一致;命令:cd server && ./scripts/regression-workflow-actions.sh;命令:cd client && npm run test:e2e -- workflow-quick-approve.spec.ts mobile-approvals-offline.spec.ts。","bash|npm|playwright|manual",TODO,TODO,TODO,"server/src/routes/workflow.js|server/src/utils/workflow.js|client/src/pages/WorkflowTasksPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx","[""A1""]","涉及 workflow 状态机和权限;实现方案必须说明回滚路径,默认以 revert commit 回滚,不做破坏性迁移。"
|
||||
A2,统一审批中心入口与状态收口,"收口 PC /workflow/tasks、移动 /mobile/approvals、业务详情页审批历史和通知 deeplink 的一致性,确保正式审批动作回到 OA 完成。","1) 待办、已办、业务详情页状态一致;2) 审批动作通过、驳回、退回、撤回、转审仍写入 OA 工作流历史;3) 企业微信消息只 deeplink 回 OA,不出现企业微信原生审批入口;4) 并发或已处理任务有明确提示。","流程级验证:提交一条报销或请假审批 -> PC 待办处理 -> 移动待办同步消失或进入已办 -> 业务详情审批历史一致;命令:cd server && ./scripts/regression-workflow-actions.sh;命令:cd client && npm run test:e2e -- workflow-quick-approve.spec.ts mobile-approvals-offline.spec.ts。","bash|npm|playwright|manual",DONE,TODO,TODO,"server/src/routes/workflow.js|server/src/utils/workflow.js|client/src/pages/WorkflowTasksPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx","[""A1""]","A2 开发完成;prisma validate、workflow node --check、client typecheck/build 通过;回归与 E2E 在登录阶段被本地 DB 漂移阻断:employees.avatar_path 缺失,对应迁移 20260329194000_r43_employee_avatar_contracts,本轮不执行迁移。"
|
||||
A3,报销发票OCR主链路收口,"收口上传、OCR 状态、查重、绑定明细、提交审批、付款标记和导出中的体验或状态缺口,优先复用现有 reimbursement 与 invoicePool 实现。","1) 员工能完成报销主流程:上传发票/附件 -> OCR/查重 -> 绑定明细 -> 提交审批;2) 财务能处理重复、付款标记和导出;3) 普通用户页面不出现内部状态码、发票池、绑定整单等不应见文案;4) OCR 失败、重复、附件缺失有业务提示。","流程级验证:创建报销草稿 -> 上传票据 -> OCR 进入终态 -> 绑定明细 -> 提交审批 -> 审批通过 -> 财务付款/导出;命令:cd server && ./scripts/regression-dedup.sh && ./scripts/regression-ocr.sh;命令:cd client && npm run test:e2e -- r102-reimbursement-main-flow.spec.ts invoice-ocr-flow.spec.ts。","bash|npm|playwright|manual",TODO,TODO,TODO,"server/src/routes/reimbursements.js|server/src/routes/invoicePool.js|server/src/routes/invoicePool/*|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/reimbursement-detail/*|client/src/pages/invoice-pool/*","[""A1"",""A2""]","涉及报销和发票状态;默认不改 OCR provider 和数据库结构,若需 schema/API 变更另拆 Issue。"
|
||||
A4,企业微信入口绑定通知回OA,"收口企业微信 config、登录/绑定、移动落点、通知 deeplink 和脱敏消息,企业微信只作为入口、身份、通知和打卡原始采集边界。","1) 企业微信手机入口默认进入 /mobile;2) 已绑定用户直达,未绑定可回退账号密码;3) 停用员工不得从企业微信进入业务系统;4) 通知内容脱敏且 deeplink 回 OA;5) 通知失败不阻断主业务。","流程级验证:模拟企业微信 UA 访问入口 -> 已绑定进入移动工作台 -> 未绑定回退账号密码 -> 通知 deeplink 打开 OA 审批页;命令:cd client && npm run test:e2e -- wecom-entry.spec.ts wecom-first-bind.spec.ts r124-mobile-entry-matrix.spec.ts;后端补 node --check src/routes/wecomAuth.js。","npm|playwright|node|manual",TODO,TODO,TODO,"server/src/routes/wecomAuth.js|server/src/services/notifications/*|client/src/lib/wecomEntry.ts|client/src/lib/runtimeContext.ts|client/src/lib/notificationLinks.ts","[""A1"",""A2""]","不接企业微信原生审批、不接飞书;涉及真实企业微信凭据时 live smoke 缺配置必须标 SKIP。"
|
||||
A5,请假补卡外出加班审批与考勤口径,"优先收口现有请假审批、团队状态、日历展示和考勤解释边界;首轮不做企业微信打卡 API 同步,不重造定位、人脸、Wi-Fi 或排班。","1) 请假审批状态正确;2) 已批准请假影响团队状态或日历展示;3) 补卡、外出、加班若现有实现不足,先记录缺口或做最小入口收口;4) 文案不引导员工去企业微信原生审批。","流程级验证:员工提交请假 -> 主管/HR 在 OA 审批 -> 团队状态或日历体现结果 -> 驳回/撤回后状态正确;命令:cd client && npm run test:e2e -- leave-flow.spec.ts leave-team-calendar.spec.ts;命令:cd server && node --check src/routes/leave.js。","npm|playwright|node|manual",TODO,TODO,TODO,"server/src/routes/leave.js|server/src/services/leave.js|client/src/pages/LeaveRequestPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/TeamPresencePage.tsx","[""A1"",""A2""]","首轮不接企业微信打卡同步;若补卡/外出/加班缺运行时能力,A1 后先拆最小 Issue,不扩大到完整考勤系统。"
|
||||
|
||||
|
@@ -282,6 +282,7 @@ function isoOrNull(value) {
|
||||
}
|
||||
|
||||
function mapTaskSummary(task, allowedActions = []) {
|
||||
const businessSummary = buildTaskBusinessSummary(task);
|
||||
const workflowInstance = task.workflow_instance
|
||||
? {
|
||||
entity_type: task.workflow_instance.entity_type,
|
||||
@@ -318,9 +319,89 @@ function mapTaskSummary(task, allowedActions = []) {
|
||||
currentStatus: workflowInstance.current_status,
|
||||
}
|
||||
: null,
|
||||
businessSummary,
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackBusinessTitle(entityType) {
|
||||
if (entityType === "reimbursement") return "报销单";
|
||||
if (entityType === "leave") return "请假申请";
|
||||
if (entityType === "purchase_request") return "采购申请";
|
||||
if (entityType === "travel_request") return "差旅申请";
|
||||
return "业务单据";
|
||||
}
|
||||
|
||||
function buildTaskBusinessSummary(task) {
|
||||
const workflowInstance = task.workflow_instance;
|
||||
if (!workflowInstance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
reference: workflowInstance.entity_id,
|
||||
title: fallbackBusinessTitle(workflowInstance.entity_type),
|
||||
applicantName: null,
|
||||
amount: null,
|
||||
duration: null,
|
||||
businessStatus: workflowInstance.current_status ?? null,
|
||||
};
|
||||
|
||||
if (workflowInstance.entity_type === "reimbursement" && workflowInstance.reimbursement_form) {
|
||||
const form = workflowInstance.reimbursement_form;
|
||||
return {
|
||||
...fallback,
|
||||
reference: form.form_number ?? workflowInstance.entity_id,
|
||||
title: form.description ? `报销:${form.description}` : "报销单",
|
||||
applicantName: form.submitter?.name ?? form.owner?.name ?? null,
|
||||
amount: toMoneyString(form.total_amount),
|
||||
businessStatus: form.status ?? fallback.businessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "leave" && workflowInstance.leave_request) {
|
||||
const leaveRequest = workflowInstance.leave_request;
|
||||
return {
|
||||
...fallback,
|
||||
title: leaveRequest.leave_type?.name
|
||||
? `请假:${leaveRequest.leave_type.name}`
|
||||
: "请假申请",
|
||||
applicantName: leaveRequest.owner?.name ?? null,
|
||||
duration:
|
||||
leaveRequest.requested_days == null
|
||||
? null
|
||||
: `${Number(leaveRequest.requested_days)}天`,
|
||||
businessStatus: leaveRequest.status ?? fallback.businessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "purchase_request" && workflowInstance.purchase_request) {
|
||||
const purchaseRequest = workflowInstance.purchase_request;
|
||||
const amount = toMoneyString(purchaseRequest.total_amount);
|
||||
return {
|
||||
...fallback,
|
||||
reference: purchaseRequest.request_no ?? workflowInstance.entity_id,
|
||||
title: purchaseRequest.title || "采购申请",
|
||||
applicantName: purchaseRequest.applicant?.name ?? null,
|
||||
amount: amount ? `${amount} ${purchaseRequest.currency_code}` : null,
|
||||
businessStatus: purchaseRequest.status ?? fallback.businessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "travel_request" && workflowInstance.travel_request) {
|
||||
const travelRequest = workflowInstance.travel_request;
|
||||
return {
|
||||
...fallback,
|
||||
reference: travelRequest.request_no ?? workflowInstance.entity_id,
|
||||
title: travelRequest.purpose ? `差旅:${travelRequest.purpose}` : "差旅申请",
|
||||
applicantName: travelRequest.applicant?.name ?? null,
|
||||
amount: toMoneyString(travelRequest.estimated_amount),
|
||||
businessStatus: travelRequest.status ?? fallback.businessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function buildTaskVisibilityFilters(tx, request) {
|
||||
const roleFilters = request.employee.roleCodes.length
|
||||
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
||||
@@ -1380,6 +1461,67 @@ async function workflowRoutes(fastify) {
|
||||
entity_type: true,
|
||||
entity_id: true,
|
||||
current_status: true,
|
||||
reimbursement_form: {
|
||||
select: {
|
||||
form_number: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
description: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
submitter: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
leave_request: {
|
||||
select: {
|
||||
status: true,
|
||||
requested_days: true,
|
||||
leave_type: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
purchase_request: {
|
||||
select: {
|
||||
request_no: true,
|
||||
status: true,
|
||||
title: true,
|
||||
total_amount: true,
|
||||
currency_code: true,
|
||||
applicant: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
travel_request: {
|
||||
select: {
|
||||
request_no: true,
|
||||
status: true,
|
||||
purpose: true,
|
||||
estimated_amount: true,
|
||||
applicant: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
在新工单中引用
屏蔽一个用户