687 行
29 KiB
TypeScript
687 行
29 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import {
|
||
fetchErpMasterDataStatus,
|
||
fetchIntegrationBindings,
|
||
fetchIntegrationOutbox,
|
||
fetchIntegrationOutboxDetail,
|
||
fetchIntegrationOutboxStats,
|
||
retryIntegrationOutbox,
|
||
syncErpMasterData,
|
||
type ErpMasterDataResourceType,
|
||
type ErpMasterDataSyncItem,
|
||
type IntegrationBindingItem,
|
||
type IntegrationOutboxItem,
|
||
type IntegrationOutboxStatus,
|
||
} from "../api/integrations";
|
||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||
import { isApiError } from "../api/types";
|
||
import { PageHeader } from "../components/common/PageHeader";
|
||
import { Pagination } from "../components/common/Pagination";
|
||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||
import { Button } from "../components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../components/ui/dialog";
|
||
import { Input } from "../components/ui/input";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||
|
||
const RESOURCE_LABELS: Record<ErpMasterDataResourceType, string> = {
|
||
legal_entity: "法体",
|
||
cost_center: "成本中心",
|
||
supplier: "供应商",
|
||
};
|
||
|
||
const OUTBOX_STATUS_OPTIONS: Array<{ value: IntegrationOutboxStatus | ""; label: string }> = [
|
||
{ value: "", label: "全部状态" },
|
||
{ value: "pending", label: "待处理" },
|
||
{ value: "processing", label: "处理中" },
|
||
{ value: "succeeded", label: "已成功" },
|
||
{ value: "failed", label: "失败" },
|
||
{ value: "dead_letter", label: "死信" },
|
||
];
|
||
|
||
const BINDING_ENTITY_OPTIONS: Array<{ value: string; label: string }> = [
|
||
{ value: "", label: "全部类型" },
|
||
{ value: "reimbursement_journal", label: "报销凭证" },
|
||
{ value: "purchase_request_commitment_journal", label: "采购 Commitment" },
|
||
{ value: "purchase_order_accrual_journal", label: "采购 Accrual" },
|
||
{ value: "purchase_order_po", label: "采购 PO 映射" },
|
||
{ value: "purchase_order_contract", label: "采购合同映射" },
|
||
{ value: "asset_erp_card", label: "资产台账摘要" },
|
||
{ value: "travel_payable_summary", label: "差旅应付摘要" },
|
||
{ value: "travel_settlement_batch", label: "差旅结算批次" },
|
||
];
|
||
|
||
type ErpTab = "master-data" | "outbox" | "bindings";
|
||
|
||
function resolveError(error: unknown) {
|
||
if (isApiError(error)) {
|
||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
}
|
||
if (error instanceof Error) {
|
||
return error.message;
|
||
}
|
||
return "请求失败,请稍后重试。";
|
||
}
|
||
|
||
function formatDateTime(value: string | null) {
|
||
if (!value) return "-";
|
||
return value.slice(0, 19).replace("T", " ");
|
||
}
|
||
|
||
function formatRelativeTime(value: string | null) {
|
||
if (!value) return "-";
|
||
const target = new Date(value).getTime();
|
||
if (!Number.isFinite(target)) return "-";
|
||
const diffSeconds = Math.round((target - Date.now()) / 1000);
|
||
const abs = Math.abs(diffSeconds);
|
||
if (abs < 60) {
|
||
return diffSeconds >= 0 ? `${abs} 秒后` : `${abs} 秒前`;
|
||
}
|
||
const diffMinutes = Math.round(diffSeconds / 60);
|
||
if (Math.abs(diffMinutes) < 60) {
|
||
return diffMinutes >= 0 ? `${diffMinutes} 分钟后` : `${Math.abs(diffMinutes)} 分钟前`;
|
||
}
|
||
const diffHours = Math.round(diffMinutes / 60);
|
||
if (Math.abs(diffHours) < 24) {
|
||
return diffHours >= 0 ? `${diffHours} 小时后` : `${Math.abs(diffHours)} 小时前`;
|
||
}
|
||
const diffDays = Math.round(diffHours / 24);
|
||
return diffDays >= 0 ? `${diffDays} 天后` : `${Math.abs(diffDays)} 天前`;
|
||
}
|
||
|
||
function formatGateReason(reason: string) {
|
||
if (reason === "env_hard_off") return "Env 总闸关闭";
|
||
if (reason === "flag_force_off") return "功能开关强制关闭";
|
||
if (reason === "department_deferred") return "部门灰度在管理端按全局集成动作放行";
|
||
if (reason === "env_inherit_global_on") return "Env 继承开启";
|
||
if (reason === "flag_force_on_global") return "功能开关强制开启";
|
||
return reason;
|
||
}
|
||
|
||
function outboxStatusLabel(status: string) {
|
||
switch (status) {
|
||
case "pending":
|
||
return "待处理";
|
||
case "processing":
|
||
return "处理中";
|
||
case "succeeded":
|
||
return "已成功";
|
||
case "failed":
|
||
return "失败";
|
||
case "dead_letter":
|
||
return "死信";
|
||
default:
|
||
return status;
|
||
}
|
||
}
|
||
|
||
function outboxStatusClassName(status: string) {
|
||
if (status === "failed" || status === "dead_letter") {
|
||
return "border-red-200 bg-red-50 text-red-700";
|
||
}
|
||
if (status === "succeeded") {
|
||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||
}
|
||
if (status === "processing") {
|
||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||
}
|
||
return "border-zinc-200 bg-zinc-50 text-zinc-700";
|
||
}
|
||
|
||
function renderOutboxSummary(item: IntegrationOutboxItem) {
|
||
const summary = item.payloadSummary;
|
||
return summary.identityCode ?? summary.entityId ?? "-";
|
||
}
|
||
|
||
function bindingEntityLabel(value: string) {
|
||
return BINDING_ENTITY_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||
}
|
||
|
||
function BindingLocalIdCell({ item }: { item: IntegrationBindingItem }) {
|
||
if (!item.routePath) {
|
||
return <span className="break-all text-zinc-600">{item.localId}</span>;
|
||
}
|
||
|
||
return (
|
||
<Link className="break-all text-primary hover:underline" to={item.routePath}>
|
||
{item.localId}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
export function ErpIntegrationPage() {
|
||
const queryClient = useQueryClient();
|
||
const [tab, setTab] = useState<ErpTab>("master-data");
|
||
const [error, setError] = useState("");
|
||
|
||
const [outboxPage, setOutboxPage] = useState(1);
|
||
const [outboxStatus, setOutboxStatus] = useState<IntegrationOutboxStatus | "">("");
|
||
const [outboxEventType, setOutboxEventType] = useState("");
|
||
const [selectedOutboxId, setSelectedOutboxId] = useState("");
|
||
|
||
const [bindingsPage, setBindingsPage] = useState(1);
|
||
const [bindingProvider, setBindingProvider] = useState("");
|
||
const [bindingEntityType, setBindingEntityType] = useState("");
|
||
|
||
const statusQuery = useQuery({
|
||
queryKey: ["erp-master-data-status"],
|
||
queryFn: () => fetchErpMasterDataStatus(),
|
||
});
|
||
|
||
const outboxStatsQuery = useQuery({
|
||
enabled: tab === "outbox",
|
||
queryKey: ["integration-outbox-stats"],
|
||
queryFn: () => fetchIntegrationOutboxStats(),
|
||
});
|
||
|
||
const outboxQuery = useQuery({
|
||
enabled: tab === "outbox",
|
||
queryKey: ["integration-outbox", outboxPage, outboxStatus, outboxEventType],
|
||
queryFn: () =>
|
||
fetchIntegrationOutbox({
|
||
page: outboxPage,
|
||
pageSize: 20,
|
||
status: outboxStatus || undefined,
|
||
eventType: outboxEventType.trim() || undefined,
|
||
}),
|
||
});
|
||
|
||
const outboxDetailQuery = useQuery({
|
||
enabled: Boolean(selectedOutboxId),
|
||
queryKey: ["integration-outbox-detail", selectedOutboxId],
|
||
queryFn: () => fetchIntegrationOutboxDetail(selectedOutboxId),
|
||
});
|
||
|
||
const bindingsQuery = useQuery({
|
||
enabled: tab === "bindings",
|
||
queryKey: ["integration-bindings", bindingsPage, bindingProvider, bindingEntityType],
|
||
queryFn: () =>
|
||
fetchIntegrationBindings({
|
||
page: bindingsPage,
|
||
pageSize: 20,
|
||
provider: bindingProvider || undefined,
|
||
entityType: bindingEntityType || undefined,
|
||
}),
|
||
});
|
||
|
||
const syncMutation = useMutation({
|
||
mutationFn: async (resources?: ErpMasterDataResourceType[]) => syncErpMasterData({ resources }),
|
||
onSuccess: async (result) => {
|
||
await queryClient.invalidateQueries({ queryKey: ["erp-master-data-status"] });
|
||
setError("");
|
||
const finished = result.items
|
||
.map((item) => `${RESOURCE_LABELS[item.resourceType]}:${item.status}`)
|
||
.join(" / ");
|
||
toast.success(`主数据同步完成:${finished}`);
|
||
},
|
||
onError: (mutationError) => {
|
||
const message = resolveError(mutationError);
|
||
setError(message);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const retryMutation = useMutation({
|
||
mutationFn: retryIntegrationOutbox,
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["integration-outbox"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["integration-outbox-stats"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["integration-outbox-detail"] }),
|
||
]);
|
||
toast.success("同步任务已重置为待处理");
|
||
},
|
||
onError: (mutationError) => {
|
||
const message = resolveError(mutationError);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const itemsByType = useMemo(() => {
|
||
const map = new Map<ErpMasterDataResourceType, ErpMasterDataSyncItem>();
|
||
for (const item of statusQuery.data?.items ?? []) {
|
||
map.set(item.resourceType, item);
|
||
}
|
||
return map;
|
||
}, [statusQuery.data?.items]);
|
||
|
||
const gate = statusQuery.data?.gate ?? null;
|
||
const outboxTotalPages = Math.max(1, Math.ceil((outboxQuery.data?.total ?? 0) / 20));
|
||
const bindingsTotalPages = Math.max(1, Math.ceil((bindingsQuery.data?.total ?? 0) / 20));
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title="外部系统"
|
||
subtitle="主数据同步、业务队列和 ID 映射。"
|
||
actions={
|
||
tab === "master-data" ? (
|
||
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
|
||
{syncMutation.isPending ? "同步中..." : "同步全部"}
|
||
</Button>
|
||
) : null
|
||
}
|
||
/>
|
||
<WorkspaceNotice title="ERP 写入后置" tone="warning">
|
||
试运行期间不把 ERP 写入作为主路径;本页用于管理员查看同步状态和队列排障。
|
||
</WorkspaceNotice>
|
||
|
||
{error ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||
) : null}
|
||
|
||
<Tabs value={tab} onValueChange={(value) => setTab(value as ErpTab)}>
|
||
<TabsList className="flex w-full justify-start gap-1">
|
||
<TabsTrigger value="master-data">主数据同步</TabsTrigger>
|
||
<TabsTrigger value="outbox">同步队列</TabsTrigger>
|
||
<TabsTrigger value="bindings">ID 映射</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="master-data" className="space-y-6">
|
||
<div className="grid gap-4 lg:grid-cols-4">
|
||
<div className="card-std p-5 space-y-2 lg:col-span-4">
|
||
<h3 className="text-base font-semibold text-zinc-900">ERP 状态</h3>
|
||
{statusQuery.isLoading ? <p className="text-sm text-zinc-500">加载中...</p> : null}
|
||
{statusQuery.isError ? (
|
||
<p className="text-sm text-red-600">{resolveError(statusQuery.error)}</p>
|
||
) : null}
|
||
{gate ? (
|
||
<div className="grid gap-3 text-sm text-zinc-700 md:grid-cols-2 xl:grid-cols-4">
|
||
<div>
|
||
<div className="text-zinc-500">Env 总闸</div>
|
||
<div className="font-medium">{gate.envEnabled ? "已开启" : "已关闭"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-zinc-500">功能开关</div>
|
||
<div className="font-medium">{gate.effectiveEnabled ? "允许" : "阻断"}</div>
|
||
<div className="text-xs text-zinc-500">{formatGateReason(gate.effectiveReason)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-zinc-500">同步服务 / 主数据端点</div>
|
||
<div className="font-medium">{gate.provider}</div>
|
||
<div className="text-xs text-zinc-500">
|
||
{gate.masterDataEndpointConfigured ? "主数据端点已配置" : "主数据端点未配置"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-zinc-500">凭证端点 / 自动同步</div>
|
||
<div className="font-medium">
|
||
{gate.journalEndpointConfigured ? "凭证端点已配置" : "凭证端点未配置"}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">{gate.masterDataSyncCron || "未启用自动同步"}</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{(["legal_entity", "cost_center", "supplier"] as const).map((resourceType) => {
|
||
const item = itemsByType.get(resourceType) ?? null;
|
||
return (
|
||
<div key={resourceType} className="card-std space-y-3 p-5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-zinc-900">{RESOURCE_LABELS[resourceType]}</h3>
|
||
<p className="text-sm text-zinc-500">状态:{item?.status ?? "idle"}</p>
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => syncMutation.mutate([resourceType])}
|
||
disabled={syncMutation.isPending}
|
||
>
|
||
仅同步{RESOURCE_LABELS[resourceType]}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-1 text-sm text-zinc-700">
|
||
<div>最近开始:{formatDateTime(item?.lastStartedAt ?? null)}</div>
|
||
<div>最近完成:{formatDateTime(item?.lastFinishedAt ?? null)}</div>
|
||
<div>最近成功:{formatDateTime(item?.lastSuccessAt ?? null)}</div>
|
||
<div className="break-all">最近错误:{item?.lastError ?? "-"}</div>
|
||
</div>
|
||
|
||
{item?.lastResult ? (
|
||
<div className="space-y-1 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-700">
|
||
<div>
|
||
请求 {item.lastResult.requested} / 新增 {item.lastResult.created} / 更新 {item.lastResult.updated}
|
||
</div>
|
||
<div>
|
||
归档 {item.lastResult.archived} / 跳过 {item.lastResult.skipped}
|
||
</div>
|
||
{item.lastResult.sampleSkipped.length ? (
|
||
<div className="break-all text-xs text-zinc-500">
|
||
示例跳过:
|
||
{item.lastResult.sampleSkipped
|
||
.map((sample) => `${sample.key}:${sample.reason}`)
|
||
.join(";")}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-zinc-500">暂无同步记录。</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="card-std space-y-2 p-5 text-sm text-zinc-600">
|
||
<h3 className="text-base font-semibold text-zinc-900">说明</h3>
|
||
<p>- 本页只负责主数据同步、业务同步队列和 ID 映射。</p>
|
||
<p>- 报销、采购和资产的同步动作在对应业务详情区执行。</p>
|
||
<p>- 当前仍使用演示同步服务。</p>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="outbox" className="space-y-6">
|
||
<div className="grid gap-4 md:grid-cols-5">
|
||
{OUTBOX_STATUS_OPTIONS.filter((item) => item.value).map((item) => {
|
||
const status = item.value as IntegrationOutboxStatus;
|
||
const count =
|
||
status === "pending"
|
||
? outboxStatsQuery.data?.pending
|
||
: status === "processing"
|
||
? outboxStatsQuery.data?.processing
|
||
: status === "succeeded"
|
||
? outboxStatsQuery.data?.succeeded
|
||
: status === "failed"
|
||
? outboxStatsQuery.data?.failed
|
||
: outboxStatsQuery.data?.deadLetter;
|
||
|
||
return (
|
||
<div
|
||
key={status}
|
||
className={`card-std space-y-2 p-4 ${status === "failed" || status === "dead_letter" ? "border-red-200 bg-red-50/60" : ""}`}
|
||
>
|
||
<div className="text-sm text-zinc-500">{outboxStatusLabel(status)}</div>
|
||
<div className="text-2xl font-semibold text-zinc-900">
|
||
{outboxStatsQuery.isLoading ? "-" : count ?? 0}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="card-std space-y-4 p-5">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-zinc-900">同步任务列表</h3>
|
||
<p className="text-sm text-zinc-500">失败任务可重试。</p>
|
||
</div>
|
||
<div className="flex flex-col gap-3 md:flex-row">
|
||
<label className="space-y-1 text-sm text-zinc-600">
|
||
<span>状态</span>
|
||
<select
|
||
className="select-std min-w-40"
|
||
value={outboxStatus}
|
||
onChange={(event) => {
|
||
setOutboxStatus(event.target.value as IntegrationOutboxStatus | "");
|
||
setOutboxPage(1);
|
||
}}
|
||
>
|
||
{OUTBOX_STATUS_OPTIONS.map((item) => (
|
||
<option key={item.label} value={item.value}>
|
||
{item.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="space-y-1 text-sm text-zinc-600">
|
||
<span>事件类型</span>
|
||
<Input
|
||
className="min-w-56"
|
||
placeholder="例如 erp.journal.export"
|
||
value={outboxEventType}
|
||
onChange={(event) => {
|
||
setOutboxEventType(event.target.value);
|
||
setOutboxPage(1);
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{outboxQuery.isLoading ? <TableSkeleton columns={7} rows={6} /> : null}
|
||
{outboxQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveError(outboxQuery.error)}
|
||
</p>
|
||
) : null}
|
||
|
||
{outboxQuery.data ? (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>事件类型</TableHead>
|
||
<TableHead>摘要标识</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>尝试</TableHead>
|
||
<TableHead>下次重试</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead>处理时间</TableHead>
|
||
<TableHead className="w-40">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{outboxQuery.data.items.length ? (
|
||
outboxQuery.data.items.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell className="font-medium text-zinc-900">{item.eventType}</TableCell>
|
||
<TableCell>
|
||
<div className="space-y-1 text-sm">
|
||
<div className="font-medium text-zinc-900">{renderOutboxSummary(item)}</div>
|
||
<div className="text-xs text-zinc-500">
|
||
{item.payloadSummary.entityType ?? "-"}
|
||
{item.payloadSummary.sourceEventName ? ` · ${item.payloadSummary.sourceEventName}` : ""}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className={`inline-flex rounded-full border px-2 py-1 text-xs font-medium ${outboxStatusClassName(item.status)}`}>
|
||
{outboxStatusLabel(item.status)}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
{item.attempts} / {item.maxAttempts}
|
||
</TableCell>
|
||
<TableCell>{formatDateTime(item.nextRetryAt)}</TableCell>
|
||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||
<TableCell>{formatDateTime(item.processedAt)}</TableCell>
|
||
<TableCell>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={() => setSelectedOutboxId(item.id)}
|
||
>
|
||
查看详情
|
||
</Button>
|
||
{item.status === "failed" || item.status === "dead_letter" ? (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={retryMutation.isPending}
|
||
onClick={() => retryMutation.mutate(item.id)}
|
||
>
|
||
重试
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell colSpan={8} className="py-6 text-center text-zinc-500">
|
||
当前筛选下暂无同步任务
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<Pagination page={outboxPage} totalPages={outboxTotalPages} onPageChange={setOutboxPage} />
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="bindings" className="space-y-6">
|
||
<div className="card-std space-y-4 p-5">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-zinc-900">IntegrationBinding 列表</h3>
|
||
<p className="text-sm text-zinc-500">查看本地单据与 ERP 外部 ID/编号的映射关系,并支持深链回到业务页。</p>
|
||
</div>
|
||
<div className="flex flex-col gap-3 md:flex-row">
|
||
<label className="space-y-1 text-sm text-zinc-600">
|
||
<span>同步服务</span>
|
||
<select
|
||
className="select-std min-w-40"
|
||
value={bindingProvider}
|
||
onChange={(event) => {
|
||
setBindingProvider(event.target.value);
|
||
setBindingsPage(1);
|
||
}}
|
||
>
|
||
<option value="">全部同步服务</option>
|
||
<option value="erp">erp</option>
|
||
</select>
|
||
</label>
|
||
<label className="space-y-1 text-sm text-zinc-600">
|
||
<span>实体类型</span>
|
||
<select
|
||
className="select-std min-w-56"
|
||
value={bindingEntityType}
|
||
onChange={(event) => {
|
||
setBindingEntityType(event.target.value);
|
||
setBindingsPage(1);
|
||
}}
|
||
>
|
||
{BINDING_ENTITY_OPTIONS.map((item) => (
|
||
<option key={item.label} value={item.value}>
|
||
{item.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{bindingsQuery.isLoading ? <TableSkeleton columns={6} rows={6} /> : null}
|
||
{bindingsQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveError(bindingsQuery.error)}
|
||
</p>
|
||
) : null}
|
||
|
||
{bindingsQuery.data ? (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>同步服务</TableHead>
|
||
<TableHead>实体类型</TableHead>
|
||
<TableHead>本地 ID</TableHead>
|
||
<TableHead>外部 ID</TableHead>
|
||
<TableHead>外部编号</TableHead>
|
||
<TableHead>最后同步</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{bindingsQuery.data.items.length ? (
|
||
bindingsQuery.data.items.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell>{item.provider}</TableCell>
|
||
<TableCell>{bindingEntityLabel(item.entityType)}</TableCell>
|
||
<TableCell>
|
||
<BindingLocalIdCell item={item} />
|
||
</TableCell>
|
||
<TableCell className="break-all">{item.externalId ?? "-"}</TableCell>
|
||
<TableCell className="break-all">{item.externalCode ?? "-"}</TableCell>
|
||
<TableCell title={formatDateTime(item.lastSyncedAt)}>{formatRelativeTime(item.lastSyncedAt)}</TableCell>
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="py-6 text-center text-zinc-500">
|
||
当前筛选下暂无 ID 映射记录
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<Pagination page={bindingsPage} totalPages={bindingsTotalPages} onPageChange={setBindingsPage} />
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<Dialog
|
||
open={Boolean(selectedOutboxId)}
|
||
onOpenChange={(open) => {
|
||
if (!open) {
|
||
setSelectedOutboxId("");
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>同步任务详情</DialogTitle>
|
||
<DialogDescription>查看数据内容、错误摘要和当前队列状态。</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{outboxDetailQuery.isLoading ? <p className="text-sm text-zinc-500">详情加载中...</p> : null}
|
||
{outboxDetailQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{resolveError(outboxDetailQuery.error)}
|
||
</p>
|
||
) : null}
|
||
|
||
{outboxDetailQuery.data ? (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-700 md:grid-cols-2">
|
||
<div>队列 ID:{outboxDetailQuery.data.id}</div>
|
||
<div>事件类型:{outboxDetailQuery.data.eventType}</div>
|
||
<div>状态:{outboxStatusLabel(outboxDetailQuery.data.status)}</div>
|
||
<div>尝试次数:{outboxDetailQuery.data.attempts} / {outboxDetailQuery.data.maxAttempts}</div>
|
||
<div>创建时间:{formatDateTime(outboxDetailQuery.data.createdAt)}</div>
|
||
<div>处理时间:{formatDateTime(outboxDetailQuery.data.processedAt)}</div>
|
||
<div>下次重试:{formatDateTime(outboxDetailQuery.data.nextRetryAt)}</div>
|
||
<div>死信时间:{formatDateTime(outboxDetailQuery.data.deadLetterAt)}</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-sm font-medium text-zinc-900">最近错误</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-600">
|
||
{outboxDetailQuery.data.lastError ?? "-"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-sm font-medium text-zinc-900">数据内容</div>
|
||
<pre className="max-h-[45vh] overflow-auto rounded-lg border border-zinc-200 bg-zinc-950 p-4 text-xs text-zinc-100">
|
||
{JSON.stringify(outboxDetailQuery.data.payload ?? null, null, 2)}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|