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
@@ -1,350 +1,344 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Megaphone, Plus, Edit2, Trash2, Pin, PinOff, Eye, Clock,
|
||||
CheckCircle, X, Save, AlertTriangle, Send, Calendar
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import request from '@/api/request';
|
||||
import { Megaphone, Plus, Edit2, Trash2, Eye, Clock, CheckCircle, X, Save } from 'lucide-react';
|
||||
|
||||
type AnnouncementPriority = 'urgent' | 'high' | 'normal' | 'low';
|
||||
|
||||
type Announcement = {
|
||||
id: string;
|
||||
/* ── types ── */
|
||||
interface Announcement {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: AnnouncementPriority;
|
||||
pinned: boolean;
|
||||
enabled: boolean;
|
||||
author: string;
|
||||
publishTime: string;
|
||||
expireTime: string;
|
||||
readCount: number;
|
||||
targetRoles: string[];
|
||||
};
|
||||
is_published: boolean;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const priorityLabels: Record<AnnouncementPriority, string> = {
|
||||
urgent: '紧急',
|
||||
high: '重要',
|
||||
normal: '普通',
|
||||
low: '低',
|
||||
};
|
||||
|
||||
const priorityColors: Record<AnnouncementPriority, string> = {
|
||||
urgent: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
high: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
normal: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
low: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ id: 'admin', label: '超级管理员' },
|
||||
{ id: 'dba', label: '数据库管理员' },
|
||||
{ id: 'developer', label: '开发人员' },
|
||||
{ id: 'guest', label: '访客' },
|
||||
];
|
||||
|
||||
const initialAnnouncements: Announcement[] = [
|
||||
{
|
||||
id: 'ann-1', title: '【系统维护】本周日凌晨 2:00-4:00 数据库升级维护',
|
||||
content: '计划于本周日(2024年1月21日)凌晨 2:00-4:00 进行数据库版本升级,期间以下服务将不可用:\n1. 数据变更工单提交和审批\n2. 在线 SQL 查询\n3. 数据导出功能\n\n请提前安排工作,避免在维护时段提交重要工单。如有紧急情况,请联系 DBA 团队。',
|
||||
priority: 'urgent', pinned: true, enabled: true, author: 'admin',
|
||||
publishTime: '2024-01-15 10:00:00', expireTime: '2024-01-22 00:00:00',
|
||||
readCount: 156, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
||||
},
|
||||
{
|
||||
id: 'ann-2', title: '【安全提醒】禁止使用 SELECT * 查询生产数据库',
|
||||
content: '为加强数据安全,即日起严禁使用 SELECT * 查询生产环境数据库。所有查询必须明确指定所需字段名。\n\n审核规则已更新(规则编号 R002),违规查询将被自动拦截并记录审计日志。',
|
||||
priority: 'high', pinned: true, enabled: true, author: 'admin',
|
||||
publishTime: '2024-01-14 09:00:00', expireTime: '2024-02-14 00:00:00',
|
||||
readCount: 234, targetRoles: ['admin', 'dba', 'developer'],
|
||||
},
|
||||
{
|
||||
id: 'ann-3', title: '【新功能】SQL 变更自动回滚功能已上线',
|
||||
content: 'SQL 变更工单现已支持自动回滚功能。对于 UPDATE 和 DELETE 操作,系统会自动:\n1. 基于 Binlog ROW 格式生成逆向回滚语句\n2. 执行前备份受影响数据\n3. 提供一键回滚入口(执行后 72 小时内有效)\n\n使用要求:\n- MySQL 必须开启 Binlog ROW 格式\n- 操作表必须有主键\n- 单次操作影响行数不超过 10,000 行',
|
||||
priority: 'normal', pinned: false, enabled: true, author: 'zhangsan',
|
||||
publishTime: '2024-01-13 14:00:00', expireTime: '2024-04-13 00:00:00',
|
||||
readCount: 89, targetRoles: ['admin', 'dba', 'developer'],
|
||||
},
|
||||
{
|
||||
id: 'ann-4', title: '【培训通知】SQL 审核规范培训 - 1月25日',
|
||||
content: '时间:2024年1月25日 14:00-16:00\n地点:会议室 A\n讲师:DBA 团队负责人\n\n培训内容:\n1. SQL 变更工单提交流程\n2. 审核规则详解\n3. 回滚操作最佳实践\n4. 常见问题排查\n\n请相关人员准时参加。',
|
||||
priority: 'normal', pinned: false, enabled: true, author: 'lisi',
|
||||
publishTime: '2024-01-12 10:00:00', expireTime: '2024-01-26 00:00:00',
|
||||
readCount: 67, targetRoles: ['developer'],
|
||||
},
|
||||
{
|
||||
id: 'ann-5', title: '【版本更新】SQL-Platform v2.5.0 更新日志',
|
||||
content: '本次更新内容:\n\n新增功能:\n- 支持多数据源统一管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题',
|
||||
priority: 'low', pinned: false, enabled: false, author: 'admin',
|
||||
publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00',
|
||||
readCount: 45, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
||||
},
|
||||
];
|
||||
/* ── simple markdown → html ── */
|
||||
function simpleMarkdown(md: string): string {
|
||||
let html = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// bold **text** or __text__
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
// italic *text* or _text_
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
// `code`
|
||||
html = html.replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-sm">$1</code>');
|
||||
// headings
|
||||
html = html.replace(/^### (.+)$/gm, '<h4 class="text-base font-semibold mt-3 mb-1">$1</h4>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h3 class="text-lg font-semibold mt-3 mb-1">$1</h3>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h2 class="text-xl font-bold mt-3 mb-1">$1</h2>');
|
||||
// unordered list items
|
||||
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>');
|
||||
html = html.replace(/^\* (.+)$/gm, '<li class="ml-4 list-disc">$1</li>');
|
||||
// numbered list
|
||||
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>');
|
||||
// horizontal rule
|
||||
html = html.replace(/^---$/gm, '<hr class="border-[var(--dg-border)] my-3" />');
|
||||
// line breaks
|
||||
html = html.replace(/\n/g, '<br />');
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function AnnouncementPage() {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>(initialAnnouncements);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingAnn, setEditingAnn] = useState<Announcement | null>(null);
|
||||
const [previewAnn, setPreviewAnn] = useState<Announcement | null>(null);
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 表单
|
||||
/* modal state */
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formTitle, setFormTitle] = useState('');
|
||||
const [formContent, setFormContent] = useState('');
|
||||
const [formPriority, setFormPriority] = useState<AnnouncementPriority>('normal');
|
||||
const [formPinned, setFormPinned] = useState(false);
|
||||
const [formTargetRoles, setFormTargetRoles] = useState<string[]>(['admin', 'dba', 'developer', 'guest']);
|
||||
const [formExpireTime, setFormExpireTime] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
/* view preview */
|
||||
const [viewAnn, setViewAnn] = useState<Announcement | null>(null);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await request.get('/announcements');
|
||||
setAnnouncements(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
|
||||
} catch {
|
||||
setAnnouncements([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
/* ── helpers ── */
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setFormTitle('');
|
||||
setFormContent('');
|
||||
setFormPriority('normal');
|
||||
setFormPinned(false);
|
||||
setFormTargetRoles(['admin', 'dba', 'developer', 'guest']);
|
||||
setFormExpireTime('');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm();
|
||||
setEditingAnn(null);
|
||||
setShowCreate(true);
|
||||
setShowPreview(false);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (ann: Announcement) => {
|
||||
setEditingId(ann.id);
|
||||
setFormTitle(ann.title);
|
||||
setFormContent(ann.content);
|
||||
setFormPriority(ann.priority);
|
||||
setFormPinned(ann.pinned);
|
||||
setFormTargetRoles([...ann.targetRoles]);
|
||||
setFormExpireTime(ann.expireTime);
|
||||
setEditingAnn(ann);
|
||||
setShowCreate(true);
|
||||
setShowPreview(false);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const toggleRole = (role: string) => {
|
||||
setFormTargetRoles(prev =>
|
||||
prev.includes(role) ? prev.filter(r => r !== role) : [...prev, role]
|
||||
);
|
||||
};
|
||||
|
||||
const saveAnnouncement = () => {
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formContent.trim()) return;
|
||||
const now = '2024-01-15 12:00:00';
|
||||
if (editingAnn) {
|
||||
setAnnouncements(prev => prev.map(a => a.id === editingAnn.id ? {
|
||||
...a,
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
priority: formPriority,
|
||||
pinned: formPinned,
|
||||
targetRoles: [...formTargetRoles],
|
||||
expireTime: formExpireTime || a.expireTime,
|
||||
} : a));
|
||||
} else {
|
||||
const newAnn: Announcement = {
|
||||
id: `ann-${Date.now()}`,
|
||||
title: formTitle,
|
||||
content: formContent,
|
||||
priority: formPriority,
|
||||
pinned: formPinned,
|
||||
enabled: true,
|
||||
author: 'admin',
|
||||
publishTime: now,
|
||||
expireTime: formExpireTime || '2024-12-31 00:00:00',
|
||||
readCount: 0,
|
||||
targetRoles: [...formTargetRoles],
|
||||
};
|
||||
setAnnouncements(prev => [newAnn, ...prev]);
|
||||
try {
|
||||
setSaving(true);
|
||||
if (editingId) {
|
||||
await request.put(`/announcements/${editingId}`, { title: formTitle, content: formContent });
|
||||
} else {
|
||||
await request.post('/announcements', { title: formTitle, content: formContent });
|
||||
}
|
||||
setShowModal(false);
|
||||
fetchList();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setShowCreate(false);
|
||||
};
|
||||
|
||||
const togglePin = (id: string) => {
|
||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, pinned: !a.pinned } : a));
|
||||
const handleTogglePublish = async (ann: Announcement) => {
|
||||
try {
|
||||
await request.put(`/announcements/${ann.id}`, { is_published: !ann.is_published });
|
||||
fetchList();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnable = (id: string) => {
|
||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, enabled: !a.enabled } : a));
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此公告?')) return;
|
||||
try {
|
||||
await request.delete(`/announcements/${id}`);
|
||||
fetchList();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnouncement = (id: string) => {
|
||||
setAnnouncements(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const sortedAnnouncements = [...announcements].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
||||
});
|
||||
|
||||
/* ── render ── */
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">公告管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">发布和管理系统公告,支持 Markdown 格式</p>
|
||||
</div>
|
||||
<button onClick={openCreate} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
<Plus className="w-4 h-4" /> 发布公告
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: '公告总数', value: announcements.length, icon: Megaphone, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已置顶', value: announcements.filter(a => a.pinned).length, icon: Pin, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ label: '生效中', value: announcements.filter(a => a.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '已失效', value: announcements.filter(a => !a.enabled).length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
{ label: '公告总数', value: announcements.length, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已发布', value: announcements.filter(a => a.is_published).length, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '草稿', value: announcements.filter(a => !a.is_published).length, color: 'text-yellow-400', bg: 'bg-yellow-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 key={i} className="dg-card p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||
<Megaphone className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{s.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedAnnouncements.map(ann => (
|
||||
<Card key={ann.id} className={`dg-card ${!ann.enabled ? 'opacity-50' : ''} ${ann.pinned ? 'border-l-2 border-l-purple-500' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{ann.pinned && <Pin className="w-4 h-4 text-purple-400" />}
|
||||
<h3 className="font-semibold text-sm">{ann.title}</h3>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[ann.priority]}`}>
|
||||
{priorityLabels[ann.priority]}
|
||||
</span>
|
||||
{!ann.enabled && <span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">已失效</span>}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mb-3">{ann.content.replace(/\n/g, ' ')}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {ann.author}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.publishTime}</span>
|
||||
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> 到期: {ann.expireTime}</span>
|
||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {ann.readCount} 次阅读</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>可见范围:</span>
|
||||
{ann.targetRoles.map(r => (
|
||||
<span key={r} className="text-[10px] px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">
|
||||
{r === 'admin' ? '管理员' : r === 'dba' ? 'DBA' : r === 'developer' ? '开发' : '访客'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
|
||||
) : announcements.length === 0 ? (
|
||||
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">暂无公告,点击右上角创建</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{announcements.map(ann => (
|
||||
<div key={ann.id} className={`dg-card px-5 py-4 flex items-center gap-4 ${!ann.is_published ? 'opacity-60' : ''}`}>
|
||||
<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)] truncate">{ann.title}</h3>
|
||||
<span
|
||||
className={`text-[11px] px-2 py-0.5 rounded-full flex-shrink-0 ${
|
||||
ann.is_published
|
||||
? 'bg-green-500/15 text-green-400'
|
||||
: 'bg-yellow-500/15 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{ann.is_published ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<button onClick={() => togglePin(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-purple-500/15 hover:text-purple-400 transition-colors" title={ann.pinned ? '取消置顶' : '置顶'}>
|
||||
{ann.pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => setPreviewAnn(ann)} 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="预览">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(ann)} 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="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => toggleEnable(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={ann.enabled ? '失效' : '生效'}>
|
||||
{ann.enabled ? <CheckCircle className="w-4 h-4 text-green-400" /> : <AlertTriangle className="w-4 h-4 text-yellow-400" />}
|
||||
</button>
|
||||
<button onClick={() => deleteAnnouncement(ann.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>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] truncate">{ann.content.replace(/\n/g, ' ')}</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.created_at}</span>
|
||||
{ann.published_at && (
|
||||
<span className="flex items-center gap-1"><CheckCircle className="w-3 h-3" /> 发布于 {ann.published_at}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setViewAnn(ann)}
|
||||
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="预览"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(ann)}
|
||||
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="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTogglePublish(ann)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
ann.is_published
|
||||
? 'bg-yellow-500/15 text-yellow-400 hover:bg-yellow-500/25'
|
||||
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
|
||||
}`}
|
||||
>
|
||||
{ann.is_published ? '取消发布' : '发布'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(ann.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>
|
||||
)}
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
{previewAnn && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewAnn(null)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
{/* ── View Preview Modal ── */}
|
||||
{viewAnn && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setViewAnn(null)}>
|
||||
<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-[85vh] overflow-y-auto"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">{previewAnn.title}</h3>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[previewAnn.priority]}`}>
|
||||
{priorityLabels[previewAnn.priority]}
|
||||
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{viewAnn.title}</h3>
|
||||
<span
|
||||
className={`text-[11px] px-2 py-0.5 rounded-full ${
|
||||
viewAnn.is_published ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{viewAnn.is_published ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setPreviewAnn(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<button onClick={() => setViewAnn(null)} 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="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
|
||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {previewAnn.author}</span>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {previewAnn.publishTime}</span>
|
||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {previewAnn.readCount} 次阅读</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-line leading-relaxed max-h-96 overflow-y-auto">
|
||||
{previewAnn.content}
|
||||
</div>
|
||||
<div className="flex justify-end mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={() => setPreviewAnn(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">关闭</button>
|
||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {viewAnn.created_at}</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-[var(--dg-text-secondary)] leading-relaxed prose-sm"
|
||||
dangerouslySetInnerHTML={{ __html: simpleMarkdown(viewAnn.content) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(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()}>
|
||||
{/* ── 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-3xl 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">{editingAnn ? '编辑公告' : '发布公告'}</h3>
|
||||
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<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={formTitle} onChange={e => setFormTitle(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="输入公告标题" />
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">
|
||||
标题 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={e => setFormTitle(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.5 block">公告内容 <span className="text-red-400">*</span></label>
|
||||
<textarea value={formContent} onChange={e => setFormContent(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={8} placeholder="输入公告内容,支持多行文本..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">优先级</label>
|
||||
<select value={formPriority} onChange={e => setFormPriority(e.target.value as AnnouncementPriority)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
||||
{Object.entries(priorityLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">到期时间</label>
|
||||
<input type="datetime-local" value={formExpireTime} onChange={e => setFormExpireTime(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formPinned} onCheckedChange={setFormPinned} />
|
||||
<span className="text-sm">置顶公告</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">可见角色</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{roleOptions.map(role => (
|
||||
<button
|
||||
key={role.id}
|
||||
onClick={() => toggleRole(role.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
|
||||
formTargetRoles.includes(role.id)
|
||||
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'
|
||||
}`}
|
||||
>
|
||||
{role.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-xs text-[var(--dg-text-muted)]">
|
||||
内容 <span className="text-red-400">*</span> <span className="text-[var(--dg-text-muted)]">(支持 Markdown)</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
showPreview
|
||||
? 'bg-[var(--dg-blue)] text-white'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
{showPreview ? '编辑' : '预览'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<div
|
||||
className="w-full min-h-[200px] px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] leading-relaxed overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: simpleMarkdown(formContent) }}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={e => setFormContent(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)] resize-none font-mono"
|
||||
rows={12}
|
||||
placeholder="输入公告内容,支持 Markdown 语法... **粗体** *斜体* `代码` ## 二级标题 - 列表项 1. 有序列表"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={() => setShowCreate(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={saveAnnouncement} disabled={!formTitle.trim() || !formContent.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Save className="w-4 h-4" /> {editingAnn ? '保存修改' : '发布公告'}
|
||||
<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={!formTitle.trim() || !formContent.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"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {saving ? '保存中…' : editingId ? '保存修改' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import request from '../api/request';
|
||||
|
||||
interface AuditRule {
|
||||
@@ -11,278 +11,237 @@ interface AuditRule {
|
||||
pattern: string | null;
|
||||
is_enabled: boolean;
|
||||
message: string;
|
||||
value: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const RULE_TYPES = [
|
||||
{ value: 'syntax', label: '语法检查', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'performance', label: '性能优化', color: 'bg-yellow-100 text-yellow-700' },
|
||||
{ value: 'safety', label: '安全检查', color: 'bg-red-100 text-red-700' },
|
||||
{ value: 'naming', label: '命名规范', color: 'bg-green-100 text-green-700' },
|
||||
];
|
||||
const DB_TABS = [
|
||||
{ key: 'mysql', label: 'MySQL', icon: '🐬' },
|
||||
{ key: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
|
||||
{ key: 'redis', label: 'Redis', icon: '🔴' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; color: string }> = {
|
||||
error: { label: '错误', color: 'bg-red-500 text-white' },
|
||||
warning: { label: '警告', color: 'bg-yellow-500 text-white' },
|
||||
info: { label: '提示', color: 'bg-blue-500 text-white' },
|
||||
};
|
||||
type DbType = (typeof DB_TABS)[number]['key'];
|
||||
|
||||
function isDDL(name: string): boolean {
|
||||
const ddlKeywords = [
|
||||
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
|
||||
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION',
|
||||
];
|
||||
const upper = name.toUpperCase();
|
||||
return ddlKeywords.some((kw) => upper.includes(kw));
|
||||
}
|
||||
|
||||
export default function AuditRulesPage() {
|
||||
const [activeTab, setActiveTab] = useState<DbType>('mysql');
|
||||
const [rules, setRules] = useState<AuditRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' });
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '', description: '', rule_type: 'safety', severity: 'warning',
|
||||
db_type: 'mysql', pattern: '', is_enabled: true, message: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
|
||||
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => { fetchRules(); fetchStats(); }, []);
|
||||
|
||||
async function fetchRules() {
|
||||
const fetchRules = useCallback(async (dbType: DbType) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (filter.rule_type) params.set('rule_type', filter.rule_type);
|
||||
if (filter.severity) params.set('severity', filter.severity);
|
||||
if (filter.is_enabled !== '') params.set('is_enabled', filter.is_enabled);
|
||||
const res = await request.get('/audit-rules', { params });
|
||||
setRules(Array.isArray(res.data) ? res.data : []);
|
||||
} catch { setRules([]); } finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await request.get('/audit-rules/stats/summary');
|
||||
setStats(res.data || null);
|
||||
} catch { setStats(null); }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
await request.post('/audit-rules', form);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' });
|
||||
fetchRules();
|
||||
fetchStats();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '创建失败');
|
||||
const res = await request.get('/audit-rules', {
|
||||
params: { db_type: dbType },
|
||||
});
|
||||
setRules(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
|
||||
} catch {
|
||||
setRules([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleToggle(rule: AuditRule) {
|
||||
useEffect(() => {
|
||||
fetchRules(activeTab);
|
||||
}, [activeTab, fetchRules]);
|
||||
|
||||
const handleSeed = async () => {
|
||||
try {
|
||||
await request.post('/audit-rules/' + rule.id + '/toggle');
|
||||
fetchRules();
|
||||
fetchStats();
|
||||
} catch { /* */ }
|
||||
}
|
||||
setSeeding(true);
|
||||
await request.post(`/audit-rules/seed/${activeTab}`);
|
||||
fetchRules(activeTab);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '初始化失败');
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('确认删除此规则?')) return;
|
||||
const handleToggle = async (rule: AuditRule) => {
|
||||
try {
|
||||
await request.delete('/audit-rules/' + id);
|
||||
fetchRules();
|
||||
fetchStats();
|
||||
} catch { /* */ }
|
||||
}
|
||||
await request.post(`/audit-rules/${rule.id}/toggle`);
|
||||
setRules((prev) =>
|
||||
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0];
|
||||
const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info;
|
||||
const handleSaveValue = async (rule: AuditRule) => {
|
||||
const newVal = editingValue[rule.id];
|
||||
if (newVal === undefined || newVal === rule.value) return;
|
||||
try {
|
||||
setSaving((prev) => ({ ...prev, [rule.id]: true }));
|
||||
await request.put(`/audit-rules/${rule.id}`, { value: newVal });
|
||||
setRules((prev) =>
|
||||
prev.map((r) => (r.id === rule.id ? { ...r, value: newVal } : r)),
|
||||
);
|
||||
setEditingValue((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[rule.id];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [rule.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Group rules into DDL / DML
|
||||
const ddlRules = rules.filter((r) => isDDL(r.name));
|
||||
const dmlRules = rules.filter((r) => !isDDL(r.name));
|
||||
|
||||
const renderRuleGroup = (title: string, icon: string, groupRules: AuditRule[]) => {
|
||||
if (groupRules.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] flex items-center gap-2 px-1">
|
||||
<span>{icon}</span>
|
||||
<span>{title}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">({groupRules.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{groupRules.map((rule) => {
|
||||
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
|
||||
const currentEditVal = editingValue[rule.id];
|
||||
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`dg-card px-4 py-3 flex items-center gap-4 transition-opacity ${
|
||||
!rule.is_enabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => handleToggle(rule)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
||||
rule.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 ${
|
||||
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Name + Message */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
|
||||
{rule.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">
|
||||
{rule.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Value input (if applicable) */}
|
||||
{hasValue && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={(e) =>
|
||||
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
|
||||
}
|
||||
onBlur={() => handleSaveValue(rule)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveValue(rule);
|
||||
}}
|
||||
disabled={saving[rule.id]}
|
||||
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50"
|
||||
/>
|
||||
{saving[rule.id] && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">保存中…</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">审核规则</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理 SQL 审核引擎的检查规则</p>
|
||||
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">审核规则</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||
onClick={handleSeed}
|
||||
disabled={seeding}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
新建规则
|
||||
{seeding ? '初始化中…' : '初始化规则'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '总规则数', value: stats.total, color: 'text-gray-900' },
|
||||
{ label: '已启用', value: stats.enabled, color: 'text-green-600' },
|
||||
{ label: '已禁用', value: stats.disabled, color: 'text-gray-400' },
|
||||
{ label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<p className="text-sm text-gray-500">{s.label}</p>
|
||||
<p className={'text-2xl font-bold ' + s.color}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={filter.rule_type}
|
||||
onChange={e => setFilter({ ...filter, rule_type: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filter.severity}
|
||||
onChange={e => setFilter({ ...filter, severity: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">全部级别</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">提示</option>
|
||||
</select>
|
||||
<select
|
||||
value={filter.is_enabled}
|
||||
onChange={e => setFilter({ ...filter, is_enabled: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">已启用</option>
|
||||
<option value="false">已禁用</option>
|
||||
</select>
|
||||
<button onClick={fetchRules} className="px-3 py-2 bg-gray-100 rounded-lg text-sm hover:bg-gray-200">
|
||||
刷新
|
||||
</button>
|
||||
{/* DB Type Tabs */}
|
||||
<div className="dg-card p-1.5 flex gap-1">
|
||||
{DB_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-[var(--dg-blue)] text-white'
|
||||
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">新建审核规则</h2>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
placeholder="规则名称"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="描述(可选)"
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={form.rule_type}
|
||||
onChange={e => setForm({ ...form, rule_type: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={form.severity}
|
||||
onChange={e => setForm({ ...form, severity: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="error">错误</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">提示</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
placeholder="匹配模式(正则表达式,可选)"
|
||||
value={form.pattern}
|
||||
onChange={e => setForm({ ...form, pattern: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
<input
|
||||
placeholder="提示消息"
|
||||
value={form.message}
|
||||
onChange={e => setForm({ ...form, message: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-5">
|
||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleCreate} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Rule List */}
|
||||
{loading ? (
|
||||
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="dg-card p-8 text-center space-y-3">
|
||||
<p className="text-[var(--dg-text-muted)] text-sm">
|
||||
当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSeed}
|
||||
disabled={seeding}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"
|
||||
>
|
||||
点击初始化默认规则
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
|
||||
{renderRuleGroup('DML 规则', '📝', dmlRules)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules Table */}
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">加载中...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">暂无规则,点击"新建规则"添加</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<th className="px-4 py-3">规则名称</th>
|
||||
<th className="px-4 py-3">类型</th>
|
||||
<th className="px-4 py-3">级别</th>
|
||||
<th className="px-4 py-3">提示消息</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rules.map(rule => {
|
||||
const typeInfo = getTypeInfo(rule.rule_type);
|
||||
const sevInfo = getSeverityInfo(rule.severity);
|
||||
return (
|
||||
<tr key={rule.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900">{rule.name}</p>
|
||||
{rule.description && <p className="text-xs text-gray-400 mt-0.5">{rule.description}</p>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={'px-2 py-0.5 rounded text-xs ' + typeInfo.color}>{typeInfo.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={'px-2 py-0.5 rounded text-xs ' + sevInfo.color}>{sevInfo.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate">{rule.message}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggle(rule)}
|
||||
className={'px-2 py-1 rounded text-xs ' + (
|
||||
rule.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
||||
)}
|
||||
>
|
||||
{rule.is_enabled ? '启用' : '禁用'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import request from '@/api/request';
|
||||
import { Save, Shield, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
/* ── types ── */
|
||||
interface LdapConfig {
|
||||
ldap_host: string;
|
||||
ldap_port: number;
|
||||
use_ldaps: boolean;
|
||||
admin_dn: string;
|
||||
admin_password: string;
|
||||
search_filter: string;
|
||||
base_dn: string;
|
||||
user_mapping: Record<string, string>;
|
||||
}
|
||||
|
||||
const defaultConfig: LdapConfig = {
|
||||
ldap_host: '',
|
||||
ldap_port: 389,
|
||||
use_ldaps: false,
|
||||
admin_dn: '',
|
||||
admin_password: '',
|
||||
search_filter: '(objectClass=person)',
|
||||
base_dn: '',
|
||||
user_mapping: { username: 'uid', email: 'mail', display_name: 'cn' },
|
||||
};
|
||||
|
||||
export default function LdapConfigPage() {
|
||||
const [config, setConfig] = useState<LdapConfig>(defaultConfig);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
/* test */
|
||||
const [testUser, setTestUser] = useState('');
|
||||
const [testPass, setTestPass] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await request.get('/ldap-config');
|
||||
const data = (res as any).data ?? res;
|
||||
if (data && typeof data === 'object') {
|
||||
setConfig({
|
||||
ldap_host: data.ldap_host || '',
|
||||
ldap_port: data.ldap_port || 389,
|
||||
use_ldaps: data.use_ldaps ?? false,
|
||||
admin_dn: data.admin_dn || '',
|
||||
admin_password: data.admin_password || '',
|
||||
search_filter: data.search_filter || '',
|
||||
base_dn: data.base_dn || '',
|
||||
user_mapping: typeof data.user_mapping === 'string'
|
||||
? JSON.parse(data.user_mapping)
|
||||
: data.user_mapping || defaultConfig.user_mapping,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* 404 = no config yet, use defaults */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
/* update helpers */
|
||||
const updateField = <K extends keyof LdapConfig>(key: K, value: LdapConfig[K]) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateMapping = (key: string, value: string) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
user_mapping: { ...prev.user_mapping, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
/* save */
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
await request.put('/ldap-config', config);
|
||||
setSaveMsg({ ok: true, text: '保存成功' });
|
||||
} catch (err: any) {
|
||||
setSaveMsg({ ok: false, text: err.response?.data?.detail || '保存失败' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setSaveMsg(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
/* test connection */
|
||||
const handleTest = async () => {
|
||||
if (!testUser.trim() || !testPass.trim()) return;
|
||||
try {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
const res = await request.post('/ldap-config/test', { test_user: testUser, test_password: testPass });
|
||||
const data = (res as any).data ?? res;
|
||||
setTestResult({ success: data.success ?? true, message: data.message || '连接成功' });
|
||||
} catch (err: any) {
|
||||
setTestResult({ success: false, message: err.response?.data?.detail || '连接失败' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>;
|
||||
}
|
||||
|
||||
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)]">LDAP 设置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">配置 LDAP/AD 域账号认证集成</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<div
|
||||
className={`dg-card px-4 py-3 flex items-center gap-2 text-sm ${
|
||||
saveMsg.ok ? 'text-green-400 bg-green-500/10' : 'text-red-400 bg-red-500/10'
|
||||
}`}
|
||||
>
|
||||
{saveMsg.ok ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
{saveMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Form */}
|
||||
<div className="dg-card p-5 space-y-5">
|
||||
{/* Server */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" /> LDAP 服务器
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">LDAP 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.ldap_host}
|
||||
onChange={e => updateField('ldap_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="ldap.company.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.ldap_port}
|
||||
onChange={e => updateField('ldap_port', parseInt(e.target.value) || 389)}
|
||||
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>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateField('use_ldaps', !config.use_ldaps)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
||||
config.use_ldaps ? '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 ${
|
||||
config.use_ldaps ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">启用 LDAPS (SSL/TLS)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin */}
|
||||
<div className="pt-4 border-t border-[var(--dg-border)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3">管理员账号</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员 DN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.admin_dn}
|
||||
onChange={e => updateField('admin_dn', 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="cn=admin,dc=company,dc=com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={config.admin_password}
|
||||
onChange={e => updateField('admin_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>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="pt-4 border-t border-[var(--dg-border)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3">搜索配置</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Search Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.search_filter}
|
||||
onChange={e => updateField('search_filter', 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="(objectClass=person)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Base DN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.base_dn}
|
||||
onChange={e => updateField('base_dn', 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="ou=users,dc=company,dc=com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Mapping */}
|
||||
<div className="pt-4 border-t border-[var(--dg-border)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3">用户属性映射</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">username 字段</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.user_mapping.username || ''}
|
||||
onChange={e => updateMapping('username', 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="uid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">email 字段</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.user_mapping.email || ''}
|
||||
onChange={e => updateMapping('email', 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="mail"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">display_name 字段</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.user_mapping.display_name || ''}
|
||||
onChange={e => updateMapping('display_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="cn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div className="dg-card p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" /> 测试 LDAP 连接
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">使用 LDAP 账号验证配置是否正确</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">测试用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testUser}
|
||||
onChange={e => setTestUser(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="LDAP 用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">测试密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={testPass}
|
||||
onChange={e => setTestPass(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 className="flex items-end gap-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !testUser.trim() || !testPass.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mt-4 px-4 py-3 rounded-lg text-sm flex items-center gap-2 ${
|
||||
testResult.success
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import AnnouncementPage from './AnnouncementPage';
|
||||
import NotificationPage from './NotificationPage';
|
||||
import LdapConfigPage from './LdapConfigPage';
|
||||
import AuditRulesPage from './AuditRulesPage';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'rules', label: '审核规则', icon: '🛡️' },
|
||||
{ id: 'announcement', label: '公告管理', icon: '📢' },
|
||||
{ id: 'notification', label: '消息推送', icon: '🔔' },
|
||||
{ id: 'ldap', label: 'LDAP', icon: '🔐' },
|
||||
] as const;
|
||||
|
||||
type TabId = typeof tabs[number]['id'];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('rules');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">系统设置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理平台配置、审核规则、消息推送和 LDAP 认证</p>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-xl w-fit">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div>
|
||||
{activeTab === 'rules' && <AuditRulesPage />}
|
||||
{activeTab === 'announcement' && <AnnouncementPage />}
|
||||
{activeTab === 'notification' && <NotificationPage />}
|
||||
{activeTab === 'ldap' && <LdapConfigPage />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user