W1-W3 实现企业微信个人应用消息通知

这个提交包含在:
2026-05-11 09:17:00 +08:00
父节点 805e050ba8
当前提交 b1ae41fa99
共修改 12 个文件,包含 869 行新增和 111 行删除
+106 -88
查看文件
@@ -170,6 +170,7 @@ function formatChannelType(channelType: string) {
}
function formatMode(mode: string) {
if (mode === "wecom_app_message") return "企微应用消息";
return mode === "wecom_webhook" ? "Webhook" : "Mock";
}
@@ -185,6 +186,7 @@ function formatSummary(values: string[], labelMap: Record<string, string>, empty
}
function buildPayload(form: ChannelFormState): NotificationChannelPayload {
const isWebhookMode = form.mode === "wecom_webhook";
const templateJson: NotificationChannelTemplate = {
title: form.title.trim() || "{{title}}",
body: form.body.trim() || "{{body}}\n\n查看详情:{{detailUrl}}",
@@ -194,7 +196,7 @@ function buildPayload(form: ChannelFormState): NotificationChannelPayload {
const configJson: NotificationChannelWriteConfig = {
mode: form.mode,
webhookUrl: form.webhookUrl.trim() || null,
webhookUrl: isWebhookMode ? form.webhookUrl.trim() || null : null,
timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)),
routing: {
roleCodes: form.roleCodes,
@@ -203,9 +205,9 @@ function buildPayload(form: ChannelFormState): NotificationChannelPayload {
},
};
if (form.secretAction === "replace") {
if (isWebhookMode && form.secretAction === "replace") {
configJson.secret = form.secret.trim() || null;
} else if (form.secretAction === "clear") {
} else if (isWebhookMode && form.secretAction === "clear") {
configJson.clearSecret = true;
}
@@ -247,7 +249,7 @@ export function NotificationChannelsPage() {
const saveMutation = useMutation({
mutationFn: async () => {
if (editingId && form.secretAction === "replace" && !form.secret.trim()) {
if (editingId && form.mode === "wecom_webhook" && form.secretAction === "replace" && !form.secret.trim()) {
throw new Error("请输入新的 Webhook Secret,或切换为保留现有密钥。");
}
@@ -323,12 +325,14 @@ export function NotificationChannelsPage() {
}
const channels = channelsQuery.data?.items ?? [];
const isWebhookMode = form.mode === "wecom_webhook";
const isAppMessageMode = form.mode === "wecom_app_message";
return (
<div className="space-y-6">
<PageHeader
title="通知渠道"
subtitle="维护企微群机器人渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
subtitle="维护企微通知渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
actions={
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
新建渠道
@@ -523,7 +527,7 @@ export function NotificationChannelsPage() {
{editingId ? "编辑通知渠道" : "新建通知渠道"}
</h3>
<p className="text-sm text-zinc-500">
通过角色 / 业务域 / 通知类型三维控制 IM 群路由,避免默认全量广播。
通过角色 / 业务域 / 通知类型三维控制 IM 路由,避免默认全量广播。
</p>
</div>
{editingId ? (
@@ -543,7 +547,7 @@ export function NotificationChannelsPage() {
disabled={Boolean(editingId)}
onChange={(event) => setForm((prev) => ({ ...prev, channelType: event.target.value as "wecom" }))}
>
<option value="wecom">企微群机器人</option>
<option value="wecom">企微</option>
</select>
</div>
@@ -572,7 +576,11 @@ export function NotificationChannelsPage() {
>
<option value="mock">Mock</option>
<option value="wecom_webhook">企微 Webhook</option>
<option value="wecom_app_message">企微应用消息(个人)</option>
</select>
{isAppMessageMode ? (
<p className="text-xs text-zinc-500">使用企业微信自建应用给已绑定成员发送个人消息。</p>
) : null}
</div>
<div className="space-y-2">
@@ -586,90 +594,100 @@ export function NotificationChannelsPage() {
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="webhookUrl">Webhook URL</Label>
<Input
id="webhookUrl"
value={form.webhookUrl}
placeholder={form.mode === "mock" ? "Mock 模式可留空" : "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?..."}
onChange={(event) => setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))}
/>
</div>
<div className="space-y-2 md:col-span-2">
<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>
{isWebhookMode ? (
<>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="webhookUrl">Webhook URL</Label>
<Input
id="webhookUrl"
value={form.webhookUrl}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?..."
onChange={(event) => setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))}
/>
</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}
<div className="space-y-2 md:col-span-2">
<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 === "clear" ? (
<p className="text-xs text-amber-600">保存后将清空当前 Webhook Secret。</p>
) : null}
</div>
{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 className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600 md:col-span-2">
{isAppMessageMode
? "个人应用消息使用现有企业微信应用配置和成员绑定,不需要填写 Webhook URL 或 Secret。"
: "Mock 模式只记录本地投递结果,不需要填写 Webhook URL 或 Secret。"}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">