refactor: Webhook 移入系统设置「消息通知」Tab,取消独立菜单
- WebhookConfig 页面转为可嵌入卡片组件 WebhookManager (标准设置卡样式,逻辑不变),渲染于飞书通知卡下方 - 移除侧边栏「Webhook」菜单项、/webhooks 路由与页面文件 (旧地址自动回退流水线页) - 使用指南/接口文档同步更新 Webhook 配置入口描述
This commit is contained in:
1 parent
ed88a18e03
commit
695c1c2357
6 files changed
+22
-23
No files matched your search
@@ -0,0 +1,299 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
|
||||
CheckCircle2, AlertTriangle, Send, Loader2, Lock
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 与后端 model.WebhookEndpoint 对齐(events 为 JSON 字符串,secret 只写不读)
|
||||
interface WebhookItem {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
secretSet: boolean;
|
||||
events: string;
|
||||
active: boolean;
|
||||
lastTriggered: string | null;
|
||||
lastStatus: 'success' | 'failed' | 'none';
|
||||
}
|
||||
|
||||
const eventOptions = [
|
||||
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
|
||||
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
|
||||
{ value: 'pipeline.failed', label: '流水线失败', desc: '当流水线执行失败时触发' },
|
||||
{ value: 'pipeline.cancelled', label: '流水线取消', desc: '当流水线被取消时触发' },
|
||||
{ value: 'stage.started', label: '阶段开始', desc: '当某个阶段开始执行时触发' },
|
||||
{ value: 'stage.completed', label: '阶段完成', desc: '当某个阶段执行完成时触发' },
|
||||
{ value: 'deploy.success', label: '部署成功', desc: '当部署阶段成功时触发' },
|
||||
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
|
||||
];
|
||||
|
||||
const parseEvents = (raw: string): string[] => {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter(e => typeof e === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (v: string | null | undefined) => {
|
||||
if (!v) return '从未触发';
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return '从未触发';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
const emptyForm = { name: '', url: '', secret: '', events: [] as string[] };
|
||||
|
||||
export default function WebhookManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingSecretSet, setEditingSecretSet] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const { data: webhooks = [], isLoading } = useQuery({ queryKey: ['webhooks'], queryFn: apiClient.listWebhooks });
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
|
||||
|
||||
// 新建 / 编辑(secret 留空表示不修改)
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (input: { editingId: number | null; form: typeof emptyForm }) => {
|
||||
const body: Record<string, any> = { name: input.form.name, url: input.form.url, events: input.form.events };
|
||||
if (input.form.secret) body.secret = input.form.secret;
|
||||
return input.editingId != null
|
||||
? apiClient.updateWebhook(input.editingId, body)
|
||||
: apiClient.createWebhook(body);
|
||||
},
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(input.editingId != null ? 'Webhook 已更新' : 'Webhook 已创建');
|
||||
invalidate();
|
||||
closeForm();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => apiClient.deleteWebhook(id),
|
||||
onSuccess: () => { toast.success('Webhook 已删除'); invalidate(); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (wh: WebhookItem) => apiClient.updateWebhook(wh.id, { active: !wh.active }),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// 测试发送:真实 HTTP POST + HMAC-SHA256 签名
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: number) => apiClient.testWebhook(id),
|
||||
onSuccess: (data: any) => {
|
||||
invalidate();
|
||||
if (data?.success) {
|
||||
toast.success('测试发送成功', { description: data.message || `响应 ${data.statusCode}` });
|
||||
} else {
|
||||
toast.error('测试发送失败', { description: data?.message || '目标地址返回异常' });
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error('测试发送失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setEditingSecretSet(false);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('请填写名称和 URL');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate({ editingId, form });
|
||||
};
|
||||
|
||||
const startEdit = (wh: WebhookItem) => {
|
||||
setForm({ name: wh.name, url: wh.url, secret: '', events: parseEvents(wh.events) });
|
||||
setEditingId(wh.id);
|
||||
setEditingSecretSet(wh.secretSet);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = (wh: WebhookItem) => {
|
||||
if (!window.confirm(`确定删除 Webhook「${wh.name}」?`)) return;
|
||||
deleteMutation.mutate(wh.id);
|
||||
};
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
events: prev.events.includes(event) ? prev.events.filter(e => e !== event) : [...prev.events, event],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{/* Card Header */}
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>Webhook 通知</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>发布事件回调第三方(HMAC-SHA256 签名)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowForm(true); setEditingId(null); setEditingSecretSet(false); setForm(emptyForm); }}
|
||||
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />新建 Webhook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5">
|
||||
{showForm && (
|
||||
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId != null ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
|
||||
<input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="如:飞书群通知" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>Webhook URL</label>
|
||||
<input value={form.url} onChange={e => setForm(p => ({ ...p, url: e.target.value }))}
|
||||
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选,只写不读)</label>
|
||||
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
|
||||
placeholder={editingId != null && editingSecretSet ? '已设置密钥,留空保持不变' : '用于 HMAC-SHA256 签名验证,留空则不签名'}
|
||||
className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
{editingId != null && editingSecretSet && (
|
||||
<p className="text-[11px] mt-1 flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Lock className="w-3 h-3" />已保存密钥不可读取;填写新值将覆盖,留空则不修改
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件(点击选择)</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{eventOptions.map(evt => {
|
||||
const on = form.events.includes(evt.value);
|
||||
return (
|
||||
<button key={evt.value} type="button" title={evt.desc} onClick={() => toggleEvent(evt.value)}
|
||||
className="text-[11px] px-2.5 py-[4px] rounded-full border font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: on ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
background: on ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: on ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}>
|
||||
{evt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={closeForm} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
||||
<button onClick={handleSave} disabled={saveMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||
{saveMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />正在加载 Webhook 列表...
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
暂无 Webhook,点击右上角「新建 Webhook」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((wh: WebhookItem) => (
|
||||
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
|
||||
{wh.active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
{wh.secretSet && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<Lock className="w-2.5 h-2.5" />已签名
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[12px] font-mono mb-2 truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Webhook className="w-3 h-3 flex-shrink-0" /> {wh.url}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{parseEvents(wh.events).map(evt => {
|
||||
const opt = eventOptions.find(o => o.value === evt);
|
||||
return (
|
||||
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
{opt?.label || evt}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{parseEvents(wh.events).length === 0 && (
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>未订阅任何事件</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => testMutation.mutate(wh.id)}
|
||||
disabled={testMutation.isPending && testMutation.variables === wh.id}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="测试发送">
|
||||
{testMutation.isPending && testMutation.variables === wh.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Send className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => startEdit(wh)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => toggleMutation.mutate(wh)} disabled={toggleMutation.isPending}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
|
||||
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(wh)} disabled={deleteMutation.isPending}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="删除">
|
||||
{deleteMutation.isPending && deleteMutation.variables === wh.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
|
||||
<span>最后触发: {formatTime(wh.lastTriggered)}</span>
|
||||
{wh.lastStatus !== 'none' && (
|
||||
<span className="flex items-center gap-1" style={{ color: wh.lastStatus === 'success' ? '#1a7f37' : '#cf222e' }}>
|
||||
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
|
||||
{wh.lastStatus === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user