import { useState, useEffect, useCallback } from 'react'; import request from '@/api/request'; import { Plus, Trash2, Send, Bell, Mail, Settings, X } from 'lucide-react'; /* ── types ── */ type ChannelType = 'dingtalk' | 'webhook_feishu' | 'email'; interface NotifChannel { id: number; name: string; channel_type: ChannelType; config: Record; is_enabled: boolean; } const CHANNEL_LABELS: Record = { dingtalk: '钉钉', webhook_feishu: '飞书', email: '邮件', }; const CHANNEL_ICONS: Record = { dingtalk: Bell, webhook_feishu: Send, email: Mail, }; interface FormState { name: string; channel_type: ChannelType; config: Record; is_enabled: boolean; } const defaultForm = (): FormState => ({ name: '', channel_type: 'dingtalk', config: { webhook_url: '' }, is_enabled: true, }); const dingtalkDefault = () => ({ webhook_url: '', secret: '' }); const feishuDefault = () => ({ webhook_url: '' }); const emailDefault = () => ({ smtp_host: '', smtp_port: 465, smtp_user: '', smtp_password: '', from_addr: '', use_tls: true }); const configDefaults: Record Record> = { dingtalk: dingtalkDefault, webhook_feishu: feishuDefault, email: emailDefault, }; export default function NotificationPage() { const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(false); /* modal */ const [showModal, setShowModal] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(defaultForm()); const [saving, setSaving] = useState(false); /* test */ const [testingId, setTestingId] = useState(null); const [testResult, setTestResult] = useState>({}); const fetchList = useCallback(async () => { try { setLoading(true); const res = await request.get('/notifications'); setChannels(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []); } catch { setChannels([]); } finally { setLoading(false); } }, []); useEffect(() => { fetchList(); }, [fetchList]); /* ── helpers ── */ const openCreate = () => { setEditingId(null); setForm(defaultForm()); setShowModal(true); }; const openEdit = (ch: NotifChannel) => { setEditingId(ch.id); setForm({ name: ch.name, channel_type: ch.channel_type, config: { ...ch.config }, is_enabled: ch.is_enabled }); setShowModal(true); }; const handleTypeChange = (t: ChannelType) => { setForm(prev => ({ ...prev, channel_type: t, config: configDefaults[t]() })); }; const updateConfig = (key: string, value: any) => { setForm(prev => ({ ...prev, config: { ...prev.config, [key]: value } })); }; const handleSave = async () => { if (!form.name.trim()) return; try { setSaving(true); if (editingId) { await request.put(`/notifications/${editingId}`, form); } else { await request.post('/notifications', form); } setShowModal(false); fetchList(); } catch (err: any) { alert(err.response?.data?.detail || '保存失败'); } finally { setSaving(false); } }; const handleToggle = async (ch: NotifChannel) => { try { await request.put(`/notifications/${ch.id}`, { is_enabled: !ch.is_enabled }); fetchList(); } catch { /* ignore */ } }; const handleDelete = async (id: number) => { if (!confirm('确定删除此通知渠道?')) return; try { await request.delete(`/notifications/${id}`); fetchList(); } catch { /* ignore */ } }; const handleTest = async (id: number) => { try { setTestingId(id); setTestResult(prev => ({ ...prev, [id]: null })); const res = await request.post(`/notifications/${id}/test`); const data = (res as any).data ?? res; setTestResult(prev => ({ ...prev, [id]: { success: true, message: data.message || '测试成功' } })); } catch (err: any) { setTestResult(prev => ({ ...prev, [id]: { success: false, message: err.response?.data?.detail || '测试失败' }, })); } finally { setTestingId(null); setTimeout(() => setTestResult(prev => ({ ...prev, [id]: null })), 5000); } }; /* ── config form renderer ── */ const renderConfigFields = () => { const t = form.channel_type; if (t === 'dingtalk') { return ( <>
updateConfig('webhook_url', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." />
updateConfig('secret', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="可选,加签密钥" />
); } if (t === 'webhook_feishu') { return (
updateConfig('webhook_url', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." />
); } /* email */ return (
updateConfig('smtp_host', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="smtp.example.com" />
updateConfig('smtp_port', parseInt(e.target.value) || 465)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" />
updateConfig('smtp_user', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="noreply@example.com" />
updateConfig('smtp_password', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="••••••••" />
updateConfig('from_addr', e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="noreply@example.com" />
启用 TLS
); }; /* ── render ── */ return (
{/* Header */}

消息推送

管理钉钉、飞书、邮件等通知渠道

{/* List */} {loading ? (
加载中…
) : channels.length === 0 ? (
暂无通知渠道,点击右上角添加
) : (
{channels.map(ch => { const Icon = CHANNEL_ICONS[ch.channel_type] || Settings; const result = testResult[ch.id]; return (

{ch.name}

{CHANNEL_LABELS[ch.channel_type] || ch.channel_type} {ch.is_enabled ? '已启用' : '已禁用'}

{ch.channel_type === 'email' ? `${ch.config.smtp_host}:${ch.config.smtp_port} / ${ch.config.from_addr}` : ch.config.webhook_url || '未配置'}

); })}
)} {/* ── Create / Edit Modal ── */} {showModal && (
setShowModal(false)}>
e.stopPropagation()} >

{editingId ? '编辑渠道' : '添加渠道'}

setForm(prev => ({ ...prev, name: e.target.value }))} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]" placeholder="例如:运维群钉钉机器人" />
{!editingId && (
{(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => ( ))}
)}

渠道配置

{renderConfigFields()}
启用此渠道
)}
); }