Files
opsxsql/frontend/src/pages/QueriesPage.tsx
T
opsx 4c2c5e182a feat(redis-query): 查询页 Redis 结果面板补导出按钮(CSV / JSON / 导出全部)
问题:上一轮把导出按钮只加在了「键浏览页」(RedisDetailPage → RedisKeyDetailPanel),
用户在「查询页」(/queries → RedisKeyValuePanel)看结果,那边一个导出按钮都没有。

两个面板是不同组件,只改一个另一个不会生效:
  /queries          → features/query/components/RedisKeyValuePanel.tsx   ← 本次补
  /redis(键浏览)  → features/redis/components/RedisKeyDetailPanel.tsx  ← 上一轮已加

改动:
1. RedisKeyValuePanel 新增 instanceId / db 两个 props(导出需要)
2. 组件内新增导出逻辑:
   - buildPageText()      把当前已加载值转 CSV / JSON(纯前端,不发请求)
   - exportCurrent()      导出当前页,瞬时完成
   - exportAll()          申请一次性票据 → 浏览器原生下载,服务端流式导出全部
3. 选中 key 的头部右侧新增按钮组:CSV / JSON / 导出全部
   (搜索框宽度 64 → 56,给按钮腾位置)
4. QueriesPage 传入 instanceId={Number(instanceId)} db={Number(database||0)}

实测:
  分页详情 type=zset total=83 本页=100条内 ✅
  票据获取 ✅
  无 Authorization 的浏览器式下载 HTTP 200,CSV 84 行 ✅
  bundle: QueriesPage-*.js 含「导出全部」×1、「Redis 键值可视化」×1 ✅
          RedisDetailPage-*.js 含「导出全部」×1(键浏览页仍在)✅

镜像:lghost/opsxsql:latest + v0.1 = sha256:7d72f2a96875
2026-10-27 10:20:25 +00:00

