From 4c2c5e182a66dd370e9457ef491444a36d332222 Mon Sep 17 00:00:00 2001 From: opsx Date: Tue, 27 Oct 2026 10:20:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(redis-query):=20=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E9=A1=B5=20Redis=20=E7=BB=93=E6=9E=9C=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E8=A1=A5=E5=AF=BC=E5=87=BA=E6=8C=89=E9=92=AE=EF=BC=88CSV=20/?= =?UTF-8?q?=20JSON=20/=20=E5=AF=BC=E5=87=BA=E5=85=A8=E9=83=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:上一轮把导出按钮只加在了「键浏览页」(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 --- .../query/components/RedisKeyValuePanel.tsx | 119 +++++++++++++++++- frontend/src/pages/QueriesPage.tsx | 2 + 2 files changed, 119 insertions(+), 2 deletions(-) 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)} /> )}