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
This commit is contained in:
1 parent
71d5afff03
commit
4c2c5e182a
2 files changed
+119
-2
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { KeyRound, Loader2, Search } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { KeyRound, Loader2, Search, Download, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { redisApi } from '@/api/redis';
|
||||
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||
import type { RedisKeyDetail, RedisKeyInfo } from '../types';
|
||||
@@ -29,6 +31,9 @@ interface RedisKeyValuePanelProps {
|
||||
onCopy: (text: string) => void;
|
||||
/** 查看单行值(field/value 结构化查看) */
|
||||
onInspect: (field: string, value: unknown) => void;
|
||||
/** Redis 实例 ID 与 db 号:导出(含「导出全部」流式下载)需要 */
|
||||
instanceId?: number;
|
||||
db?: number;
|
||||
}
|
||||
|
||||
/** Redis 键值可视化面板:pattern 扫描 key 列表 + 结构化值查看 */
|
||||
@@ -48,7 +53,89 @@ export function RedisKeyValuePanel({
|
||||
onValueSearchChange,
|
||||
onCopy,
|
||||
onInspect,
|
||||
instanceId,
|
||||
db = 0,
|
||||
}: RedisKeyValuePanelProps) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const keyName = selectedKey?.key || selectedKey?.name || '';
|
||||
|
||||
/** 把当前已加载的值转成 CSV / JSON 文本(纯前端,不发请求) */
|
||||
const buildPageText = (format: 'csv' | 'json'): string => {
|
||||
if (!selectedKey) return '';
|
||||
const type = selectedKey.type;
|
||||
const val: any = selectedKey.value;
|
||||
const cell = (v: any) => {
|
||||
const s = v === null || v === undefined ? '' : String(v);
|
||||
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
};
|
||||
const rows: string[][] = [];
|
||||
if (type === 'hash') {
|
||||
rows.push(['field', 'value']);
|
||||
Object.entries(val || {}).forEach(([f, v]) => rows.push([f, String(v ?? '')]));
|
||||
} else if (type === 'list') {
|
||||
rows.push(['index', 'value']);
|
||||
(val || []).forEach((v: any, i: number) => rows.push([String(i), String(v ?? '')]));
|
||||
} else if (type === 'set') {
|
||||
rows.push(['member']);
|
||||
(val || []).forEach((v: any) => rows.push([String(v ?? '')]));
|
||||
} else if (type === 'zset') {
|
||||
rows.push(['member', 'score']);
|
||||
(val || []).forEach((z: any) => rows.push([String(z.Member ?? z.member ?? ''), String(z.Score ?? z.score ?? '')]));
|
||||
} else {
|
||||
rows.push(['content']);
|
||||
rows.push([String(val ?? '')]);
|
||||
}
|
||||
if (format === 'csv') return rows.map((r) => r.map(cell).join(',')).join('\n');
|
||||
return JSON.stringify(rows, null, 2);
|
||||
};
|
||||
|
||||
const downloadBlob = (text: string, filename: string, mime: string) => {
|
||||
const blob = new Blob(['\ufeff' + text], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
/** 导出当前已加载的这一页(纯前端,瞬时) */
|
||||
const exportCurrent = (format: 'csv' | 'json') => {
|
||||
if (!selectedKey) return;
|
||||
const safe = keyName.replace(/[^\w.-]+/g, '_').slice(0, 80) || 'redis-key';
|
||||
downloadBlob(
|
||||
buildPageText(format),
|
||||
`${safe}.${format}`,
|
||||
format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json;charset=utf-8',
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出全部(服务端流式):先拿一次性票据,再交给浏览器原生下载。
|
||||
* 浏览器边收边写磁盘、服务端边读边写响应,大 key 也不会把内存打爆。
|
||||
*/
|
||||
const exportAll = (format: 'csv' | 'jsonl') => {
|
||||
if (!selectedKey || !instanceId) return;
|
||||
setExporting(true);
|
||||
redisApi
|
||||
.createExportTicket(instanceId, keyName, db)
|
||||
.then((res) => {
|
||||
if (!res?.ticket) throw new Error('未取到导出票据');
|
||||
const url = redisApi.buildExportUrl(instanceId, keyName, db, format, res.ticket);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setExporting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${hideOnWide ? 'md:hidden' : ''}`}>
|
||||
<div className="dg-card overflow-hidden">
|
||||
@@ -133,10 +220,38 @@ export function RedisKeyValuePanel({
|
||||
<span>TTL {formatTTL(selectedKey.ttl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<div className="relative w-full sm:w-56">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||
<input value={valueSearch} onChange={e => onValueSearchChange(e.target.value)} className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" placeholder="搜索字段或值" />
|
||||
</div>
|
||||
{/* 导出:当前页走纯前端,全部走服务端流式(票据) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => exportCurrent('csv')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
title="导出当前已加载的这一页为 CSV"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportCurrent('json')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--dg-border)] px-2 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
title="导出当前已加载的这一页为 JSON"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportAll('csv')}
|
||||
disabled={exporting || !instanceId}
|
||||
className="dg-btn dg-btn-primary h-8 gap-1 px-2.5 text-xs disabled:opacity-50"
|
||||
title="服务端流式导出全部数据(边读边写,不占内存)"
|
||||
>
|
||||
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||||
导出全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{selectedKey.type === 'string' ? (
|
||||
|
||||
@@ -502,6 +502,8 @@ export default function QueriesPage() {
|
||||
onValueSearchChange={setRedisValueSearch}
|
||||
onCopy={copyRedisText}
|
||||
onInspect={handleInspectRedisValue}
|
||||
instanceId={Number(instanceId)}
|
||||
db={Number(database || 0)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user