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(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 = { 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 (
{/* Card Header */}
Webhook 通知 发布事件回调第三方(HMAC-SHA256 签名)
{/* Body */}
{showForm && (

{editingId != null ? '编辑 Webhook' : '新建 Webhook'}

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)' }} />
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)' }} />
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 && (

已保存密钥不可读取;填写新值将覆盖,留空则不修改

)}
{eventOptions.map(evt => { const on = form.events.includes(evt.value); return ( ); })}
)} {isLoading ? (
正在加载 Webhook 列表...
) : webhooks.length === 0 ? (
暂无 Webhook,点击右上角「新建 Webhook」创建
) : (
{webhooks.map((wh: WebhookItem) => (
{wh.name} {wh.active ? '已启用' : '已禁用'} {wh.secretSet && ( 已签名 )}
{wh.url}
{parseEvents(wh.events).map(evt => { const opt = eventOptions.find(o => o.value === evt); return ( {opt?.label || evt} ); })} {parseEvents(wh.events).length === 0 && ( 未订阅任何事件 )}
最后触发: {formatTime(wh.lastTriggered)} {wh.lastStatus !== 'none' && ( 状态: {wh.lastStatus === 'success' ? : } {wh.lastStatus === 'success' ? '成功' : '失败'} )}
))}
)}
); }