feat(P23IM2-4): 通知渠道运维态前端(secret保留/替换/清空)
这个提交包含在:
@@ -235,6 +235,10 @@ export function mapApiErrorMessage(
|
|||||||
if (details?.integration === "travel_sync_disabled") {
|
if (details?.integration === "travel_sync_disabled") {
|
||||||
return "差旅同步功能未开启。";
|
return "差旅同步功能未开启。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (details?.secret === "field_encryption_key_missing") {
|
||||||
|
return "当前环境未配置 FIELD_ENCRYPTION_KEY,无法保存或更新 Webhook Secret。";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackMessage || "请求失败,请稍后重试。";
|
return fallbackMessage || "请求失败,请稍后重试。";
|
||||||
|
|||||||
@@ -20,7 +20,17 @@ export type NotificationChannelRouting = {
|
|||||||
export type NotificationChannelConfig = {
|
export type NotificationChannelConfig = {
|
||||||
mode: NotificationChannelMode;
|
mode: NotificationChannelMode;
|
||||||
webhookUrl: string | null;
|
webhookUrl: string | null;
|
||||||
secret: string | null;
|
timeoutMs: number;
|
||||||
|
routing: NotificationChannelRouting;
|
||||||
|
secretConfigured: boolean;
|
||||||
|
secretMasked: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationChannelWriteConfig = {
|
||||||
|
mode: NotificationChannelMode;
|
||||||
|
webhookUrl: string | null;
|
||||||
|
secret?: string | null;
|
||||||
|
clearSecret?: boolean;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
routing: NotificationChannelRouting;
|
routing: NotificationChannelRouting;
|
||||||
};
|
};
|
||||||
@@ -51,15 +61,24 @@ export type NotificationChannelPayload = {
|
|||||||
channelType: "wecom";
|
channelType: "wecom";
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
configJson: NotificationChannelConfig;
|
configJson: NotificationChannelWriteConfig;
|
||||||
templateJson: NotificationChannelTemplate;
|
templateJson: NotificationChannelTemplate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotificationChannelUpdatePayload = {
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
configJson?: NotificationChannelWriteConfig;
|
||||||
|
templateJson?: NotificationChannelTemplate;
|
||||||
|
};
|
||||||
|
|
||||||
export type NotificationChannelTestResponse = {
|
export type NotificationChannelTestResponse = {
|
||||||
channel: NotificationChannel;
|
channel: NotificationChannel;
|
||||||
testResult: {
|
testResult: {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
code: string;
|
code: string;
|
||||||
|
message: string | null;
|
||||||
|
retryable: boolean | null;
|
||||||
previewMessage: string | null;
|
previewMessage: string | null;
|
||||||
response: unknown;
|
response: unknown;
|
||||||
skipped: boolean;
|
skipped: boolean;
|
||||||
@@ -77,7 +96,7 @@ export async function createNotificationChannel(payload: NotificationChannelPayl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNotificationChannel(id: string, payload: Omit<NotificationChannelPayload, "channelType">) {
|
export async function updateNotificationChannel(id: string, payload: NotificationChannelUpdatePayload) {
|
||||||
return apiRequestData<{ channel: NotificationChannel }>(`/api/notifications/channels/${id}`, {
|
return apiRequestData<{ channel: NotificationChannel }>(`/api/notifications/channels/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type NotificationChannelConfig,
|
type NotificationChannelConfig,
|
||||||
type NotificationChannelPayload,
|
type NotificationChannelPayload,
|
||||||
type NotificationChannelTemplate,
|
type NotificationChannelTemplate,
|
||||||
|
type NotificationChannelWriteConfig,
|
||||||
} from "../api/notificationChannels";
|
} from "../api/notificationChannels";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
@@ -34,6 +35,9 @@ type ChannelFormState = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mode: NotificationChannelConfig["mode"];
|
mode: NotificationChannelConfig["mode"];
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
secretConfigured: boolean;
|
||||||
|
secretMasked: string | null;
|
||||||
|
secretAction: "keep" | "replace" | "clear";
|
||||||
secret: string;
|
secret: string;
|
||||||
timeoutMs: string;
|
timeoutMs: string;
|
||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
@@ -50,6 +54,8 @@ type TestResultState = {
|
|||||||
channelName: string;
|
channelName: string;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
code: string;
|
code: string;
|
||||||
|
message: string | null;
|
||||||
|
retryable: boolean | null;
|
||||||
skipped: boolean;
|
skipped: boolean;
|
||||||
previewMessage: string | null;
|
previewMessage: string | null;
|
||||||
response: unknown;
|
response: unknown;
|
||||||
@@ -111,6 +117,9 @@ function buildInitialForm(): ChannelFormState {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
mode: "mock",
|
mode: "mock",
|
||||||
webhookUrl: "",
|
webhookUrl: "",
|
||||||
|
secretConfigured: false,
|
||||||
|
secretMasked: null,
|
||||||
|
secretAction: "replace",
|
||||||
secret: "",
|
secret: "",
|
||||||
timeoutMs: "5000",
|
timeoutMs: "5000",
|
||||||
roleCodes: [],
|
roleCodes: [],
|
||||||
@@ -130,7 +139,10 @@ function mapChannelToForm(channel: NotificationChannel): ChannelFormState {
|
|||||||
enabled: channel.enabled,
|
enabled: channel.enabled,
|
||||||
mode: channel.configJson.mode,
|
mode: channel.configJson.mode,
|
||||||
webhookUrl: channel.configJson.webhookUrl ?? "",
|
webhookUrl: channel.configJson.webhookUrl ?? "",
|
||||||
secret: channel.configJson.secret ?? "",
|
secretConfigured: channel.configJson.secretConfigured,
|
||||||
|
secretMasked: channel.configJson.secretMasked,
|
||||||
|
secretAction: channel.configJson.secretConfigured ? "keep" : "replace",
|
||||||
|
secret: "",
|
||||||
timeoutMs: String(channel.configJson.timeoutMs ?? 5000),
|
timeoutMs: String(channel.configJson.timeoutMs ?? 5000),
|
||||||
roleCodes: channel.configJson.routing.roleCodes ?? [],
|
roleCodes: channel.configJson.routing.roleCodes ?? [],
|
||||||
domainScopes: channel.configJson.routing.domainScopes ?? [],
|
domainScopes: channel.configJson.routing.domainScopes ?? [],
|
||||||
@@ -172,21 +184,28 @@ function buildPayload(form: ChannelFormState): NotificationChannelPayload {
|
|||||||
deepLinkLabel: form.deepLinkLabel.trim() || "查看详情",
|
deepLinkLabel: form.deepLinkLabel.trim() || "查看详情",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const configJson: NotificationChannelWriteConfig = {
|
||||||
|
mode: form.mode,
|
||||||
|
webhookUrl: form.webhookUrl.trim() || null,
|
||||||
|
timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)),
|
||||||
|
routing: {
|
||||||
|
roleCodes: form.roleCodes,
|
||||||
|
domainScopes: form.domainScopes,
|
||||||
|
allowedTypes: form.allowedTypes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.secretAction === "replace") {
|
||||||
|
configJson.secret = form.secret.trim() || null;
|
||||||
|
} else if (form.secretAction === "clear") {
|
||||||
|
configJson.clearSecret = true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channelType: form.channelType,
|
channelType: form.channelType,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
configJson: {
|
configJson,
|
||||||
mode: form.mode,
|
|
||||||
webhookUrl: form.webhookUrl.trim() || null,
|
|
||||||
secret: form.secret.trim() || null,
|
|
||||||
timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)),
|
|
||||||
routing: {
|
|
||||||
roleCodes: form.roleCodes,
|
|
||||||
domainScopes: form.domainScopes,
|
|
||||||
allowedTypes: form.allowedTypes,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
templateJson,
|
templateJson,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -208,8 +227,12 @@ export function NotificationChannelsPage() {
|
|||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
if (editingId && form.secretAction === "replace" && !form.secret.trim()) {
|
||||||
|
throw new Error("请输入新的 Webhook Secret,或切换为保留现有密钥。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPayload(form);
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
const payload = buildPayload(form);
|
|
||||||
return updateNotificationChannel(editingId, {
|
return updateNotificationChannel(editingId, {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
@@ -217,7 +240,7 @@ export function NotificationChannelsPage() {
|
|||||||
templateJson: payload.templateJson,
|
templateJson: payload.templateJson,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return createNotificationChannel(buildPayload(form));
|
return createNotificationChannel(payload);
|
||||||
},
|
},
|
||||||
onSuccess: async ({ channel }) => {
|
onSuccess: async ({ channel }) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["notification-channels"] });
|
await queryClient.invalidateQueries({ queryKey: ["notification-channels"] });
|
||||||
@@ -247,6 +270,8 @@ export function NotificationChannelsPage() {
|
|||||||
channelName: result.channelName,
|
channelName: result.channelName,
|
||||||
ok: result.testResult.ok,
|
ok: result.testResult.ok,
|
||||||
code: result.testResult.code,
|
code: result.testResult.code,
|
||||||
|
message: result.testResult.message,
|
||||||
|
retryable: result.testResult.retryable,
|
||||||
skipped: result.testResult.skipped,
|
skipped: result.testResult.skipped,
|
||||||
previewMessage: result.testResult.previewMessage,
|
previewMessage: result.testResult.previewMessage,
|
||||||
response: result.testResult.response,
|
response: result.testResult.response,
|
||||||
@@ -461,13 +486,78 @@ export function NotificationChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor="webhookSecret">Webhook Secret(可空)</Label>
|
<Label htmlFor="webhookSecret">Webhook Secret</Label>
|
||||||
<Input
|
{editingId && form.secretConfigured ? (
|
||||||
id="webhookSecret"
|
<div className="space-y-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4">
|
||||||
value={form.secret}
|
<p className="text-xs text-zinc-600">
|
||||||
placeholder="可选,配置后将参与签名"
|
当前已配置密钥:{form.secretMasked ?? "******"}。默认保留现有密钥,不会回显明文。
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, secret: event.target.value }))}
|
</p>
|
||||||
/>
|
<div className="flex flex-wrap gap-4 text-sm text-zinc-700">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="secretAction"
|
||||||
|
checked={form.secretAction === "keep"}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretAction: "keep",
|
||||||
|
secret: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
保留现有密钥
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="secretAction"
|
||||||
|
checked={form.secretAction === "replace"}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretAction: "replace",
|
||||||
|
secret: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
替换密钥
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="secretAction"
|
||||||
|
checked={form.secretAction === "clear"}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretAction: "clear",
|
||||||
|
secret: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
清空密钥
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{editingId ? "当前未配置密钥,保存时可新增。" : "可选,配置后将参与签名。"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.secretAction === "replace" ? (
|
||||||
|
<Input
|
||||||
|
id="webhookSecret"
|
||||||
|
value={form.secret}
|
||||||
|
placeholder={editingId ? "输入新的 Webhook Secret" : "可选,配置后将参与签名"}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, secret: event.target.value }))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{form.secretAction === "clear" ? (
|
||||||
|
<p className="text-xs text-amber-600">保存后将清空当前 Webhook Secret。</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -617,8 +707,16 @@ export function NotificationChannelsPage() {
|
|||||||
{lastTestResult.ok ? "成功" : "失败"}
|
{lastTestResult.ok ? "成功" : "失败"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-zinc-500">code={lastTestResult.code}</span>
|
<span className="text-zinc-500">code={lastTestResult.code}</span>
|
||||||
|
{lastTestResult.retryable !== null ? (
|
||||||
|
<span className="text-zinc-500">retryable={lastTestResult.retryable ? "true" : "false"}</span>
|
||||||
|
) : null}
|
||||||
{lastTestResult.skipped ? <span className="text-amber-600">(已跳过)</span> : null}
|
{lastTestResult.skipped ? <span className="text-amber-600">(已跳过)</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{lastTestResult.message ? (
|
||||||
|
<p className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-700">
|
||||||
|
{lastTestResult.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 font-medium text-zinc-900">预览消息</p>
|
<p className="mb-1 font-medium text-zinc-900">预览消息</p>
|
||||||
<pre className="whitespace-pre-wrap rounded-lg bg-white p-3 text-xs text-zinc-700">
|
<pre className="whitespace-pre-wrap rounded-lg bg-white p-3 text-xs text-zinc-700">
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户