Files
opsxsql/frontend/src/pages/NotificationsPage.tsx
T
logan 4351421848 fix: ConfirmDialog确认后弹框不关闭(9页面) + 登录页顶部渐变线移除
- 9个页面 onConfirm 回调添加 setDeleteConfirm(null) 关闭弹框
  - InstancePage/InstanceDetailPage/InstanceGroupPage/RedisDetailPage
  - UsersPage/AnnouncementPage/NotificationPage/NotificationsPage/WorkflowPage
- 白日梦境(NeonCityView) + 云石纹理(GradientMeshView) 移除顶部蓝色渐变线
2026-05-27 22:10:50 +08:00

188 lines
8.4 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Bell, Webhook, Check, Settings, TestTube, ToggleLeft, ToggleRight, Loader2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { notificationApi } from '@/api/notification';
import { ConfirmDialog } from '@/components/ConfirmDialog';
interface NotificationChannel {
id: number;
name: string;
channel_type: string;
config: Record<string, any>;
is_enabled: boolean;
created_at: string;
updated_at: string;
}
const channelStyle: Record<string, { bg: string; color: string; label: string }> = {
webhook_feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]', label: '飞书' },
dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]', label: '钉钉' },
email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]', label: '邮件' },
};
export default function NotificationsPage() {
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ id: number; success: boolean; message: string } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
const fetchChannels = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await notificationApi.list();
setChannels(data);
} catch (err: any) {
setError(err?.response?.data?.detail || '获取通知渠道失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const toggleChannel = useCallback(async (id: number) => {
const ch = channels.find(c => c.id === id);
if (!ch) return;
try {
await notificationApi.update(id, { is_enabled: !ch.is_enabled });
setChannels(prev => prev.map(c => c.id === id ? { ...c, is_enabled: !c.is_enabled } : c));
} catch {}
}, [channels]);
const handleTest = useCallback(async (id: number) => {
setTestingId(id);
setTestResult(null);
try {
const res = await notificationApi.test(id);
setTestResult({ id, success: res.success, message: res.message });
} catch (err: any) {
setTestResult({ id, success: false, message: err?.response?.data?.detail || '测试失败' });
} finally {
setTestingId(null);
setTimeout(() => setTestResult(null), 5000);
}
}, []);
const handleDelete = useCallback(async (id: number) => {
setDeleteConfirm({msg: '确认删除该通知渠道?', onConfirm: async () => { try { await notificationApi.delete(id); setChannels(prev => prev.filter(c => c.id !== id)); } catch {} }});
return;
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<AlertCircle className="w-12 h-12 mb-3 text-red-400" />
<p className="text-sm">{error}</p>
<button onClick={fetchChannels} className="mt-3 px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors">
重试
</button>
</div>
);
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">通知渠道配置</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、邮件</p>
</div>
</div>
{channels.length === 0 ? (
<div className="dg-card p-12 text-center">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
<p className="text-sm text-[var(--dg-text-muted)]">暂无通知渠道,请联系管理员配置</p>
</div>
) : (
<>
<div className="grid grid-cols-5 gap-4">
{[
{ label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: channels.filter(c => c.is_enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '未启用', value: channels.filter(c => !c.is_enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-2 gap-4">
{channels.map(channel => {
const style = channelStyle[channel.channel_type] || { bg: 'bg-gray-400/10', color: 'text-gray-400', label: channel.channel_type };
return (
<div key={channel.id} className={`dg-card p-5 transition-all ${channel.is_enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
<Webhook className={`w-6 h-6 ${style.color}`} />
</div>
<div>
<div className="text-base font-semibold">{channel.name}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{style.label}
{channel.config?.webhook_url && <span className="ml-2 opacity-60">{channel.config.webhook_url.substring(0, 40)}...</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(channel.id)}
disabled={testingId === channel.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors disabled:opacity-50"
title="测试"
>
{testingId === channel.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <TestTube className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(channel.id)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
<button onClick={() => toggleChannel(channel.id)} className="transition-colors">
{channel.is_enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
</button>
</div>
</div>
{testResult && testResult.id === channel.id && (
<div className={`mt-3 px-3 py-2 rounded-lg text-xs ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.message}
</div>
)}
</div>
);
})}
</div>
</>
)}
<ConfirmDialog
open={!!deleteConfirm}
title={deleteConfirm?.msg || '确认操作'}
variant="danger"
confirmText="确定"
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}