R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关

这个提交包含在:
2026-04-05 11:21:39 +08:00
父节点 ff0a677bf3
当前提交 47e10e6acc
共修改 24 个文件,包含 1701 行新增和 197 行删除
+112 -1
查看文件
@@ -4,11 +4,13 @@ import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
createNotificationChannel,
fetchNotificationDeliveries,
fetchNotificationChannels,
testNotificationChannel,
updateNotificationChannel,
type NotificationChannel,
type NotificationChannelConfig,
type NotificationDeliveryStatus,
type NotificationChannelPayload,
type NotificationChannelTemplate,
type NotificationChannelWriteConfig,
@@ -171,6 +173,12 @@ function formatMode(mode: string) {
return mode === "wecom_webhook" ? "Webhook" : "Mock";
}
function formatDeliveryStatus(status: NotificationDeliveryStatus) {
if (status === "success") return "成功";
if (status === "failed") return "失败";
return "处理中";
}
function formatSummary(values: string[], labelMap: Record<string, string>, emptyLabel: string) {
if (!values.length) return emptyLabel;
return values.map((value) => labelMap[value] ?? value).join("、");
@@ -219,11 +227,23 @@ export function NotificationChannelsPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ChannelFormState>(buildInitialForm);
const [lastTestResult, setLastTestResult] = useState<TestResultState | null>(null);
const [deliveryFilters, setDeliveryFilters] = useState<{ channelId: string; status: NotificationDeliveryStatus | "" }>({
channelId: "",
status: "",
});
const channelsQuery = useQuery({
queryKey: ["notification-channels"],
queryFn: () => fetchNotificationChannels(),
});
const deliveriesQuery = useQuery({
queryKey: ["notification-deliveries", deliveryFilters],
queryFn: () =>
fetchNotificationDeliveries({
channelId: deliveryFilters.channelId || null,
status: deliveryFilters.status || null,
}),
});
const saveMutation = useMutation({
mutationFn: async () => {
@@ -308,7 +328,7 @@ export function NotificationChannelsPage() {
<div className="space-y-6">
<PageHeader
title="通知渠道"
subtitle="维护企微群机器人渠道、业务域路由与测试发送。IM 继续通过 Outbox 异步派发。"
subtitle="维护企微群机器人渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
actions={
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
新建渠道
@@ -404,6 +424,97 @@ export function NotificationChannelsPage() {
) : null}
</div>
<div className="card-std space-y-4 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">最近投递记录</h3>
<p className="text-sm text-zinc-500">展示业务通知同步外推的最近状态,不包含页面里的“测试发送”结果。</p>
</div>
<div className="flex flex-wrap gap-3">
<select
className="select-std"
value={deliveryFilters.channelId}
onChange={(event) => setDeliveryFilters((prev) => ({ ...prev, channelId: event.target.value }))}
>
<option value="">全部渠道</option>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
<select
className="select-std"
value={deliveryFilters.status}
onChange={(event) =>
setDeliveryFilters((prev) => ({
...prev,
status: event.target.value as NotificationDeliveryStatus | "",
}))
}
>
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="pending">处理中</option>
</select>
</div>
</div>
{deliveriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{deliveriesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(deliveriesQuery.error)}
</p>
) : null}
{!deliveriesQuery.isLoading && !deliveriesQuery.isError ? (
<div className="rounded-lg border border-zinc-200">
<Table data-testid="notification-deliveries-table">
<TableHeader>
<tr>
<TableHead>通知</TableHead>
<TableHead>渠道</TableHead>
<TableHead>状态</TableHead>
<TableHead>尝试次数</TableHead>
<TableHead>结果码</TableHead>
<TableHead>最近尝试</TableHead>
<TableHead>错误摘要</TableHead>
</tr>
</TableHeader>
<TableBody>
{(deliveriesQuery.data?.items ?? []).length ? (
(deliveriesQuery.data?.items ?? []).map((item) => (
<TableRow key={`${item.notificationId}:${item.channelId}`}>
<TableCell className="max-w-sm">
<div className="font-medium text-zinc-900">{item.notificationTitle || item.notificationType}</div>
<div className="text-xs text-zinc-500">{item.notificationType}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.channelName}</div>
<div className="text-xs text-zinc-500">{formatChannelType(item.channelType)}</div>
</TableCell>
<TableCell>{formatDeliveryStatus(item.status)}</TableCell>
<TableCell>{item.attemptCount}</TableCell>
<TableCell className="text-xs text-zinc-600">{item.resultCode ?? "-"}</TableCell>
<TableCell className="text-xs text-zinc-600">{formatDateTime(item.latestAttemptAt)}</TableCell>
<TableCell className="max-w-xs text-xs text-zinc-600">{item.errorMessage ?? "-"}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="py-4 text-center text-sm text-zinc-500">
暂无投递记录。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between gap-3">