feat: harden query read-only workflow

This commit is contained in:
Logan.Li committed 2026-07-07 17:20:44 +08:00
1 parent 58bbc394e9
commit 617c15dbfe
29 files changed
+1417 -395

No files matched your search

+49 -186
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
X, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star,
StarOff, Download, Search, ArrowUpDown, Database, Table2,
AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, ChevronRight, Clock, FileText, Sparkles,
Download, Search, ArrowUpDown, Database, Table2,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
History, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
} from 'lucide-react';
// Card components replaced by design system utility classes
import { useActionPermission } from '@/hooks/useAuth';
@@ -16,10 +16,14 @@ import { instanceApi } from '@/api/instance';
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
import type { RedisLineEditorState } from '@/features/redis/types';
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import type { QueryHistoryItem } from '@/features/query/types';
import { downloadCSV, exportToCSV } from '@/features/query/utils';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface QueryResult {
columns: string[];
rows: Record<string, unknown>[];
@@ -37,25 +41,9 @@ interface QueryResult {
masked_columns?: string[];
}
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', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
};
@@ -93,35 +81,6 @@ const queryPolicyText: Record<DBType, string> = {
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
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() {
// -- 编辑器 state --
const [sql, setSql] = useState('');
@@ -134,6 +93,7 @@ export default function QueriesPage() {
const [executing, setExecuting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
// -- Pagination --
const [pageSize, setPageSize] = useState(100);
@@ -367,9 +327,6 @@ export default function QueriesPage() {
return r;
}, [history, favFilter, searchHistory]);
/* ================================================================ */
/* Render */
/* ================================================================ */
return (
<div className="flex h-[calc(100vh-3rem)] gap-0">
{/* ====== 主区域 ====== */}
@@ -435,44 +392,17 @@ export default function QueriesPage() {
</div>
</div>
{/* -- 模板选择器 -- */}
{showTemplates && (
<div className="shrink-0 px-5 pb-2">
<div className="dg-card p-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
{templateCategories.map(cat => (
<button key={cat} onClick={() => setTemplateCategory(cat)}
className={`dg-btn dg-btn-ghost dg-btn-sm ${templateCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
{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 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>
{filteredTemplates.length === 0 ? (
<div className="text-center py-4 text-xs dg-text-muted">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{filteredTemplates.map(tpl => (
<button key={tpl.id} onClick={() => handleApplyTemplate(tpl)}
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group"
title={tpl.description}>
<div className="flex items-center gap-2 mb-0.5">
<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 dg-text-muted line-clamp-1">{tpl.description}</p>
</button>
))}
</div>
)}
</div>
</div>
<QueryTemplatePanel
categories={templateCategories}
activeCategory={templateCategory}
categoryLabels={catLabel}
search={templateSearch}
templates={filteredTemplates}
onCategoryChange={setTemplateCategory}
onSearchChange={setTemplateSearch}
onApplyTemplate={handleApplyTemplate}
/>
)}
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
@@ -622,28 +552,14 @@ export default function QueriesPage() {
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto">
{/* ARDM-style: Hash field/value compact list for Redis HGETALL */}
{dbType === 'redis' && result.columns.length === 2 && result.columns[0] === 'field' && result.columns[1] === 'value' ? (
<div className="divide-y divide-[var(--dg-border)]">
{(sortedResult?.rows || []).map((row, i) => {
const field = String(row.field ?? '');
const value = String(row.value ?? '');
const isJson = (() => { try { const p = JSON.parse(value); return typeof p === 'object'; } catch { return false; } })();
const formatted = isJson ? JSON.stringify(JSON.parse(value), null, 2) : value;
return (
<div key={i} className="flex hover:bg-[var(--dg-bg-hover)] transition-colors">
<div className="w-[200px] shrink-0 px-4 py-2.5 text-xs font-mono text-blue-400 border-r border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
{field}
</div>
<div className="flex-1 px-4 py-2.5 text-xs font-mono whitespace-pre-wrap break-all min-w-0">
{isJson ? (
<pre className="text-blue-300 text-xs whitespace-pre-wrap">{formatted}</pre>
) : (
<span>{value}</span>
)}
</div>
</div>
);
})}
</div>
<RedisHashQueryResult
rows={sortedResult?.rows || []}
rawKey={result.raw_key}
rawType={result.raw_type}
rawTtl={result.raw_ttl}
command={sql}
onView={setRedisLineViewer}
/>
) : (
/* Standard table for other results */
<table className="w-full text-sm">
@@ -747,80 +663,27 @@ export default function QueriesPage() {
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
</div>{/* end 主区域 */}
{/* ====== 右侧历史抽屉 ====== */}
{showHistory && (
<div className="w-[300px] shrink-0 border-l border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
查询历史
{history.length > 0 && <span className="text-xs dg-text-muted font-normal">({history.length})</span>}
</span>
<button onClick={() => setShowHistory(false)} className="dg-btn dg-btn-ghost dg-btn-sm p-1">
<PanelRightClose className="w-4 h-4" />
</button>
</div>
{/* 搜索+筛选 */}
{history.length > 0 && (
<div className="px-4 pb-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史..." value={searchHistory} onChange={e => setSearchHistory(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button onClick={() => setFavFilter(!favFilter)}
className={`dg-btn dg-btn-ghost dg-btn-sm ${favFilter ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : ''}`}>
<Star className="w-3 h-3" />仅收藏
</button>
<div className="flex-1" />
<button onClick={handleClearHistory}
className={`dg-btn dg-btn-ghost dg-btn-sm ${clearConfirm ? 'text-red-400 bg-red-500/10' : ''}`}>
{clearConfirm ? <CheckCircle className="w-3 h-3" /> : <X className="w-3 h-3" />}
{clearConfirm ? '确认清除' : '清除'}
</button>
</div>
</div>
)}
{/* 历史列表 */}
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div>
) : filteredHistory.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
) : (
<div className="space-y-1.5 px-3 py-2">
{filteredHistory.map(h => (
<div key={h.id} className={`dg-card px-3 py-2.5 group ${h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : ''}`}>
<div className="flex items-center gap-2 mb-1">
{h.db_type && dbTypeIcon[h.db_type] && <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />}
<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>
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span>
<div className="flex-1" />
<button onClick={() => toggleFavorite(h.id)}
className={`p-0.5 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400'}`}>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center gap-1">
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<button onClick={() => setSql(h.sql)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
title="粘贴到编辑器">
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
<QueryHistoryDrawer
history={history}
filteredHistory={filteredHistory}
favFilter={favFilter}
searchHistory={searchHistory}
clearConfirm={clearConfirm}
dbTypeIcon={dbTypeIcon}
onClose={() => setShowHistory(false)}
onSearchChange={setSearchHistory}
onToggleFavoriteFilter={() => setFavFilter(!favFilter)}
onClearHistory={handleClearHistory}
onToggleFavorite={toggleFavorite}
onUseSql={setSql}
/>
)}
<RedisValueViewerDialog
state={redisLineViewer}
onChange={setRedisLineViewer}
onClose={() => setRedisLineViewer({ open: false, field: '', value: '', format: 'Json' })}
/>
</div>
);
}