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

这个提交包含在:
2026-04-05 11:21:39 +08:00
父节点 ff0a677bf3
当前提交 47e10e6acc
共修改 24 个文件,包含 1701 行新增和 197 行删除
+7
查看文件
@@ -53,3 +53,10 @@ export async function unbindWecom() {
body: JSON.stringify({}),
});
}
export async function updateNotificationPreferences(payload: { notificationPushEnabled: boolean }) {
return apiRequestData<{ notificationPushEnabled: boolean }>("/api/me/notification-preferences", {
method: "PATCH",
body: JSON.stringify(payload),
});
}
+44
查看文件
@@ -85,6 +85,28 @@ export type NotificationChannelTestResponse = {
};
};
export type NotificationDeliveryStatus = "pending" | "success" | "failed";
export type NotificationDeliveryRecord = {
notificationId: string;
channelId: string;
channelName: string;
channelType: NotificationChannelType;
notificationType: string;
notificationTitle: string;
status: NotificationDeliveryStatus;
attemptCount: number;
latestAttemptAt: string | null;
resultCode: string | null;
errorMessage: string | null;
responseBody: unknown;
notificationCreatedAt: string | null;
};
export type NotificationDeliveryListResponse = {
items: NotificationDeliveryRecord[];
};
export async function fetchNotificationChannels() {
return apiRequestData<NotificationChannelListResponse>("/api/notifications/channels");
}
@@ -109,3 +131,25 @@ export async function testNotificationChannel(id: string) {
body: JSON.stringify({}),
});
}
export async function fetchNotificationDeliveries(filters: {
notificationId?: string | null;
channelId?: string | null;
status?: NotificationDeliveryStatus | "" | null;
}) {
const params = new URLSearchParams();
if (filters.notificationId) {
params.set("notificationId", filters.notificationId);
}
if (filters.channelId) {
params.set("channelId", filters.channelId);
}
if (filters.status) {
params.set("status", filters.status);
}
const queryString = params.toString();
return apiRequestData<NotificationDeliveryListResponse>(
`/api/notifications/deliveries${queryString ? `?${queryString}` : ""}`,
);
}
+1
查看文件
@@ -31,6 +31,7 @@ export type MeResponse = {
departmentId: string;
directManagerId: string | null;
status: string;
notificationPushEnabled: boolean;
roleCodes: string[];
wecomBinding?: {
externalUserId: string;
+24 -1
查看文件
@@ -32,6 +32,7 @@ type AuthContextValue = AuthState & {
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
refreshMe: () => Promise<void>;
clearSession: () => void;
offlineAccessMode: boolean;
};
@@ -248,6 +249,27 @@ export function AuthProvider({ children }: PropsWithChildren) {
}
}, [clearSession]);
const refreshMe = useCallback(async () => {
let accessToken = tokenRef.current;
if (!accessToken) {
const refreshed = await refreshSession();
accessToken = refreshed.accessToken;
tokenRef.current = accessToken;
}
const me = await fetchCurrentEmployee(accessToken);
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
setOfflineAccessMode(false);
writeOfflineAuthSnapshot(me, permissions);
setState((previous) => ({
...previous,
accessToken,
me,
permissions,
ready: true,
}));
}, []);
useEffect(() => {
configureHttpAuth({
getAccessToken: () => tokenRef.current,
@@ -311,10 +333,11 @@ export function AuthProvider({ children }: PropsWithChildren) {
changePassword,
logout,
bootstrapByRefresh,
refreshMe,
clearSession,
offlineAccessMode,
}),
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, state],
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+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">