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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user