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)
This commit is contained in:
1 parent
d5d19d6be4
commit
bab55d3927
27 files changed
+2881
-591
No files matched your search
@@ -0,0 +1,456 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user