Files
opsxsql/frontend/src/pages/QueriesPage.tsx
T
logan 08f0f88cf1 feat: 工单列表重设计+权限组实例过滤+通知修复
后端:
- Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841)
- create_ticket 自动生成工单编号(flush后生成)
- _enrich_ticket 新增 instance_name, current_approver_name 字段
- current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人
- TicketResponse schema 新增 ticket_no, instance_name, current_approver_name
- 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass)
- instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例)
- instance_group_members 脏数据清理, 重建 group_id=2 正确数据
- notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行
- test_notification endpoint 改为真正调用 NotificationSender.send()
- auditor 角色添加到 audit_rule/ticket 只读端点
- seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia)
- SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据

前端:
- TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作
- 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败
- 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息
- 状态流程可视化(提交→审批→通过→执行→完成)
- 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计)
- TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例
- QueriesPage: 用 accessible(readonly) 过滤查询实例
- instance.ts 新增 accessible 方法
- 分页优化: 动态计算页码范围(总页>5时中间滚动)
2026-05-24 22:55:51 +08:00

897 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star,
StarOff, Download, Search, ArrowUpDown, Database, Table2,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance';
import SqlEditor from '@/components/SqlEditor';
import SchemaSidebar from '@/components/SchemaSidebar';
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import { redisApi } from '@/api/redis';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface QueryResult {
columns: string[];
rows: Record<string, unknown>[];
affected_rows: number;
execution_time_ms: number;
}
interface QueryHistoryItem {
id: number;
sql: string;
instance_id: number;
database: string;
db_type: string;
status: string;
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',
};
const dbTypeIcon: Record<string, string> = {
mysql: '/db-logos/mysql.svg',
postgresql: '/db-logos/postgres.svg',
redis: '/db-logos/redis.svg',
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[]>(() => {
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={handleExecuted}
history={history}
onToggleFavorite={toggleFavorite}
/>
);
}
return (
<div className="space-y-5">
<PageHeader
title="查询数据"
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
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-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>
{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-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-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-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-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>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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 [databases, setDatabases] = useState<string[]>([]);
const [loadingDbs, setLoadingDbs] = useState(false);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string }>>([]);
const [editorSchema, setEditorSchema] = useState<Record<string, Record<string, 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);
const [showSidebar, setShowSidebar] = useState(true);
useEffect(() => {
instanceApi.accessible('readonly').then(res => {
const data = Array.isArray(res) ? res : [];
setInstances(data);
}).catch(() => { setInstances([]); });
}, []);
// 选实例后加载数据库列表 + 表名(用于 SQL 自动补全)
useEffect(() => {
if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; }
const inst = instances.find(i => i.id === Number(instanceId));
if (inst?.db_type === 'redis') {
setDatabases([]); setDatabase('0'); setEditorSchema({}); return;
}
const type = inst?.db_type || '';
if (type === 'mysql' || type === 'postgres' || type === 'postgresql') {
setLoadingDbs(true);
instanceDetailApi.listDatabases(Number(instanceId))
.then(async data => {
const dbs = Array.isArray(data) ? data : [];
setDatabases(dbs);
// 快速加载所有库的表名(不含列名)
const schema: Record<string, Record<string, string[]>> = {};
await Promise.all(dbs.map(async db => {
try {
const tables = await instanceDetailApi.listTables(Number(instanceId), db);
if (Array.isArray(tables)) {
schema[db] = {};
for (const t of tables) {
schema[db][t.name] = [];
}
}
} catch { /* ignore */ }
}));
setEditorSchema(schema);
})
.catch(() => { setDatabases([]); setEditorSchema({}); })
.finally(() => setLoadingDbs(false));
} else {
setDatabases([]);
setEditorSchema({});
}
}, [instanceId, instances]);
const selectedInstance = useMemo(() => {
return instances.find(inst => String(inst.id) === instanceId);
}, [instances, instanceId]);
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
// 选了目标数据库后,加载该库的完整列名用于补全
useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
// 排除 Redis 残留的纯数字 database(如 "0")
if (/^\d+$/.test(database)) return;
instanceDetailApi.getDbSchema(Number(instanceId), database)
.then(data => {
if (data && typeof data === 'object') {
setEditorSchema(prev => ({
...prev,
[database]: data as Record<string, string[]>,
}));
}
})
.catch(() => { /* ignore */ });
}, [instanceId, database, dbType]);
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 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 = useCallback((tpl: QueryTemplate) => {
const templateSQL = getTemplateSQL(tpl, dbType, { db: database || undefined });
setSql(templateSQL);
setShowTemplates(false);
}, [dbType, database]);
const handleExecute = useCallback(async () => {
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
// 检查未替换的模板占位符
if (/\{(?:table|db|collection)\}/.test(sql)) {
setError('SQL 中含有未替换的占位符 {table}/{db}/{collection},请先替换或删除'); return;
}
setExecuting(true);
setError('');
setResult(null);
try {
const res = await queryApi.execute({
instance_id: Number(instanceId),
database: database || undefined,
sql,
}) as unknown as QueryResult;
const execTime = res.execution_time_ms || 0;
setResult(res);
onExecuted({
id: Date.now(),
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'success',
execution_time_ms: execTime,
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
created_at: new Date().toISOString(),
favorite: false,
});
} catch (e: unknown) {
const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail;
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '查询失败');
setError(msg);
onExecuted({
id: Date.now(),
sql,
instance_id: Number(instanceId),
database,
db_type: dbType,
status: 'failed',
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" />;
};
/* ---- 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 catLabel: Record<string, string> = {
all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询',
};
return (
<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)] border border-[var(--dg-border)]">
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
{dbTypeLabel[dbType] || dbType}
</span>
)}
</div>
<div className="flex items-center gap-2">
<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>
<button
onClick={() => setSql(h.sql)}
className="p-1 rounded text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="粘贴到编辑器"
>
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Instance + Database selector */}
<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 transition-colors"
>
<option value="">请选择实例</option>
{instances.map(inst => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.host}:{inst.port}) — {dbTypeLabel[inst.db_type] || inst.db_type}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">目标数据库</label>
{dbType === 'redis' ? (
<select
value={database || '0'}
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 transition-colors"
>
{Array.from({ length: 16 }, (_, i) => (
<option key={i} value={String(i)}>DB{i}</option>
))}
</select>
) : (
<select
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 transition-colors"
>
<option value="">{loadingDbs ? '加载中...' : databases.length > 0 ? '请选择数据库(可选)' : '选择实例后可选'}</option>
{databases.map(db => (
<option key={db} value={db}>{db}</option>
))}
</select>
)}
</div>
</div>
{/* Template selector */}
<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 text-[var(--dg-text-secondary)]" />
查询语句 <span className="text-red-400">*</span>
</label>
<div className="flex items-center gap-2">
{instanceId && (
<button
onClick={() => setShowSidebar(!showSidebar)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all ${
showSidebar
? '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)]'
}`}
>
<Database className="w-3.5 h-3.5" />
{showSidebar ? '隐藏导航' : '显示导航'}
</button>
)}
<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)] 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})
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
{showTemplates && (
<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={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'
}`}
>
{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>
)}
{/* CodeMirror Editor + Schema Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* Schema Sidebar */}
{showSidebar && instanceId && (
<div className="lg:col-span-1 h-[280px]">
<SchemaSidebar
instanceId={instanceId}
database={database}
dbType={dbType}
onInsert={(text) => setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')}
/>
</div>
)}
{/* Editor */}
<div className={showSidebar && instanceId ? 'lg:col-span-3' : 'lg:col-span-4'}>
<div className="dg-card overflow-hidden">
<SqlEditor
value={sql}
onChange={setSql}
placeholder={placeholderMap[dbType]}
language={editorLang}
height="240px"
onExecute={handleExecute}
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined}
/>
<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)]">
<span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
{dbTypeLabel[dbType]} 模式
</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>
</div>
</div>
{/* Security notice */}
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-xs text-[var(--dg-text-secondary)]">
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
</span>
</div>
{error && (
<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 */}
{result && (
<Card className="dg-card overflow-hidden">
<CardHeader className="border-b border-[var(--dg-border)]">
<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" />
查询结果
</CardTitle>
<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
ref={resultContainerRef}
onScroll={handleResultScroll}
className="overflow-auto max-h-[450px]"
>
<table className="w-full text-sm">
<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}
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>
{/* 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)] 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)] transition-colors"
>
取消
</button>
<button
onClick={handleExecute}
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 ? '执行中...' : '执行查询'}
</button>
</div>
</div>
);
}