feat: Redis查询结果优化 — 复制按钮+JSON自动格式化+语义化列名
1. GET/HGET/TTL等单值命令: columns改为[key,value]替代[command,result] 2. DBSIZE等无key命令保持[command,result]格式 3. 前端添加复制按钮(Clipboard API), 2秒反馈 4. JSON值自动检测并格式化显示(whitespace-pre-wrap) 5. 结果区最大高度350px→500px 6. cost_ms字段兼容处理
This commit is contained in:
1 parent
828a0597c5
commit
18430f3717
2 files changed
+77
-14
No files matched your search
@@ -702,11 +702,25 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
|||||||
}
|
}
|
||||||
columns = []string{"field", "value"}
|
columns = []string{"field", "value"}
|
||||||
} else {
|
} else {
|
||||||
rows = append(rows, map[string]interface{}{
|
// Single value result (GET, HGET, TYPE, TTL, etc.)
|
||||||
"command": command,
|
// Try to extract key name from command
|
||||||
"result": fmt.Sprintf("%v", v),
|
keyName := ""
|
||||||
})
|
if len(parts) > 1 {
|
||||||
columns = []string{"command", "result"}
|
keyName = parts[1]
|
||||||
|
}
|
||||||
|
if keyName != "" {
|
||||||
|
rows = append(rows, map[string]interface{}{
|
||||||
|
"key": keyName,
|
||||||
|
"value": fmt.Sprintf("%v", v),
|
||||||
|
})
|
||||||
|
columns = []string{"key", "value"}
|
||||||
|
} else {
|
||||||
|
rows = append(rows, map[string]interface{}{
|
||||||
|
"command": command,
|
||||||
|
"result": fmt.Sprintf("%v", v),
|
||||||
|
})
|
||||||
|
columns = []string{"command", "result"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
StarOff, Download, Search, ArrowUpDown, Database, Table2,
|
StarOff, Download, Search, ArrowUpDown, Database, Table2,
|
||||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
||||||
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
||||||
History, PanelRightClose, PanelRightOpen,
|
History, PanelRightClose, PanelRightOpen, Copy, Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
// Card components replaced by design system utility classes
|
// Card components replaced by design system utility classes
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
@@ -264,6 +264,46 @@ export default function QueriesPage() {
|
|||||||
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, exportToCSV(result.columns, result.rows));
|
downloadCSV(`query_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, exportToCSV(result.columns, result.rows));
|
||||||
}, [result]);
|
}, [result]);
|
||||||
|
|
||||||
|
/* ---- Copy Result ---- */
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const handleCopyResult = useCallback(() => {
|
||||||
|
if (!result) return;
|
||||||
|
let text = '';
|
||||||
|
if (result.rows?.length && result.columns?.length) {
|
||||||
|
// Format as readable text
|
||||||
|
const lines = result.rows.map((row: Record<string, unknown>) =>
|
||||||
|
result.columns.map((col: string) => {
|
||||||
|
const val = String(row[col] ?? '');
|
||||||
|
// Try to pretty-format JSON values
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(val);
|
||||||
|
if (typeof parsed === 'object') return JSON.stringify(parsed, null, 2);
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
return val;
|
||||||
|
}).join('\t')
|
||||||
|
);
|
||||||
|
text = [result.columns.join('\t'), ...lines].join('\n');
|
||||||
|
} else {
|
||||||
|
text = JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
/* ---- Smart cell value formatter (detect JSON) ---- */
|
||||||
|
const formatCellValue = useCallback((val: unknown) => {
|
||||||
|
const str = String(val ?? '');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(str);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
return { text: JSON.stringify(parsed, null, 2), isJson: true };
|
||||||
|
}
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
return { text: str, isJson: false };
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* ---- History ---- */
|
/* ---- History ---- */
|
||||||
const toggleFavorite = useCallback((id: number) => {
|
const toggleFavorite = useCallback((id: number) => {
|
||||||
setHistory(prev => {
|
setHistory(prev => {
|
||||||
@@ -439,21 +479,27 @@ export default function QueriesPage() {
|
|||||||
查询结果
|
查询结果
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
<div className="flex items-center gap-3 text-xs dg-text-secondary">
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms}ms</span>
|
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{result.execution_time_ms || result.cost_ms || 0}ms</span>
|
||||||
<span>{result.rows?.length || 0} 行</span>
|
<span>{result.rows?.length || 0} 行</span>
|
||||||
<span>{result.columns?.length || 0} 列</span>
|
<span>{result.columns?.length || 0} 列</span>
|
||||||
{result.rows && result.rows.length > 0 && (
|
{result.rows && result.rows.length > 0 && (
|
||||||
<button onClick={handleExportResult}
|
<>
|
||||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
<button onClick={handleCopyResult}
|
||||||
<Download className="w-3 h-3" />导出 CSV
|
className="dg-btn dg-btn-ghost dg-btn-sm gap-1">
|
||||||
</button>
|
{copied ? <><Check className="w-3 h-3 text-green-400" /><span className="text-green-400">已复制</span></> : <><Copy className="w-3 h-3" />复制</>}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleExportResult}
|
||||||
|
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||||
|
<Download className="w-3 h-3" />导出 CSV
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
{result.columns && result.columns.length > 0 ? (
|
{result.columns && result.columns.length > 0 ? (
|
||||||
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto max-h-[350px]">
|
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto max-h-[500px]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="dg-table-header">
|
<tr className="dg-table-header">
|
||||||
@@ -481,9 +527,12 @@ export default function QueriesPage() {
|
|||||||
{result.columns.map(col => {
|
{result.columns.map(col => {
|
||||||
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
|
const isSensitive = sensitiveKeywords.some(k => col.toLowerCase().includes(k));
|
||||||
const cellValue = row[col] ?? '';
|
const cellValue = row[col] ?? '';
|
||||||
|
const { text: displayText, isJson } = formatCellValue(cellValue);
|
||||||
return (
|
return (
|
||||||
<td key={col} className="px-3 py-2 text-xs max-w-[200px] truncate" title={String(cellValue)}>
|
<td key={col} className={`px-3 py-2 text-xs ${isJson ? 'max-w-[400px] whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed' : 'max-w-[200px] truncate'}`} title={String(cellValue)}>
|
||||||
{isSensitive ? <span className="text-yellow-400 italic">{maskData(String(cellValue))}</span> : String(cellValue)}
|
{isSensitive ? <span className="text-yellow-400 italic">{maskData(String(cellValue))}</span> :
|
||||||
|
isJson ? <span className="text-blue-300">{displayText}</span> :
|
||||||
|
String(cellValue)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in new issue
Block a user