merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化

后端新增 (Step 1):
- mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5)
- redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5)
- mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能)
- postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7)

后端合并 (Step 2):
- audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则)
- audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射
- audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb)

前端优化 (Step 4) — 16页面全覆盖:
- InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage
- UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard
- InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage
- useAuth.tsx, SqlEditor.tsx

后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
logan committed 2026-05-23 16:46:13 +08:00
1 parent e1ba8b3327
commit 58eb78c987
24 files changed
+16333 -2225

No files matched your search

+477 -117
View File
@@ -1,5 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import request from '../api/request';
import {
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
Info, CheckCircle, XCircle, Database, Table, FileSearch,
Shield, Loader2, Save, RotateCcw, Leaf, Server,
} from 'lucide-react';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface AuditRule {
id: number;
@@ -16,23 +25,90 @@ interface AuditRule {
updated_at: string;
}
const DB_TABS = [
{ key: 'mysql', label: 'MySQL', icon: '🐬' },
{ key: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
{ key: 'redis', label: 'Redis', icon: '🔴' },
] as const;
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL';
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
type DbType = (typeof DB_TABS)[number]['key'];
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
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));
interface DbTab {
key: DbType;
label: string;
icon: React.ElementType;
}
const DB_TABS: DbTab[] = [
{ key: 'mysql', label: 'MySQL', icon: Database },
{ key: 'postgresql', label: 'PostgreSQL', icon: Server },
{ key: 'redis', label: 'Redis', icon: Table },
{ key: 'mongodb', label: 'MongoDB', icon: Leaf },
];
const GROUP_CONFIG: Record<RuleGroup, { label: string; icon: React.ElementType; color: string }> = {
DDL: { label: 'DDL 规则', icon: Table, color: 'text-amber-400 bg-amber-500/15' },
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
};
const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: string }> = {
error: { label: '错误', color: 'text-red-400', dot: 'bg-red-400' },
warning: { label: '警告', color: 'text-amber-400', dot: 'bg-amber-400' },
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
};
const DDL_KEYWORDS = [
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION', 'VIEW',
'DATABASE', 'SCHEMA', 'SEQUENCE', '集合', 'COLLECTION',
' Validator', 'CAPPED', 'SCHEMA',
];
const DML_KEYWORDS = ['DML', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'BULK', '写入', 'UNSET'];
const DCL_KEYWORDS = ['DCL', 'GRANT', 'REVOKE', 'DENY'];
const DQL_KEYWORDS = ['DQL', 'SELECT', 'EXPLAIN', 'DESCRIBE', 'SHOW', 'FIND', '查询', '聚合', 'AGGREGAT', 'PROJECTION', 'FILTER', 'REGEX', '$WHERE'];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getRuleGroup(name: string): RuleGroup {
const upper = name.toUpperCase();
if (DDL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DDL';
if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML';
if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL';
if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL';
// MongoDB security rules -> DCL, performance rules -> DQL
if (upper.includes('SECURITY') || upper.includes('安全') || upper.includes('审计') || upper.includes('注入') || upper.includes('EVAL') || upper.includes('MAPSQL_REDUCE')) return 'DCL';
if (upper.includes('PERFORMANCE') || upper.includes('性能') || upper.includes('索引') || upper.includes('扫描') || upper.includes('分片') || upper.includes('SHARD')) return 'DQL';
// Default to DDL for structural rules, DML for data rules
if (upper.includes('WHERE') || upper.includes('写入') || upper.includes('UPDATE') || upper.includes('DELETE')) return 'DML';
return 'DDL';
}
function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): AuditRule[] {
const severityOrder = { error: 0, warning: 1, info: 2 };
const sorted = [...rules];
switch (sortBy) {
case 'severity':
return sorted.sort((a, b) => (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) - (severityOrder[b.severity as keyof typeof severityOrder] ?? 3));
case 'name':
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
case 'type':
return sorted.sort((a, b) => getRuleGroup(a.name).localeCompare(getRuleGroup(b.name)));
default:
return sorted;
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function AuditRulesPage() {
const [activeTab, setActiveTab] = useState<DbType>('mysql');
const [rules, setRules] = useState<AuditRule[]>([]);
@@ -41,13 +117,27 @@ export default function AuditRulesPage() {
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<Record<number, boolean>>({});
// -- Search & Filter --
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
const [sortBy, setSortBy] = useState<'severity' | 'name' | 'type'>('severity');
// -- Expand & Confirm --
const [expandedRuleId, setExpandedRuleId] = useState<number | null>(null);
const [confirmToggle, setConfirmToggle] = useState<number | null>(null);
/* ---------------------------------------------------------------- */
/* Fetch rules */
/* ---------------------------------------------------------------- */
const fetchRules = useCallback(async (dbType: DbType) => {
try {
setLoading(true);
const res = await request.get('/audit-rules', {
params: { db_type: dbType },
});
setRules(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
const data = (res as { data?: unknown[] }).data ?? (res as unknown[]);
setRules(Array.isArray(data) ? data as AuditRule[] : []);
} catch {
setRules([]);
} finally {
@@ -57,32 +147,45 @@ export default function AuditRulesPage() {
useEffect(() => {
fetchRules(activeTab);
setSearchQuery('');
setSeverityFilter('all');
setExpandedRuleId(null);
}, [activeTab, fetchRules]);
const handleSeed = async () => {
/* ---------------------------------------------------------------- */
/* Handlers */
/* ---------------------------------------------------------------- */
const handleSeed = useCallback(async () => {
try {
setSeeding(true);
await request.post(`/audit-rules/seed/${activeTab}`);
fetchRules(activeTab);
} catch (err: any) {
alert(err.response?.data?.detail || '初始化失败');
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } } };
alert(e.response?.data?.detail || '初始化失败');
} finally {
setSeeding(false);
}
};
}, [activeTab, fetchRules]);
const handleToggle = async (rule: AuditRule) => {
const handleToggle = useCallback(async (rule: AuditRule) => {
if (confirmToggle !== rule.id) {
setConfirmToggle(rule.id);
return;
}
try {
await request.post(`/audit-rules/${rule.id}/toggle`);
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
);
setConfirmToggle(null);
} catch {
/* ignore */
}
};
}, [confirmToggle]);
const handleSaveValue = async (rule: AuditRule) => {
const handleSaveValue = useCallback(async (rule: AuditRule) => {
const newVal = editingValue[rule.id];
if (newVal === undefined || newVal === rule.value) return;
try {
@@ -101,147 +204,404 @@ export default function AuditRulesPage() {
} finally {
setSaving((prev) => ({ ...prev, [rule.id]: false }));
}
}, [editingValue]);
/* ---------------------------------------------------------------- */
/* Derived: filtered & grouped rules */
/* ---------------------------------------------------------------- */
const filteredRules = useMemo(() => {
let result = [...rules];
// Search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.message.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
// Severity filter
if (severityFilter !== 'all') {
result = result.filter((r) => r.severity === severityFilter);
}
// Sort
return sortRules(result, sortBy);
}, [rules, searchQuery, severityFilter, sortBy]);
const groupedRules = useMemo(() => {
const groups: Record<RuleGroup, AuditRule[]> = {
DDL: [],
DML: [],
DCL: [],
DQL: [],
};
for (const rule of filteredRules) {
const group = getRuleGroup(rule.name);
groups[group].push(rule);
}
return groups;
}, [filteredRules]);
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
const renderRuleCard = (rule: AuditRule) => {
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
const currentEditVal = editingValue[rule.id];
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
const isExpanded = expandedRuleId === rule.id;
const severityInfo = SEVERITY_CONFIG[rule.severity] || SEVERITY_CONFIG.info;
const isConfirmingToggle = confirmToggle === rule.id;
return (
<div
key={rule.id}
className={`dg-card overflow-hidden transition-all ${!rule.is_enabled ? 'opacity-50' : ''}`}
>
{/* Main row */}
<div
className="px-4 py-3 flex items-center gap-4 cursor-pointer"
onClick={() => setExpandedRuleId(isExpanded ? null : rule.id)}
>
{/* Toggle */}
<button
onClick={(e) => {
e.stopPropagation();
handleToggle(rule);
}}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors duration-200 ${
rule.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
}`}
title={rule.is_enabled ? '禁用规则' : '启用规则'}
>
<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>
{/* Severity dot */}
<span className={`w-2 h-2 rounded-full ${severityInfo.dot} shrink-0`} title={severityInfo.label} />
{/* Name + Message */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
{rule.name}
</p>
<span className={`text-[11px] px-1.5 py-0.5 rounded ${severityInfo.color} ${severityInfo.color.split(' ')[1] || 'bg-gray-500/15'}`}>
{severityInfo.label}
</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">{rule.message}</p>
</div>
{/* Value input */}
{hasValue && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<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 transition-all"
/>
{saving[rule.id] && <Loader2 className="w-3.5 h-3.5 animate-spin text-[var(--dg-text-muted)]" />}
</div>
)}
{/* Expand icon */}
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)] shrink-0" />
)}
</div>
{/* Toggle confirmation */}
{isConfirmingToggle && (
<div className="px-4 py-2 bg-yellow-500/10 border-t border-yellow-500/20 flex items-center gap-3">
<AlertTriangle className="w-4 h-4 text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-300 flex-1">
确认{rule.is_enabled ? '禁用' : '启用'}规则 &quot;{rule.name}&quot;?
</span>
<button
onClick={() => handleToggle(rule)}
className="px-2.5 py-1 text-xs bg-yellow-500/20 text-yellow-400 rounded hover:bg-yellow-500/30 transition-colors"
>
确认
</button>
<button
onClick={() => setConfirmToggle(null)}
className="px-2.5 py-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
取消
</button>
</div>
)}
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-[var(--dg-border)] pt-3">
<div className="space-y-3 text-sm">
{/* Description */}
{rule.description && (
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">规则说明</span>
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.description}</p>
</div>
</div>
)}
{/* Message */}
<div className="flex items-start gap-2">
<MessageSquareIcon className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">触发消息</span>
<p className="text-[var(--dg-text-secondary)] mt-0.5">{rule.message}</p>
</div>
</div>
{/* Pattern */}
{rule.pattern && (
<div className="flex items-start gap-2">
<FileSearch className="w-4 h-4 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-xs text-[var(--dg-text-muted)] font-medium">匹配模式</span>
<code className="block mt-0.5 text-xs font-mono bg-[var(--dg-bg-primary)] px-2 py-1 rounded text-purple-300">
{rule.pattern}
</code>
</div>
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 pt-2 border-t border-[var(--dg-border-light)]">
<span className="text-xs text-[var(--dg-text-muted)]">
类型: <span className="text-[var(--dg-text-secondary)]">{rule.rule_type.toUpperCase()}</span>
</span>
<span className="text-xs text-[var(--dg-text-muted)]">
更新于: <span className="text-[var(--dg-text-secondary)]">{new Date(rule.updated_at).toLocaleDateString()}</span>
</span>
</div>
</div>
</div>
)}
</div>
);
};
// 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[]) => {
const renderRuleGroup = (group: RuleGroup, groupRules: AuditRule[]) => {
if (groupRules.length === 0) return null;
const config = GROUP_CONFIG[group];
const GroupIcon = config.icon;
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>
<div className="space-y-2">
<h3 className={`text-sm font-semibold flex items-center gap-2 px-1 py-1 ${config.color} w-fit rounded-lg`}>
<GroupIcon className="w-4 h-4" />
<span>{config.label}</span>
<span className="text-xs opacity-70">({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>
);
})}
{groupRules.map((rule) => renderRuleCard(rule))}
</div>
</div>
);
};
/* ---------------------------------------------------------------- */
/* Main render */
/* ---------------------------------------------------------------- */
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<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>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版,支持搜索、筛选和排序</p>
</div>
<button
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 ? '初始化中…' : '初始化规则'}
{seeding ? (
<span className="flex items-center gap-1.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
初始化中...
</span>
) : (
'初始化规则'
)}
</button>
</div>
{/* 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 className="dg-card p-1.5 flex gap-1 flex-wrap">
{DB_TABS.map((tab) => {
const TabIcon = tab.icon;
return (
<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 min-w-[100px] ${
activeTab === tab.key
? 'bg-[var(--dg-blue)] text-white'
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<TabIcon className="w-4 h-4" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Search & Filter Bar */}
<div className="dg-card p-3 flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索规则名称、消息或描述..."
className="w-full pl-9 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:outline-none focus:border-blue-500 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{/* Severity filter */}
<div className="flex items-center gap-1.5">
<Filter className="w-4 h-4 text-[var(--dg-text-muted)]" />
{([
{ value: 'all', label: '全部' },
{ value: 'error', label: '错误' },
{ value: 'warning', label: '警告' },
{ value: 'info', label: '提示' },
] as { value: SeverityFilter; label: string }[]).map((opt) => (
<button
key={opt.value}
onClick={() => setSeverityFilter(opt.value)}
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
severityFilter === opt.value
? 'bg-blue-500/15 text-blue-400'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Sort */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-[var(--dg-text-muted)]">排序:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'severity' | 'name' | 'type')}
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs text-[var(--dg-text-primary)] focus:outline-none focus:border-blue-500"
>
<option value="severity">按严重程度</option>
<option value="name">按名称</option>
<option value="type">按类型</option>
</select>
</div>
</div>
{/* Results summary */}
{(searchQuery || severityFilter !== 'all') && (
<div className="text-xs text-[var(--dg-text-muted)] px-1 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
共 {filteredRules.length} 条规则
{searchQuery && <span>匹配 &quot;{searchQuery}&quot;</span>}
{severityFilter !== 'all' && <span>严重级别: {SEVERITY_CONFIG[severityFilter]?.label || severityFilter}</span>}
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-blue-400 hover:underline ml-1">
清除筛选
</button>
</div>
)}
{/* Rule List */}
{loading ? (
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin" />
<span>加载中...</span>
</div>
) : rules.length === 0 ? (
<div className="dg-card p-8 text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
<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"
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 transition-colors"
>
点击初始化默认规则
</button>
</div>
) : filteredRules.length === 0 ? (
<div className="dg-card p-8 text-center space-y-3">
<Search className="w-10 h-10 text-[var(--dg-text-muted)] mx-auto opacity-50" />
<p className="text-[var(--dg-text-muted)] text-sm">没有匹配的规则</p>
<button onClick={() => { setSearchQuery(''); setSeverityFilter('all'); }} className="text-sm text-blue-400 hover:underline">
清除筛选条件
</button>
</div>
) : (
<div className="space-y-6">
{renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
{renderRuleGroup('DML 规则', '📝', dmlRules)}
{renderRuleGroup('DDL', groupedRules.DDL)}
{renderRuleGroup('DML', groupedRules.DML)}
{renderRuleGroup('DCL', groupedRules.DCL)}
{renderRuleGroup('DQL', groupedRules.DQL)}
</div>
)}
</div>
);
}
/* Small icon component */
function MessageSquareIcon(props: React.SVGProps<SVGSVGElement> & { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}