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

+502 -99
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, Clock, FileText, Sparkles,
ChevronDown, Clock, FileText, Sparkles, Star,
StarOff, Download, Search, RotateCcw, ArrowUpDown,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, PanelLeft,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
@@ -11,11 +13,14 @@ import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance';
import SqlEditor from '@/components/SqlEditor';
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import type { DBType } from '@/lib/queryTemplates';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface QueryResult {
columns: string[];
rows: Record<string, any>[];
rows: Record<string, unknown>[];
affected_rows: number;
execution_time_ms: number;
}
@@ -30,8 +35,15 @@ interface QueryHistoryItem {
execution_time_ms: number;
result_preview: string;
created_at: string;
favorite?: boolean;
}
type SortDirection = 'asc' | 'desc' | null;
type SortState = { column: string | null; direction: SortDirection };
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const dbTypeLabel: Record<string, string> = {
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
};
@@ -43,16 +55,114 @@ const dbTypeIcon: Record<string, string> = {
mongodb: '/db-logos/mongo.svg',
};
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
const placeholderMap: Record<DBType, string> = {
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function maskData(value: string): string {
if (!value) return '***';
if (typeof value === 'string' && value.includes('@')) {
const [name, domain] = value.split('@');
return name.substring(0, 3) + '***@' + domain;
}
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
return '***';
}
function exportToCSV(columns: string[], rows: Record<string, unknown>[]): string {
const header = columns.join(',');
const lines = rows.map(row =>
columns.map(col => {
const val = String(row[col] ?? '');
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
}).join(',')
);
return [header, ...lines].join('\n');
}
function downloadCSV(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function QueriesPage() {
const canCreate = useActionPermission('query.create');
const [showNewQuery, setShowNewQuery] = useState(false);
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
try {
const saved = localStorage.getItem('query_favorites');
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
const initialHistory: QueryHistoryItem[] = [];
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
} catch {
return [];
}
});
const [favFilter, setFavFilter] = useState(false);
const [searchHistory, setSearchHistory] = useState('');
const [clearConfirm, setClearConfirm] = useState(false);
const toggleFavorite = useCallback((id: number) => {
setHistory(prev => {
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
const favIds = next.filter(h => h.favorite).map(h => h.id);
try { localStorage.setItem('query_favorites', JSON.stringify(favIds)); } catch { /* noop */ }
return next;
});
}, []);
const handleExecuted = useCallback((item: QueryHistoryItem) => {
setHistory(prev => [item, ...prev]);
}, []);
const handleClearHistory = useCallback(() => {
if (!clearConfirm) {
setClearConfirm(true);
setTimeout(() => setClearConfirm(false), 3000);
return;
}
setHistory([]);
setClearConfirm(false);
}, [clearConfirm]);
const filteredHistory = useMemo(() => {
let result = history;
if (favFilter) result = result.filter(h => h.favorite);
if (searchHistory) {
const kw = searchHistory.toLowerCase();
result = result.filter(h =>
h.sql.toLowerCase().includes(kw) ||
h.db_type.toLowerCase().includes(kw)
);
}
return result;
}, [history, favFilter, searchHistory]);
if (showNewQuery) {
return (
<NewQueryPanel
onCancel={() => setShowNewQuery(false)}
onExecuted={(item) => { setHistory(prev => [item, ...prev]); }}
onExecuted={handleExecuted}
history={history}
onToggleFavorite={toggleFavorite}
/>
);
}
@@ -65,42 +175,92 @@ export default function QueriesPage() {
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
/>
{/* History Controls */}
{history.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input
className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史 SQL..."
value={searchHistory}
onChange={e => setSearchHistory(e.target.value)}
/>
</div>
<button
onClick={() => setFavFilter(!favFilter)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs rounded-lg border transition-all font-medium ${
favFilter
? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
}`}
>
<Star className="w-3.5 h-3.5" />
仅收藏
</button>
<button
onClick={handleClearHistory}
className="flex items-center gap-1.5 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40 rounded-lg transition-all font-medium"
>
{clearConfirm ? <CheckCircle className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
{clearConfirm ? '确认清除' : '清除历史'}
</button>
</div>
)}
<Card className="dg-card">
<CardContent className="p-0">
{history.length === 0 ? (
<EmptyState type="search" />
) : filteredHistory.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)] text-sm">
<Search className="w-8 h-8 mx-auto mb-2 text-[var(--dg-text-muted)]" />
没有找到匹配的历史记录
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold">类型</th>
<th className="text-left px-5 py-3 font-semibold">语句</th>
<th className="text-left px-5 py-3 font-semibold">执行时长</th>
<th className="text-left px-5 py-3 font-semibold">状态</th>
<th className="text-left px-5 py-3 font-semibold">时间</th>
<th className="text-left px-4 py-3 font-semibold w-8">#</th>
<th className="text-left px-4 py-3 font-semibold">类型</th>
<th className="text-left px-4 py-3 font-semibold">语句</th>
<th className="text-left px-4 py-3 font-semibold">执行时长</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">时间</th>
<th className="text-left px-4 py-3 font-semibold w-16">操作</th>
</tr>
</thead>
<tbody>
{history.map((q, i) => (
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5">
{filteredHistory.map((q, idx) => (
<tr key={q.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-3.5 text-xs text-[var(--dg-text-muted)] font-mono">{idx + 1}</td>
<td className="px-4 py-3.5">
{q.db_type && dbTypeIcon[q.db_type] && (
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
)}
</td>
<td className="px-5 py-3.5 max-w-md">
<td className="px-4 py-3.5 max-w-md">
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
</td>
<td className="px-5 py-3.5">
<td className="px-4 py-3.5">
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
</td>
<td className="px-5 py-3.5">
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${q.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{q.status === 'success' ? '成功' : '失败'}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{new Date(q.created_at).toLocaleString()}</td>
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">{new Date(q.created_at).toLocaleString()}</td>
<td className="px-4 py-3.5">
<button
onClick={() => toggleFavorite(q.id)}
className={`p-1.5 rounded-md transition-colors ${q.favorite ? 'text-yellow-400 hover:text-yellow-300' : 'text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
title={q.favorite ? '取消收藏' : '收藏'}
>
{q.favorite ? <Star className="w-3.5 h-3.5 fill-current" /> : <StarOff className="w-3.5 h-3.5" />}
</button>
</td>
</tr>
))}
</tbody>
@@ -113,22 +273,38 @@ export default function QueriesPage() {
);
}
/* ---------- NewQueryPanel with templates + CodeMirror ---------- */
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
/* ------------------------------------------------------------------ */
/* NewQueryPanel */
/* ------------------------------------------------------------------ */
function NewQueryPanel({
onCancel, onExecuted, history, onToggleFavorite,
}: {
onCancel: () => void;
onExecuted: (item: QueryHistoryItem) => void;
history: QueryHistoryItem[];
onToggleFavorite: (id: number) => void;
}) {
const [sql, setSql] = useState('');
const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState('');
const [instances, setInstances] = useState<any[]>([]);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string }>>([]);
const [executing, setExecuting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [templateSearch, setTemplateSearch] = useState('');
const [templateCategory, setTemplateCategory] = useState('all');
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
const [showHistory, setShowHistory] = useState(false);
const [rowHeight] = useState(36);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const resultContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
instanceApi.list().then(res => {
const data = Array.isArray(res) ? res : [];
setInstances(data);
}).catch(() => {});
}).catch(() => { setInstances([]); });
}, []);
const selectedInstance = useMemo(() => {
@@ -139,21 +315,28 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
const templateCategories = useMemo(() => {
const cats = new Set(templates.map(t => t.category));
return ['all', ...Array.from(cats)];
}, [templates]);
const placeholderMap: Record<DBType, string> = {
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
const filteredTemplates = useMemo(() => {
let result = templates;
if (templateCategory !== 'all') result = result.filter(t => t.category === templateCategory);
if (templateSearch) {
const kw = templateSearch.toLowerCase();
result = result.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw));
}
return result;
}, [templates, templateCategory, templateSearch]);
const handleApplyTemplate = (tpl: typeof queryTemplates[0]) => {
const handleApplyTemplate = useCallback((tpl: QueryTemplate) => {
const templateSQL = getTemplateSQL(tpl, dbType);
setSql(templateSQL);
setShowTemplates(false);
};
}, [dbType]);
const handleExecute = async () => {
const handleExecute = useCallback(async () => {
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
setExecuting(true);
setError('');
@@ -163,7 +346,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
instance_id: Number(instanceId),
database: database || undefined,
sql,
}) as any;
}) as unknown as QueryResult;
const execTime = res.execution_time_ms || 0;
setResult(res);
@@ -177,9 +360,10 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
execution_time_ms: execTime,
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
created_at: new Date().toISOString(),
favorite: false,
});
} catch (e: any) {
const msg = e?.response?.data?.detail || '查询失败';
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '查询失败';
setError(msg);
onExecuted({
id: Date.now(),
@@ -191,48 +375,168 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
execution_time_ms: 0,
result_preview: msg,
created_at: new Date().toISOString(),
favorite: false,
});
} finally {
setExecuting(false);
}
}, [sql, instanceId, database, dbType, onExecuted]);
/* ---- Result Sorting ---- */
const sortedResult = useMemo(() => {
if (!result || !sortState.column || !sortState.direction) return result;
const col = sortState.column;
const dir = sortState.direction === 'asc' ? 1 : -1;
const sortedRows = [...result.rows].sort((a, b) => {
const av = a[col] ?? '';
const bv = b[col] ?? '';
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
return { ...result, rows: sortedRows };
}, [result, sortState]);
const handleSort = useCallback((col: string) => {
setSortState(prev => {
if (prev.column === col) {
if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection };
if (prev.direction === 'desc') return { column: null, direction: null };
}
return { column: col, direction: 'asc' as SortDirection };
});
}, []);
const SortIcon = ({ col }: { col: string }) => {
if (sortState.column !== col) return <ArrowUpDown className="w-3 h-3 opacity-30" />;
if (sortState.direction === 'asc') return <ArrowUp className="w-3 h-3 text-blue-400" />;
return <ArrowDown className="w-3 h-3 text-blue-400" />;
};
const maskData = (value: string) => {
if (!value) return '***';
if (typeof value === 'string' && value.includes('@')) {
const [name, domain] = value.split('@');
return name.substring(0, 3) + '***@' + domain;
}
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
return '***';
/* ---- Virtual Scroll ---- */
const handleResultScroll = useCallback(() => {
if (!resultContainerRef.current) return;
const container = resultContainerRef.current;
const scrollTop = container.scrollTop;
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visibleCount = Math.ceil(container.clientHeight / rowHeight);
const end = Math.min((sortedResult?.rows?.length || 0), start + visibleCount + 10);
setVisibleRange({ start, end });
}, [sortedResult, rowHeight]);
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
const handleExportResult = useCallback(() => {
if (!result) return;
const csv = exportToCSV(result.columns, result.rows);
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
}, [result]);
const handleExportHistory = useCallback(() => {
const columns = ['序号', 'SQL', '数据库类型', '执行时长(ms)', '状态', '时间'];
const rows = history.map((h, i) => ({
序号: i + 1,
SQL: h.sql,
数据库类型: h.db_type,
'执行时长(ms)': h.execution_time_ms,
状态: h.status === 'success' ? '成功' : '失败',
时间: new Date(h.created_at).toLocaleString(),
}));
const csv = exportToCSV(columns, rows);
downloadCSV(`query_history_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, csv);
}, [history]);
const catLabel: Record<string, string> = {
all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询',
};
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
return (
<div className="space-y-5 max-w-5xl">
<div className="space-y-5 max-w-6xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">新建查询</h1>
{selectedInstance && (
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)]">
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
{dbTypeLabel[dbType] || dbType}
</span>
)}
</div>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
<X className="w-6 h-6" />
</button>
<div className="flex items-center gap-2">
{history.length > 0 && (
<button
onClick={handleExportHistory}
className="flex items-center gap-2 px-3 py-2 text-xs text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Download className="w-3.5 h-3.5" />
导出历史
</button>
)}
<button
onClick={() => setShowHistory(!showHistory)}
className={`flex items-center gap-2 px-3 py-2 text-xs rounded-lg border transition-colors ${
showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Clock className="w-3.5 h-3.5" />
历史
{history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
</button>
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* History Sidebar */}
{showHistory && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-400" />
查询历史 ({history.length})
</CardTitle>
<button onClick={() => setShowHistory(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<PanelLeftClose className="w-4 h-4" />
</button>
</div>
</CardHeader>
<CardContent className="p-0 max-h-[300px] overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-sm text-[var(--dg-text-muted)]">暂无查询历史</div>
) : (
<div className="divide-y divide-[var(--dg-border-light)]">
{history.map(h => (
<div key={h.id} className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--dg-bg-hover)] transition-colors">
{h.db_type && dbTypeIcon[h.db_type] && (
<img src={dbTypeIcon[h.db_type]} alt="" className="w-4 h-4 object-contain flex-shrink-0" />
)}
<code className="flex-1 text-xs font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{h.execution_time_ms}ms
</span>
<button
onClick={() => onToggleFavorite(h.id)}
className={`p-1 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Instance + Database selector */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
<select
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
>
<option value="">请选择实例</option>
{instances.map(inst => (
@@ -247,7 +551,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
<input
value={database}
onChange={e => setDatabase(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
placeholder="可选,默认数据库"
/>
</div>
@@ -257,12 +561,12 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium flex items-center gap-2">
<FileText className="w-4 h-4" />
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
查询语句 <span className="text-red-400">*</span>
</label>
<button
onClick={() => setShowTemplates(!showTemplates)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all"
>
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
快捷模板 ({templates.length})
@@ -270,24 +574,55 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
</button>
</div>
{/* Templates dropdown */}
{showTemplates && (
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg max-h-64 overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{templates.map(tpl => (
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg border border-[var(--dg-border)]">
{/* Category tabs + search */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{templateCategories.map(cat => (
<button
key={tpl.id}
onClick={() => handleApplyTemplate(tpl)}
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group"
key={cat}
onClick={() => setTemplateCategory(cat)}
className={`px-2.5 py-1 text-[11px] rounded-md transition-all font-medium ${
templateCategory === cat
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] border border-transparent'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{tpl.category}</span>
<span className="text-sm font-medium">{tpl.name}</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)]">{tpl.description}</p>
{catLabel[cat] || cat}
</button>
))}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
<input
value={templateSearch}
onChange={e => setTemplateSearch(e.target.value)}
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
placeholder="搜索模板..."
/>
</div>
</div>
{/* Templates Grid */}
{filteredTemplates.length === 0 ? (
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{filteredTemplates.map(tpl => (
<button
key={tpl.id}
onClick={() => handleApplyTemplate(tpl)}
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]"
title={tpl.description}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
</div>
<p className="text-xs text-[var(--dg-text-muted)] line-clamp-2">{tpl.description}</p>
</button>
))}
</div>
)}
</div>
)}
@@ -298,7 +633,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
onChange={setSql}
placeholder={placeholderMap[dbType]}
language={editorLang}
height="220px"
height="240px"
onExecute={handleExecute}
/>
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
@@ -306,7 +641,18 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
{dbTypeLabel[dbType]} 模式
</span>
<span>{sql.length} 字符 · Ctrl+Enter 执行 · Ctrl+Shift+F 格式化</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Hash className="w-3 h-3" />
{sql.length} 字符
</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
Ctrl+Enter 执行
</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
Ctrl+Shift+F 格式化
</span>
</div>
</div>
</div>
</div>
@@ -320,70 +666,127 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
</div>
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">{error}</div>
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Query result */}
{/* Query Result */}
{result && (
<Card className="dg-card">
<Card className="dg-card overflow-hidden">
<CardHeader className="border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between flex-wrap gap-3">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-400" /> 查询结果
<CheckCircle className="w-4 h-4 text-green-400" />
查询结果
</CardTitle>
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {result.execution_time_ms}ms</span>
<span>{result.rows?.length || 0} 行</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)]">
<Clock className="w-3 h-3" />
{result.execution_time_ms}ms
</span>
<span className="text-xs text-[var(--dg-text-muted)]">{result.rows?.length || 0} 行</span>
<span className="text-xs text-[var(--dg-text-muted)]">{result.columns?.length || 0} 列</span>
{result.rows && result.rows.length > 0 && (
<button
onClick={handleExportResult}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-green-500/15 text-green-400 border border-green-500/30 rounded-md hover:bg-green-500/25 transition-all"
>
<Download className="w-3.5 h-3.5" />
导出 CSV
</button>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{result.columns && result.columns.length > 0 ? (
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<div
ref={resultContainerRef}
onScroll={handleResultScroll}
className="overflow-auto max-h-[450px]"
>
<table className="w-full text-sm">
<thead className="sticky top-0">
<thead className="sticky top-0 z-10">
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-3 py-2.5 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
{result.columns.map(col => (
<th key={col} className="text-left px-4 py-3 font-semibold text-xs">
{col}
{sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && (
<span className="ml-1.5 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-400 text-[10px] rounded">敏感</span>
)}
<th
key={col}
onClick={() => handleSort(col)}
className="text-left px-3 py-2.5 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none whitespace-nowrap"
>
<div className="flex items-center gap-1">
{col}
<SortIcon col={col} />
{sensitiveKeywords.some(k => col.toLowerCase().includes(k)) && (
<span className="ml-1 px-1 py-0.5 bg-yellow-500/10 text-yellow-400 text-[9px] rounded">敏</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
{result.columns.map(col => {
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
return (
<td key={col} className="px-4 py-3 text-xs">
{isSensitive ? <span className="text-yellow-400 italic">{maskData(String(row[col] ?? ''))}</span> : String(row[col] ?? '')}
</td>
);
})}
</tr>
))}
{/* Virtual scroll: only render visible rows */}
<tr style={{ height: visibleRange.start * rowHeight }} />
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
const realIndex = visibleRange.start + i;
return (
<tr
key={realIndex}
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors"
style={{ height: rowHeight }}
>
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{realIndex + 1}</td>
{result.columns.map(col => {
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
const cellValue = row[col] ?? '';
return (
<td
key={col}
className="px-3 py-2 text-xs max-w-[200px] truncate"
title={String(cellValue)}
>
{isSensitive
? <span className="text-yellow-400 italic">{maskData(String(cellValue))}</span>
: String(cellValue)
}
</td>
);
})}
</tr>
);
})}
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
</tbody>
</table>
</div>
) : (
<div className="p-8 text-center text-[var(--dg-text-muted)]">无结果数据</div>
<div className="p-8 text-center text-[var(--dg-text-muted)] flex flex-col items-center gap-2">
<Eye className="w-6 h-6" />
<span className="text-sm">无结果数据</span>
{result.affected_rows > 0 && (
<span className="text-xs">影响行数: {result.affected_rows}</span>
)}
</div>
)}
</CardContent>
</Card>
)}
<div className="flex justify-end gap-3">
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">
<button
onClick={onCancel}
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-colors"
>
取消
</button>
<button
onClick={handleExecute}
disabled={executing}
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 disabled:opacity-50"
disabled={executing || !instanceId}
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 disabled:opacity-50 transition-all"
>
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{executing ? '执行中...' : '执行查询'}
@@ -391,4 +794,4 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
</div>
</div>
);
}
}