feat(P23IM2-4): 通知渠道运维态前端(secret保留/替换/清空)

这个提交包含在:
2026-03-13 21:50:04 +08:00
父节点 a8ec86016c
当前提交 e5b4e11f76
共修改 3 个文件,包含 145 行新增和 24 行删除
+4
查看文件
@@ -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 || "请求失败,请稍后重试。";
+22 -3
查看文件
@@ -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),
+119 -21
查看文件
@@ -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">