后端审核规则修复: - Redis SEC-001: SET/DEL等常规命令不再误报AUTH要求,改为仅对管理级命令WARNING - Redis SEC-003: 降级为INFO,排除已由CMD-001拦截的FLUSHDB/FLUSHALL - Redis key提取: 排除CONFIG/ACL等管理命令,不再把子命令参数误认为key名 - MySQL SEC-006: 新增GRANT ALL/REVOKE ALL全局权限拦截 - MySQL SEC-007: 新增LOAD DATA/LOAD XML拦截 - MySQL SEC-008: 新增SLEEP()/BENCHMARK()时间盲注拦截 - MySQL SEC-009: 新增INTO OUTFILE/DUMPFILE文件写入拦截 前端执行结果结构化: - TicketDetailPage执行结果从原始JSON改为结构化展示 - 解析JSON展示: 状态徽章+影响行数+耗时+验证状态 - Errors/Warnings逐条渲染: 红色错误框+黄色警告框+具体消息 - 删除废弃的executeStats/renderExecuteStats 其他: 审核规则6分组/慢查询移除/查询数据重命名/AuditLogs修复 测试: 1040 passed, TSC零错误
597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import request from '../api/request';
|
||
import {
|
||
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
|
||
Info, XCircle, Database, Table, FileSearch,
|
||
Shield, Loader2, Leaf, Server,
|
||
} from 'lucide-react';
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Types */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
interface AuditRule {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
rule_type: string;
|
||
severity: string;
|
||
db_type: string;
|
||
pattern: string | null;
|
||
is_enabled: boolean;
|
||
message: string;
|
||
value: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
|
||
type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL' | 'security' | 'performance';
|
||
type SeverityFilter = 'all' | 'error' | 'warning' | 'info';
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Constants */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
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' },
|
||
security: { label: '安全规则', icon: AlertTriangle, color: 'text-red-400 bg-red-500/15' },
|
||
performance: { label: '性能规则', icon: Info, color: 'text-cyan-400 bg-cyan-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' },
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Helpers */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const VALID_GROUPS: RuleGroup[] = ['DDL', 'DML', 'DCL', 'DQL', 'security', 'performance'];
|
||
|
||
function getRuleGroup(rule: AuditRule): RuleGroup {
|
||
const rt = rule.rule_type;
|
||
if (VALID_GROUPS.includes(rt as RuleGroup)) return rt as RuleGroup;
|
||
// Fallback: map unknown types
|
||
if (rt === 'safety' || rt === 'naming' || rt === 'syntax') return 'DDL';
|
||
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).localeCompare(getRuleGroup(b)));
|
||
default:
|
||
return sorted;
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Component */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
export default function AuditRulesPage() {
|
||
const [activeTab, setActiveTab] = useState<DbType>('mysql');
|
||
const [rules, setRules] = useState<AuditRule[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [seeding, setSeeding] = useState(false);
|
||
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 },
|
||
});
|
||
const data = (res as { data?: unknown[] }).data ?? (res as unknown as unknown[]);
|
||
setRules(Array.isArray(data) ? data as AuditRule[] : []);
|
||
} catch {
|
||
setRules([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchRules(activeTab);
|
||
setSearchQuery('');
|
||
setSeverityFilter('all');
|
||
setExpandedRuleId(null);
|
||
}, [activeTab, fetchRules]);
|
||
|
||
/* ---------------------------------------------------------------- */
|
||
/* Handlers */
|
||
/* ---------------------------------------------------------------- */
|
||
|
||
const handleSeed = useCallback(async () => {
|
||
try {
|
||
setSeeding(true);
|
||
await request.post(`/audit-rules/seed/${activeTab}`);
|
||
fetchRules(activeTab);
|
||
} catch (err: unknown) {
|
||
const e = err as { response?: { data?: { detail?: string } } };
|
||
alert(e.response?.data?.detail || '初始化失败');
|
||
} finally {
|
||
setSeeding(false);
|
||
}
|
||
}, [activeTab, fetchRules]);
|
||
|
||
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 = useCallback(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 }));
|
||
}
|
||
}, [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: [],
|
||
security: [],
|
||
performance: [],
|
||
};
|
||
for (const rule of filteredRules) {
|
||
const group = getRuleGroup(rule);
|
||
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 ? '禁用' : '启用'}规则 "{rule.name}"?
|
||
</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>
|
||
);
|
||
};
|
||
|
||
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-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) => renderRuleCard(rule))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ---------------------------------------------------------------- */
|
||
/* Main render */
|
||
/* ---------------------------------------------------------------- */
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Header */}
|
||
<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>
|
||
</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 ? (
|
||
<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 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>匹配 "{searchQuery}"</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 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 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', groupedRules.DDL)}
|
||
{renderRuleGroup('DML', groupedRules.DML)}
|
||
{renderRuleGroup('security', groupedRules.security)}
|
||
{renderRuleGroup('DCL', groupedRules.DCL)}
|
||
{renderRuleGroup('DQL', groupedRules.DQL)}
|
||
{renderRuleGroup('performance', groupedRules.performance)}
|
||
</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>
|
||
);
|
||
}
|