Files
opsxsql/frontend/src/pages/NotificationPage.tsx
T
logan bab55d3927 feat: 审核规则模版+设置页面整合+去掉角色切换demo
后端:
- AuditRule 新增 value 字段, rule_type 扩展 DDL/DML
- MySQL/PG/Redis 预设规则种子接口 POST /audit-rules/seed/{db_type}
- 公告管理 CRUD (announcements)
- 消息推送 CRUD+测试 (notifications: 钉钉/飞书/邮件)
- LDAP 配置 CRUD+测试连接 (ldap-config)
- 164 tests passing (+31 新增)

前端:
- AuditRulesPage 重写: DB类型Tab+DDL/DML分组+参数编辑
- SettingsPage: 系统设置主页(审核规则/公告/推送/LDAP)
- NotificationPage: 钉钉/飞书/SMTP配置
- LdapConfigPage: LDAP设置+测试连接
- AnnouncementPage: 公告Markdown编辑
- 去掉左下角角色切换demo
- 侧边栏合并为: 系统设置(含审核规则+公告+推送+LDAP)
2026-05-22 22:26:10 +08:00

457 lines
19 KiB
TypeScript

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<string, any>;
is_enabled: boolean;
}
const CHANNEL_LABELS: Record<ChannelType, string> = {
dingtalk: '钉钉',
webhook_feishu: '飞书',
email: '邮件',
};
const CHANNEL_ICONS: Record<ChannelType, React.ElementType> = {
dingtalk: Bell,
webhook_feishu: Send,
email: Mail,
};
interface FormState {
name: string;
channel_type: ChannelType;
config: Record<string, any>;
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<ChannelType, () => Record<string, any>> = {
dingtalk: dingtalkDefault,
webhook_feishu: feishuDefault,
email: emailDefault,
};
export default function NotificationPage() {
const [channels, setChannels] = useState<NotifChannel[]>([]);
const [loading, setLoading] = useState(false);
/* modal */
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(defaultForm());
const [saving, setSaving] = useState(false);
/* test */
const [testingId, setTestingId] = useState<number | null>(null);
const [testResult, setTestResult] = useState<Record<number, { success: boolean; message: string } | null>>({});
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 (
<>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook URL <span className="text-red-400">*</span></label>
<input
type="text"
value={form.config.webhook_url || ''}
onChange={e => 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=..."
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">签名密钥 (Secret)</label>
<input
type="text"
value={form.config.secret || ''}
onChange={e => 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="可选,加签密钥"
/>
</div>
</>
);
}
if (t === 'webhook_feishu') {
return (
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook URL <span className="text-red-400">*</span></label>
<input
type="text"
value={form.config.webhook_url || ''}
onChange={e => 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/..."
/>
</div>
);
}
/* email */
return (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 服务器 <span className="text-red-400">*</span></label>
<input
type="text"
value={form.config.smtp_host || ''}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">端口</label>
<input
type="number"
value={form.config.smtp_port || 465}
onChange={e => 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)]"
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 用户 <span className="text-red-400">*</span></label>
<input
type="text"
value={form.config.smtp_user || ''}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 密码</label>
<input
type="password"
value={form.config.smtp_password || ''}
onChange={e => 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="••••••••"
/>
</div>
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">发件地址 <span className="text-red-400">*</span></label>
<input
type="text"
value={form.config.from_addr || ''}
onChange={e => 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"
/>
</div>
<div className="flex items-center gap-3 pt-5">
<button
type="button"
onClick={() => updateConfig('use_tls', !form.config.use_tls)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
form.config.use_tls ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
form.config.use_tls ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-sm text-[var(--dg-text-secondary)]">启用 TLS</span>
</div>
</div>
);
};
/* ── render ── */
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">消息推送</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理钉钉、飞书、邮件等通知渠道</p>
</div>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" /> 添加渠道
</button>
</div>
{/* List */}
{loading ? (
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
) : channels.length === 0 ? (
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">暂无通知渠道,点击右上角添加</div>
) : (
<div className="space-y-3">
{channels.map(ch => {
const Icon = CHANNEL_ICONS[ch.channel_type] || Settings;
const result = testResult[ch.id];
return (
<div key={ch.id} className={`dg-card px-5 py-4 flex items-center gap-4 ${!ch.is_enabled ? 'opacity-50' : ''}`}>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${ch.is_enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
<Icon className={`w-5 h-5 ${ch.is_enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">{ch.name}</h3>
<span className="text-[11px] px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400">
{CHANNEL_LABELS[ch.channel_type] || ch.channel_type}
</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${ch.is_enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
{ch.is_enabled ? '已启用' : '已禁用'}
</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)] truncate">
{ch.channel_type === 'email'
? `${ch.config.smtp_host}:${ch.config.smtp_port} / ${ch.config.from_addr}`
: ch.config.webhook_url || '未配置'}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleToggle(ch)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
ch.is_enabled
? 'bg-yellow-500/15 text-yellow-400 hover:bg-yellow-500/25'
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
}`}
>
{ch.is_enabled ? '禁用' : '启用'}
</button>
<button
onClick={() => handleTest(ch.id)}
disabled={testingId === ch.id}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
result?.success
? 'bg-green-500/15 text-green-400'
: result?.success === false
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} disabled:opacity-50`}
>
{testingId === ch.id ? '发送中…' : result?.success ? '✓ 成功' : result?.success === false ? '✗ 失败' : '测试'}
</button>
<button
onClick={() => openEdit(ch)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
title="编辑"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(ch.id)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
{/* ── Create / Edit Modal ── */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowModal(false)}>
<div
className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-[var(--dg-text-primary)]">{editingId ? '编辑渠道' : '添加渠道'}</h3>
<button onClick={() => setShowModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">渠道名称 <span className="text-red-400">*</span></label>
<input
type="text"
value={form.name}
onChange={e => 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="例如:运维群钉钉机器人"
/>
</div>
{!editingId && (
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">渠道类型</label>
<div className="dg-card p-1.5 flex gap-1">
{(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => (
<button
key={key}
onClick={() => handleTypeChange(key)}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
form.channel_type === key
? 'bg-[var(--dg-blue)] text-white'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
<div className="pt-2 border-t border-[var(--dg-border)]">
<h4 className="text-xs font-semibold text-[var(--dg-text-secondary)] mb-3">渠道配置</h4>
{renderConfigFields()}
</div>
<div className="flex items-center gap-3 pt-2 border-t border-[var(--dg-border)]">
<button
type="button"
onClick={() => setForm(prev => ({ ...prev, is_enabled: !prev.is_enabled }))}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
form.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
form.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
<span className="text-sm text-[var(--dg-text-secondary)]">启用此渠道</span>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
<button
onClick={() => setShowModal(false)}
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
取消
</button>
<button
onClick={handleSave}
disabled={!form.name.trim() || saving}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? '保存中…' : editingId ? '保存修改' : '添加'}
</button>
</div>
</div>
</div>
)}
</div>
);
}