后端: - 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时中间滚动)
897 lines
40 KiB
TypeScript
897 lines
40 KiB
TypeScript
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>
|
||
);
|
||
}
|