feat: 查询分页(limit/offset)+流式CSV导出+去复制按钮

后端:
- ExecuteQuery 添加 limit/offset 参数,MySQL 自动注入 LIMIT/OFFSET
- 首页自动 COUNT 查总数(total),翻页页不再重复 count
- 新增 POST /query/export 流式 CSV 导出端点,游标分批写不占内存
- 默认 limit=100, max=1000,防止大数据量 OOM

前端:
- 查询结果加分页控件(首页/上一页/下一页/末页 + 每页条数选择)
- CSV 导出改走流式端点(全量数据),不限于当前页
- 去掉复制按钮(Copy/Check/coped state),Redis 单值也不显示复制
- 工具栏显示 总行数/行每页/列数
This commit is contained in:
logan committed 2026-05-28 15:51:22 +08:00
1 parent 03ff845485
commit 5ea86fe334
4 files changed
+232 -61

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
import request from './request';
export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string }) =>
execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) =>
request.post<any, any>('/query', data),
formatSQL: (sql: string) =>
+71 -57
View File
@@ -5,7 +5,7 @@ import {
StarOff, Download, Search, ArrowUpDown, Database, Table2,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
History, PanelRightClose, PanelRightOpen, Copy, Check,
History, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
} from 'lucide-react';
// Card components replaced by design system utility classes
import { useActionPermission } from '@/hooks/useAuth';
@@ -23,6 +23,8 @@ import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
interface QueryResult {
columns: string[];
rows: Record<string, unknown>[];
total: number;
count: number;
affected_rows: number;
execution_time_ms: number;
cost_ms?: number;
@@ -123,6 +125,10 @@ export default function QueriesPage() {
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
// -- Pagination --
const [pageSize, setPageSize] = useState(100);
const [currentPage, setCurrentPage] = useState(1);
// -- UI state --
const [showTemplates, setShowTemplates] = useState(false);
const [templateSearch, setTemplateSearch] = useState('');
@@ -209,12 +215,14 @@ export default function QueriesPage() {
const catLabel: Record<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' };
/* ---- Execute ---- */
const handleExecute = useCallback(async () => {
const handleExecute = useCallback(async (page = 1) => {
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; }
setExecuting(true); setError(''); setResult(null);
setExecuting(true); setError(''); setCurrentPage(page);
if (page === 1) setResult(null);
try {
const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql }) as unknown as QueryResult;
const offset = (page - 1) * pageSize;
const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql, limit: pageSize, offset }) as unknown as QueryResult;
const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 };
if (normalized.error) {
setError(normalized.error);
@@ -222,14 +230,18 @@ export default function QueriesPage() {
} else {
setResult(normalized);
}
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]);
if (page === 1) {
setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]);
}
} 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);
setHistory(prev => [{ 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 }, ...prev]);
if (page === 1) {
setHistory(prev => [{ 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 }, ...prev]);
}
} finally { setExecuting(false); }
}, [sql, instanceId, database, dbType]);
}, [sql, instanceId, database, dbType, pageSize]);
/* ---- Result Sorting ---- */
const sortedResult = useMemo(() => {
@@ -264,38 +276,26 @@ export default function QueriesPage() {
}, [sortedResult, rowHeight]);
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
const handleExportResult = useCallback(() => {
if (!result) return;
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, exportToCSV(result.columns, result.rows));
}, [result]);
/* ---- Copy Result ---- */
const [copied, setCopied] = useState(false);
const handleCopyResult = useCallback(() => {
if (!result) return;
let text = '';
if (result.rows?.length && result.columns?.length) {
// Format as readable text
const lines = result.rows.map((row: Record<string, unknown>) =>
result.columns.map((col: string) => {
const val = String(row[col] ?? '');
// Try to pretty-format JSON values
try {
const parsed = JSON.parse(val);
if (typeof parsed === 'object') return JSON.stringify(parsed, null, 2);
} catch { /* not JSON */ }
return val;
}).join('\t')
);
text = [result.columns.join('\t'), ...lines].join('\n');
} else {
text = JSON.stringify(result, null, 2);
const handleExportResult = useCallback(async () => {
if (!sql || !instanceId) return;
try {
const resp = await fetch('/api/v1/query/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ instance_id: Number(instanceId), database: database || undefined, sql }),
});
if (!resp.ok) throw new Error('导出失败');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setError((e as Error).message || '导出失败');
}
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [result]);
}, [sql, instanceId, database]);
/* ---- Smart cell value formatter (detect JSON) ---- */
const formatCellValue = useCallback((val: unknown) => {
@@ -486,24 +486,19 @@ export default function QueriesPage() {
<div className="flex items-center gap-3 text-xs dg-text-secondary">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms || result.cost_ms || 0}ms</span>
{result.raw_text !== undefined ? (
<span>{result.raw_value ? 'JSON' : typeof result.raw_text === 'string' ? 'String' : 'Value'}</span>
<span>{result.raw_value ? 'JSON' : 'String'}</span>
) : (
<>
<span>{result.rows?.length || 0} 行</span>
<span>{result.total || result.rows?.length || 0} 总行</span>
<span>{result.rows?.length || 0} 行/页</span>
<span>{result.columns?.length || 0} 列</span>
</>
)}
{(result.rows?.length > 0 || result.raw_text !== undefined) && (
<>
<button onClick={handleCopyResult}
className="dg-btn dg-btn-ghost dg-btn-sm gap-1">
{copied ? <><Check className="w-3 h-3 text-green-400" /><span className="text-green-400">已复制</span></> : <><Copy className="w-3 h-3" />复制</>}
</button>
<button onClick={handleExportResult}
className="dg-btn dg-btn-ghost dg-btn-sm">
<Download className="w-3 h-3" />导出 CSV
</button>
</>
{result.rows?.length > 0 && dbType !== 'redis' && (
<button onClick={handleExportResult}
className="dg-btn dg-btn-ghost dg-btn-sm">
<Download className="w-3 h-3" />导出 CSV
</button>
)}
</div>
</div>
@@ -524,12 +519,6 @@ export default function QueriesPage() {
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
: String(result.raw_text)
}</pre>
<button onClick={() => {
const text = result.raw_value ? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })() : String(result.raw_text);
navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
}} className="absolute top-2 right-2 dg-btn dg-btn-ghost dg-btn-sm opacity-0 group-hover:opacity-100 transition-opacity">
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
) : result.columns && result.columns.length > 0 ? (
@@ -576,6 +565,31 @@ export default function QueriesPage() {
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
</tbody>
</table>
{/* Pagination */}
{result.total > pageSize && (
<div className="flex items-center justify-between px-4 py-2 text-xs dg-text-secondary border-t border-[var(--dg-border)]">
<div className="flex items-center gap-2">
<span>每页</span>
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); handleExecute(1); }}
className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded px-1 py-0.5 text-xs">
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n}</option>)}
</select>
<span>条</span>
</div>
<div className="flex items-center gap-1">
<button disabled={currentPage <= 1} onClick={() => handleExecute(1)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3 rotate-180" /></button>
<button disabled={currentPage <= 1} onClick={() => handleExecute(currentPage - 1)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3" /></button>
<span className="px-2">{currentPage} / {Math.ceil(result.total / pageSize)}</span>
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(currentPage + 1)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3 rotate-180" /></button>
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(Math.ceil(result.total / pageSize))}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3" /></button>
</div>
<span>共 {result.total} 条</span>
</div>
)}
</div>
) : (
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">