diff --git a/frontend/src/features/query/components/RedisKeyValuePanel.tsx b/frontend/src/features/query/components/RedisKeyValuePanel.tsx index 73681c9..0c941df 100644 --- a/frontend/src/features/query/components/RedisKeyValuePanel.tsx +++ b/frontend/src/features/query/components/RedisKeyValuePanel.tsx @@ -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 (
@@ -133,10 +220,38 @@ export function RedisKeyValuePanel({ TTL {formatTTL(selectedKey.ttl)}
-
+
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="搜索字段或值" />
+ {/* 导出:当前页走纯前端,全部走服务端流式(票据) */} +
+ + + +
{selectedKey.type === 'string' ? ( diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index 883cf96..56bc45e 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -502,6 +502,8 @@ export default function QueriesPage() { onValueSearchChange={setRedisValueSearch} onCopy={copyRedisText} onInspect={handleInspectRedisValue} + instanceId={Number(instanceId)} + db={Number(database || 0)} /> )}