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