后端:
- 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)
457 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|