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:
opsx committed 2026-10-27 10:20:25 +00:00
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' ? (
+2
View File
@@ -502,6 +502,8 @@ export default function QueriesPage() {
onValueSearchChange={setRedisValueSearch}
onCopy={copyRedisText}
onInspect={handleInspectRedisValue}
instanceId={Number(instanceId)}
db={Number(database || 0)}
/>
)}