575 lines
21 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { AlertTriangle } from 'lucide-react';
import SchemaSidebar from '@/components/SchemaSidebar';
import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance';
import { redisApi } from '@/api/redis';
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import { instanceDetailApi } from '@/api/instanceDetail';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
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 { QueryTopBar } from '@/features/query/components/QueryTopBar';
import { SqlEditorSection } from '@/features/query/components/SqlEditorSection';
import type { SqlEditorHandle } from '@/components/SqlEditor';
import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel';
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
import type {
QueryInstanceOption,
QueryResult,
RedisKeyDetail,
RedisKeyInfo,
SortDirection,
SortState,
} from '@/features/query/types';
import api from '@/api/request';
import { readStorage, writeStorage } from '@/lib/storage';
export default function QueriesPage() {
// -- 编辑器 state --
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<QueryInstanceOption[]>([]);
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 [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({
open: false,
field: '',
value: '',
format: 'Json',
});
const [redisPattern, setRedisPattern] = useState('');
const [redisType, setRedisType] = useState('');
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
const [redisSelectedKey, setRedisSelectedKey] = useState<RedisKeyDetail | null>(null);
const [redisLoading, setRedisLoading] = useState(false);
const [redisDetailLoading, setRedisDetailLoading] = useState(false);
const [redisValueSearch, setRedisValueSearch] = useState('');
// -- Pagination --
const [pageSize, setPageSize] = useState(100);
const [currentPage, setCurrentPage] = useState(1);
// -- UI state --
const [showTemplates, setShowTemplates] = useState(false);
const [templateSearch, setTemplateSearch] = useState('');
const [templateCategory, setTemplateCategory] = useState('all');
const [showSidebar, setShowSidebar] = useState(true);
const [showResult, setShowResult] = useState(true);
const [showHistory, setShowHistory] = useState(false);
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
// -- Sidebar 拖拽宽度 --
const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280);
const sidebarDragRef = useRef<HTMLDivElement>(null);
// -- 历史 state --
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const sqlEditorRef = useRef<SqlEditorHandle>(null);
/** 最近一次执行的语句(选中片段或全文),分页重放以此为准 */
const lastExecutedSqlRef = useRef('');
/* ---- Data loading ---- */
useEffect(() => {
instanceApi
.accessible('readonly')
.then((res) => {
setInstances(Array.isArray(res) ? res : []);
})
.catch(() => {
setInstances([]);
});
}, []);
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(
() => instances.find((inst) => String(inst.id) === instanceId),
[instances, instanceId],
);
const dbType = normalizeDbType(selectedInstance?.db_type);
const editorLang =
dbType === 'redis' ? ('redis' as const) : dbType === 'mongodb' ? ('mongodb' as const) : ('sql' as const);
useEffect(() => {
setRedisKeys([]);
setRedisSelectedKey(null);
setRedisValueSearch('');
}, [instanceId, database]);
const handleRedisSearch = useCallback(async () => {
if (!instanceId) return;
const pattern = redisPattern.trim();
if (!pattern) {
setRedisKeys([]);
setRedisSelectedKey(null);
return;
}
setRedisLoading(true);
try {
const res = await redisApi.scanKeys(Number(instanceId), {
db: Number(database || 0),
pattern,
type: redisType || undefined,
});
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
} catch {
setRedisKeys([]);
} finally {
setRedisLoading(false);
}
}, [instanceId, database, redisPattern, redisType]);
const loadRedisKeyDetail = useCallback(
async (keyName: string) => {
if (!instanceId) return;
setRedisDetailLoading(true);
try {
const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0));
setRedisSelectedKey(detail as RedisKeyDetail);
setRedisValueSearch('');
} catch {
setRedisSelectedKey(null);
} finally {
setRedisDetailLoading(false);
}
},
[instanceId, database],
);
const copyRedisText = useCallback((text: string) => {
navigator.clipboard?.writeText(text).catch(() => undefined);
}, []);
const handleInspectRedisValue = useCallback((field: string, value: unknown) => {
setRedisLineViewer({
open: true,
field,
value: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
format: 'Json',
});
}, []);
useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
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]);
/* ---- Templates ---- */
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 r = templates;
if (templateCategory !== 'all') r = r.filter((t) => t.category === templateCategory);
if (templateSearch) {
const kw = templateSearch.toLowerCase();
r = r.filter((t) => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw));
}
return r;
}, [templates, templateCategory, templateSearch]);
const handleApplyTemplate = useCallback(
(tpl: QueryTemplate) => {
setSql(getTemplateSQL(tpl, dbType, { db: database || undefined }));
setShowTemplates(false);
},
[dbType, database],
);
const catLabel: Record<string, string> = {
all: '全部',
表结构: '表结构',
性能分析: '性能分析',
数据统计: '数据统计',
基础查询: '基础查询',
};
/* ---- Execute ---- */
const handleExecute = useCallback(
async (pageArg: number | unknown = 1, sqlOverride?: string) => {
const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1;
// 执行范围:显式覆盖 > 选区/光标语句(仅首页) > 上次执行语句(分页) > 编辑器全文
const effectiveSql =
sqlOverride ?? (page === 1 ? (sqlEditorRef.current?.getSqlToRun() ?? sql) : lastExecutedSqlRef.current || sql);
if (!effectiveSql || !instanceId) {
setError('请选择实例并输入查询语句');
return;
}
if (/\{(?:table|db|collection)\}/.test(effectiveSql)) {
setError('SQL 中含有未替换的占位符,请先替换或删除');
return;
}
lastExecutedSqlRef.current = effectiveSql;
setExecuting(true);
setError('');
setCurrentPage(page);
if (page === 1) setResult(null);
try {
const offset = (page - 1) * pageSize;
const res = (await queryApi.execute({
instance_id: Number(instanceId),
database: database || undefined,
sql: effectiveSql,
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);
setResult(null);
} else {
setResult(normalized);
}
if (page === 1) {
// 历史由后端执行时自动落库,这里只触发抽屉刷新
setHistoryRefreshKey((k) => k + 1);
}
} 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('; ')
: typeof raw === 'string'
? raw
: raw
? JSON.stringify(raw)
: '查询失败';
setError(msg);
} finally {
setExecuting(false);
}
},
[sql, instanceId, database, dbType, pageSize],
);
/* ---- Result Sorting ---- */
const sortedResult = useMemo(() => {
if (!result || !sortState.column || !sortState.direction) return result;
if (!Array.isArray(result.rows)) return result;
const col = sortState.column;
const dir = sortState.direction === 'asc' ? 1 : -1;
return {
...result,
rows: [...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;
}),
};
}, [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 handleExportResult = useCallback(async () => {
if (!sql || !instanceId) return;
try {
const blob = await api.post<unknown, Blob>(
'/query/export',
{
instance_id: Number(instanceId),
database: database || undefined,
sql,
},
{ responseType: '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 || '导出失败');
}
}, [sql, instanceId, database]);
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
const handlePageSizeChange = useCallback(
(size: number) => {
setPageSize(size);
setCurrentPage(1);
handleExecute(1);
},
[handleExecute],
);
// -- Sidebar 拖拽 --
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = sideWidth;
const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const next = Math.min(600, Math.max(200, startWidth + delta));
setSideWidth(next);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
setSideWidth((w) => {
writeStorage('querySideWidth', String(w));
return w;
});
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[sideWidth],
);
const handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []);
const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []);
const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []);
const handleToggleResult = useCallback(() => setShowResult((v) => !v), []);
return (
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
{/* ====== 主区域 ====== */}
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
{/* -- 顶栏: 实例+库选择 -- */}
<QueryTopBar
instances={instances}
instanceId={instanceId}
selectedInstance={selectedInstance}
database={database}
databases={databases}
loadingDbs={loadingDbs}
dbType={dbType}
showSidebar={showSidebar}
showTemplates={showTemplates}
showHistory={showHistory}
executing={executing}
templateCount={templates.length}
onInstanceIdChange={setInstanceId}
onDatabaseChange={setDatabase}
onToggleSidebar={handleToggleSidebar}
onToggleTemplates={handleToggleTemplates}
onToggleHistory={handleToggleHistory}
onExecute={handleExecute}
/>
{showTemplates && (
<QueryTemplatePanel
categories={templateCategories}
activeCategory={templateCategory}
categoryLabels={catLabel}
search={templateSearch}
templates={filteredTemplates}
onCategoryChange={setTemplateCategory}
onSearchChange={setTemplateSearch}
onApplyTemplate={handleApplyTemplate}
/>
)}
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
<div className="flex-1 min-h-0 flex gap-0 overflow-visible md:overflow-hidden">
{/* Schema 侧边栏(左侧,独立) */}
{showSidebar && instanceId && (
<>
<div
className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto"
style={{ width: sideWidth + 'px' }}
>
<SchemaSidebar
instanceId={instanceId}
database={database}
dbType={dbType}
clusterMode={selectedInstance?.cluster_mode}
onInsert={(text) => {
if (dbType === 'redis') {
setSql(text);
} else if (/^\s*SELECT\b/i.test(text)) {
// 左侧栏点表生成的 SELECT 语句:直接替换编辑器内容,
// 避免和上一条语句拼接成非法 SQL
setSql(text);
} else {
// 插入表名/字段名:追加到现有内容后
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
}
}}
onExecuteSql={(sqlText) => {
setSql(sqlText);
handleExecute(1, sqlText);
}}
onDatabaseChange={setDatabase}
singleDb={!!database}
/>
</div>
<div
ref={sidebarDragRef}
className="hidden md:block w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
onMouseDown={handleDragStart}
>
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
</>
)}
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{dbType === 'redis' && instanceId && (
<RedisKeyValuePanel
pattern={redisPattern}
typeFilter={redisType}
keys={redisKeys}
selectedKey={redisSelectedKey}
loading={redisLoading}
detailLoading={redisDetailLoading}
valueSearch={redisValueSearch}
hideOnWide={showSidebar}
onPatternChange={setRedisPattern}
onTypeFilterChange={setRedisType}
onSearch={handleRedisSearch}
onLoadKeyDetail={loadRedisKeyDetail}
onValueSearchChange={setRedisValueSearch}
onCopy={copyRedisText}
onInspect={handleInspectRedisValue}
instanceId={Number(instanceId)}
db={Number(database || 0)}
/>
)}
{/* SQL 编辑器 */}
<SqlEditorSection
sql={sql}
dbType={dbType}
editorLang={editorLang}
schema={editorSchema}
onSqlChange={setSql}
onExecute={handleExecute}
editorRef={sqlEditorRef}
/>
{/* -- 错误提示 -- */}
{error && (
<div className="shrink-0 px-4 pb-2">
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
</div>
)}
{/* -- 查询结果 (编辑器下方,独立滚动,含虚拟滚动表格/分页/raw 展示) -- */}
{result && (
<QueryResultPanel
result={result}
sortedResult={sortedResult}
sortState={sortState}
dbType={dbType}
sql={sql}
showResult={showResult}
pageSize={pageSize}
currentPage={currentPage}
onToggleResult={handleToggleResult}
onExportResult={handleExportResult}
onSort={handleSort}
onViewRedisLine={setRedisLineViewer}
onPageSizeChange={handlePageSizeChange}
onPageChange={handleExecute}
/>
)}
</div>
{/* end 编辑器+结果区 */}
</div>
{/* end 主体区 (sidebar + 编辑器+结果) */}
</div>
{/* end 主区域 */}
{showHistory && (
<QueryHistoryDrawer
onClose={() => setShowHistory(false)}
onUseSql={(sqlText, run) => {
setSql(sqlText);
if (run) handleExecute(1, sqlText);
}}
refreshKey={historyRefreshKey}
/>
)}
<RedisValueViewerDialog
state={redisLineViewer}
onChange={setRedisLineViewer}
onClose={() => setRedisLineViewer({ open: false, field: '', value: '', format: 'Json' })}
/>
</div>
);
}