feat: ARDM风格查询页优化 — 可拖拽Sidebar + Redis差异化结果展示 + KeyHeader\n\n1. Sidebar可拖拽宽度(200-600px, localStorage持久化)\n2. Redis raw_text展示加ARDM KeyHeader: key名+类型标签+TTL+格式标签+复制按钮\n3. HGETALL结果用field/value紧凑双列展示替代通用表格\n4. 后端raw_text模式自动查询key TYPE和TTL返回raw_type/raw_ttl
This commit is contained in:
1 parent
4e6f9eb8fd
commit
433e7e2909
2 files changed
+122
-16
No files matched your search
@@ -915,14 +915,35 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
|||||||
prettyJSON = strings.TrimRight(buf.String(), "\n")
|
prettyJSON = strings.TrimRight(buf.String(), "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return gin.H{
|
// ARDM-style: auto-fetch key type and TTL for KeyHeader
|
||||||
|
var keyType, keyTTL string
|
||||||
|
if keyName != "" && cmd != "TYPE" && cmd != "TTL" && cmd != "PTTL" && cmd != "EXISTS" && cmd != "DBSIZE" && cmd != "INFO" {
|
||||||
|
if t, err := client.Type(ctx, keyName).Result(); err == nil {
|
||||||
|
keyType = t
|
||||||
|
}
|
||||||
|
if ttl, err := client.TTL(ctx, keyName).Result(); err == nil {
|
||||||
|
if ttl < 0 {
|
||||||
|
keyTTL = "永不过期"
|
||||||
|
} else {
|
||||||
|
keyTTL = fmt.Sprintf("%vs", int(ttl.Seconds()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := gin.H{
|
||||||
"columns": []string{},
|
"columns": []string{},
|
||||||
"rows": []interface{}{},
|
"rows": []interface{}{},
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"raw_value": prettyJSON != "",
|
"raw_value": prettyJSON != "",
|
||||||
"raw_key": keyName,
|
"raw_key": keyName,
|
||||||
"raw_text": rawValue,
|
"raw_text": rawValue,
|
||||||
}, nil
|
}
|
||||||
|
if keyType != "" {
|
||||||
|
result["raw_type"] = keyType
|
||||||
|
}
|
||||||
|
if keyTTL != "" {
|
||||||
|
result["raw_ttl"] = keyTTL
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ interface QueryResult {
|
|||||||
raw_value?: boolean;
|
raw_value?: boolean;
|
||||||
raw_key?: string;
|
raw_key?: string;
|
||||||
raw_text?: string;
|
raw_text?: string;
|
||||||
|
raw_type?: string;
|
||||||
|
raw_ttl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
masked_columns?: string[];
|
masked_columns?: string[];
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,9 @@ export default function QueriesPage() {
|
|||||||
const [rowHeight] = useState(36);
|
const [rowHeight] = useState(36);
|
||||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// -- Sidebar 拖拽宽度 --
|
||||||
|
const [sideWidth, setSideWidth] = useState(() => Number(localStorage.getItem('query-side-width')) || 280);
|
||||||
|
const sidebarDragRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// -- 历史 state --
|
// -- 历史 state --
|
||||||
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
|
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
|
||||||
@@ -316,6 +321,28 @@ export default function QueriesPage() {
|
|||||||
setHistory([]); setClearConfirm(false);
|
setHistory([]); setClearConfirm(false);
|
||||||
}, [clearConfirm]);
|
}, [clearConfirm]);
|
||||||
|
|
||||||
|
// -- Sidebar 拖拽 --
|
||||||
|
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = sideWidth;
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const delta = ev.clientX - startX;
|
||||||
|
const next = Math.min(600, Math.max(200, startWidth + delta));
|
||||||
|
setSideWidth(next);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
setSideWidth(w => {
|
||||||
|
localStorage.setItem('query-side-width', String(w));
|
||||||
|
return w;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}, [sideWidth]);
|
||||||
|
|
||||||
const filteredHistory = useMemo(() => {
|
const filteredHistory = useMemo(() => {
|
||||||
let r = history;
|
let r = history;
|
||||||
if (favFilter) r = r.filter(h => h.favorite);
|
if (favFilter) r = r.filter(h => h.favorite);
|
||||||
@@ -435,16 +462,25 @@ export default function QueriesPage() {
|
|||||||
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
|
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
|
||||||
{/* Schema 侧边栏(左侧,独立) */}
|
{/* Schema 侧边栏(左侧,独立) */}
|
||||||
{showSidebar && instanceId && (
|
{showSidebar && instanceId && (
|
||||||
<div className="w-[280px] shrink-0 border-r border-[var(--dg-border)] overflow-y-auto">
|
<>
|
||||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
<div className="shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
||||||
clusterMode={selectedInstance?.cluster_mode}
|
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||||
onInsert={(text) => {
|
clusterMode={selectedInstance?.cluster_mode}
|
||||||
const sep = dbType === 'redis' ? (sql.trim() ? '\\n' : '') : (sql.trim() ? ' ' : '');
|
onInsert={(text) => {
|
||||||
setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' '));
|
const sep = dbType === 'redis' ? (sql.trim() ? '\\n' : '') : (sql.trim() ? ' ' : '');
|
||||||
}}
|
setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' '));
|
||||||
onDatabaseChange={setDatabase}
|
}}
|
||||||
singleDb={!!database} />
|
onDatabaseChange={setDatabase}
|
||||||
</div>
|
singleDb={!!database} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={sidebarDragRef}
|
||||||
|
className="w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
|
||||||
|
onMouseDown={handleDragStart}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
||||||
@@ -520,13 +556,35 @@ export default function QueriesPage() {
|
|||||||
{showResult && (
|
{showResult && (
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-0">
|
<div className="flex-1 min-h-0 overflow-auto p-0">
|
||||||
{result.raw_text !== undefined ? (
|
{result.raw_text !== undefined ? (
|
||||||
/* Raw value display (Redis GET/HGET/TTL etc.) */
|
/* Raw value display — ARDM-style FormatViewer */
|
||||||
<div className="p-4">
|
<div className="p-4 flex flex-col gap-2">
|
||||||
|
{/* Key info bar (ARDM KeyHeader style) */}
|
||||||
{result.raw_key && (
|
{result.raw_key && (
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2 font-mono">{result.raw_key}</div>
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs">
|
||||||
|
<KeyRound className="w-3 h-3 text-blue-400 shrink-0" />
|
||||||
|
<span className="font-mono text-[var(--dg-text-primary)] truncate">{result.raw_key}</span>
|
||||||
|
{result.raw_type && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/15 text-green-400">{result.raw_type}</span>
|
||||||
|
)}
|
||||||
|
{result.raw_ttl && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/15 text-orange-400">{result.raw_ttl}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||||
|
result.raw_value ? 'bg-blue-500/15 text-blue-400' : 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-muted)]'
|
||||||
|
}`}>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(
|
||||||
|
result.raw_value
|
||||||
|
? (() => { try { return JSON.stringify(JSON.parse(String(result.raw_text)), null, 2); } catch { return String(result.raw_text); } })()
|
||||||
|
: String(result.raw_text)
|
||||||
|
)} className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-60 hover:opacity-100">
|
||||||
|
<ClipboardPaste className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Formatted value */}
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<pre className={`text-sm leading-relaxed overflow-auto max-h-[500px] p-4 rounded-lg font-mono ${
|
<pre className={`text-sm leading-relaxed overflow-auto max-h-[400px] p-4 rounded-lg font-mono ${
|
||||||
result.raw_value
|
result.raw_value
|
||||||
? 'bg-[var(--dg-bg-primary)] text-blue-300 whitespace-pre-wrap break-all'
|
? 'bg-[var(--dg-bg-primary)] text-blue-300 whitespace-pre-wrap break-all'
|
||||||
: 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] whitespace-pre-wrap break-all'
|
: 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] whitespace-pre-wrap break-all'
|
||||||
@@ -538,6 +596,32 @@ export default function QueriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : result.columns && result.columns.length > 0 ? (
|
) : result.columns && result.columns.length > 0 ? (
|
||||||
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto">
|
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto">
|
||||||
|
{/* ARDM-style: Hash field/value compact list for Redis HGETALL */}
|
||||||
|
{dbType === 'redis' && result.columns.length === 2 && result.columns[0] === 'field' && result.columns[1] === 'value' ? (
|
||||||
|
<div className="divide-y divide-[var(--dg-border)]">
|
||||||
|
{(sortedResult?.rows || []).map((row, i) => {
|
||||||
|
const field = String(row.field ?? '');
|
||||||
|
const value = String(row.value ?? '');
|
||||||
|
const isJson = (() => { try { const p = JSON.parse(value); return typeof p === 'object'; } catch { return false; } })();
|
||||||
|
const formatted = isJson ? JSON.stringify(JSON.parse(value), null, 2) : value;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||||
|
<div className="w-[200px] shrink-0 px-4 py-2.5 text-xs font-mono text-blue-400 border-r border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
|
{field}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-4 py-2.5 text-xs font-mono whitespace-pre-wrap break-all min-w-0">
|
||||||
|
{isJson ? (
|
||||||
|
<pre className="text-blue-300 text-xs whitespace-pre-wrap">{formatted}</pre>
|
||||||
|
) : (
|
||||||
|
<span>{value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Standard table for other results */
|
||||||
<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">
|
||||||
@@ -588,6 +672,7 @@ export default function QueriesPage() {
|
|||||||
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{result.total > pageSize && (
|
{result.total > pageSize && (
|
||||||
<div className="flex items-center justify-between px-4 py-2 text-xs dg-text-secondary border-t border-[var(--dg-border)]">
|
<div className="flex items-center justify-between px-4 py-2 text-xs dg-text-secondary border-t border-[var(--dg-border)]">
|
||||||
|
|||||||
Reference in new issue
Block a user