R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关
这个提交包含在:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export type MeResponse = {
|
||||
departmentId: string;
|
||||
directManagerId: string | null;
|
||||
status: string;
|
||||
notificationPushEnabled: boolean;
|
||||
roleCodes: string[];
|
||||
wecomBinding?: {
|
||||
externalUserId: string;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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">
|
||||
|
||||
在新工单中引用
屏蔽一个用户