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 { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||||
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||||
import type { RedisKeyDetail, RedisKeyInfo } from '../types';
|
import type { RedisKeyDetail, RedisKeyInfo } from '../types';
|
||||||
@@ -29,6 +31,9 @@ interface RedisKeyValuePanelProps {
|
|||||||
onCopy: (text: string) => void;
|
onCopy: (text: string) => void;
|
||||||
/** 查看单行值(field/value 结构化查看) */
|
/** 查看单行值(field/value 结构化查看) */
|
||||||
onInspect: (field: string, value: unknown) => void;
|
onInspect: (field: string, value: unknown) => void;
|
||||||
|
/** Redis 实例 ID 与 db 号:导出(含「导出全部」流式下载)需要 */
|
||||||
|
instanceId?: number;
|
||||||
|
db?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redis 键值可视化面板:pattern 扫描 key 列表 + 结构化值查看 */
|
/** Redis 键值可视化面板:pattern 扫描 key 列表 + 结构化值查看 */
|
||||||
@@ -48,7 +53,89 @@ export function RedisKeyValuePanel({
|
|||||||
onValueSearchChange,
|
onValueSearchChange,
|
||||||
onCopy,
|
onCopy,
|
||||||
onInspect,
|
onInspect,
|
||||||
|
instanceId,
|
||||||
|
db = 0,
|
||||||
}: RedisKeyValuePanelProps) {
|
}: 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 (
|
return (
|
||||||
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${hideOnWide ? 'md:hidden' : ''}`}>
|
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${hideOnWide ? 'md:hidden' : ''}`}>
|
||||||
<div className="dg-card overflow-hidden">
|
<div className="dg-card overflow-hidden">
|
||||||
@@ -133,10 +220,38 @@ export function RedisKeyValuePanel({
|
|||||||
<span>TTL {formatTTL(selectedKey.ttl)}</span>
|
<span>TTL {formatTTL(selectedKey.ttl)}</span>
|
||||||
</div>
|
</div>
|
||||||
</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)]" />
|
<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="搜索字段或值" />
|
<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>
|
||||||
|
{/* 导出:当前页走纯前端,全部走服务端流式(票据) */}
|
||||||
|
<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>
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
{selectedKey.type === 'string' ? (
|
{selectedKey.type === 'string' ? (
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ export default function QueriesPage() {
|
|||||||
onValueSearchChange={setRedisValueSearch}
|
onValueSearchChange={setRedisValueSearch}
|
||||||
onCopy={copyRedisText}
|
onCopy={copyRedisText}
|
||||||
onInspect={handleInspectRedisValue}
|
onInspect={handleInspectRedisValue}
|
||||||
|
instanceId={Number(instanceId)}
|
||||||
|
db={Number(database || 0)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user