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:
logan committed 2026-05-22 22:26:10 +08:00
1 parent d5d19d6be4
commit bab55d3927
27 files changed
+2881 -591

No files matched your search

+2 -4
View File
@@ -13,8 +13,7 @@ import DataPermissionPage from '@/pages/DataPermissionPage';
import AuditRulesPage from '@/pages/AuditRulesPage';
import WorkflowPage from '@/pages/WorkflowPage';
import AutoTaskPage from '@/pages/AutoTaskPage';
import AnnouncementPage from '@/pages/AnnouncementPage';
import ConfigPlatformPage from '@/pages/ConfigPlatformPage';
import SettingsPage from '@/pages/SettingsPage';
import AuditLogsPage from '@/pages/AuditLogsPage';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
@@ -64,8 +63,7 @@ function AppRoutes() {
<Route path="audit-logs" element={<AuditLogsPage />} />
<Route path="workflow" element={<WorkflowPage />} />
<Route path="auto-task" element={<AutoTaskPage />} />
<Route path="announcement" element={<AnnouncementPage />} />
<Route path="config-platform" element={<ConfigPlatformPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Route>
+2 -38
View File
@@ -6,7 +6,6 @@ import {
} from 'lucide-react';
import { menuSections } from '@/lib/mock';
import { useAuth } from '@/hooks/useAuth';
import type { UserRole } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
const iconMap: Record<string, React.ElementType> = {
@@ -15,15 +14,8 @@ const iconMap: Record<string, React.ElementType> = {
Settings, Clock, Megaphone,
};
const roleBadgeStyle: Record<UserRole, string> = {
admin: 'bg-purple-500/15 text-purple-400',
dba: 'bg-blue-500/15 text-blue-400',
developer: 'bg-green-500/15 text-green-400',
guest: 'bg-gray-500/15 text-gray-400',
};
export default function Layout() {
const { user, logout, switchRole, visibleMenuItems } = useAuth();
const { user, logout, visibleMenuItems } = useAuth();
const { theme, toggleTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
@@ -34,18 +26,6 @@ export default function Layout() {
return item?.label || '仪表盘';
};
const toggleRole = () => {
const roles: UserRole[] = ['admin', 'dba', 'developer', 'guest'];
const idx = roles.indexOf(user.role);
const nextRole = roles[(idx + 1) % roles.length];
switchRole(nextRole);
// 切换角色后如果当前页面无权限,自动跳仪表盘
const allowed = visibleMenuItems.find(m => m.id === currentPath);
if (!allowed && currentPath !== 'dashboard') {
navigate('/dashboard', { replace: true });
}
};
return (
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
{/* 侧边栏 */}
@@ -102,21 +82,6 @@ export default function Layout() {
{/* 底部 */}
<div className="p-4 border-t border-[var(--dg-border)]">
{/* 角色切换演示 */}
<div className="mb-3 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--dg-text-muted)]">当前角色</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${roleBadgeStyle[user.role]}`}>
{user.roleName}
</span>
</div>
<button
onClick={toggleRole}
className="mt-2 w-full text-center text-[11px] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors py-1 rounded hover:bg-[var(--dg-bg-hover)]"
>
[演示] 切换角色
</button>
</div>
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer transition-colors">
<div className="w-9 h-9 rounded-full dg-gradient-br flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
{user.avatar}
@@ -145,8 +110,7 @@ export default function Layout() {
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
</div>
<div className="flex items-center gap-3">
{/* 角色标签 */}
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${roleBadgeStyle[user.role]}`}>
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-blue-500/15 text-blue-400">
{user.roleName}
</span>
<button
+1 -4
View File
@@ -45,13 +45,10 @@ const routePermissionMap: Record<string, UserRole[]> = {
roles: ['admin'],
users: ['admin'],
'data-permission': ['admin', 'dba'],
'audit-rules': ['admin', 'dba'],
'audit-logs': ['admin', 'dba'],
workflow: ['admin', 'dba'],
'auto-task': ['admin'],
announcement: ['admin'],
'config-platform': ['admin'],
notifications: ['admin'],
settings: ['admin'],
};
export const actionPermissionMap: Record<string, UserRole[]> = {
+1 -3
View File
@@ -31,12 +31,10 @@ export const menuItems = [
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
{ id: 'audit-rules', label: '审核规则', icon: 'Shield', section: 'system', roles: ['admin', 'dba'] },
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'] },
{ id: 'config-platform', label: '配置平台', icon: 'Settings', section: 'config', roles: ['admin'] },
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'config', roles: ['admin'] },
];
export const currentUser = {
+272 -278
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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 语法...&#10;&#10;**粗体** *斜体* `代码`&#10;## 二级标题&#10;- 列表项&#10;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>
+200 -241
View File
@@ -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>
);
}
+335
View File
@@ -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>
);
}
+456
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}