refactor(frontend): split 7 oversized pages into feature co-location
Per guide ch.7 page split pattern (skeleton orchestrates, private components live in features/<domain>/): - InstancePage 1716->328, UsersPage 1309->240, TicketsPage 1076->195, TicketDetailPage 911->287, QueriesPage 883->479, LdapConfigPage 860->393, WorkflowPage 852->194 - new feature modules: datasource/ rbac/ ticket/ query/ system/ldap/ governance/workflow/ (components + hooks + types + utils) - stable keys replace index keys in dynamic lists; polling intervals and toast call sites preserved 1:1 (verified by agents) - filter/pagination state moved to URL query via new useQueryState hook (Instance/Users/Tickets/AuditLogs pages): refresh/share/back-button restore state; composite updates applied atomically - permission pure functions extracted to lib/permissions.ts (used by useAuth + useLogin); page-size audit: 0 pages over 700-line budget
This commit is contained in:
1 parent
ac563d1c5e
commit
ac498c7ba2
92 files changed
+8224
-6146
No files matched your search
@@ -1,106 +1,28 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
AlertTriangle, CheckCircle, Play, Loader2,
|
||||
ChevronDown, ChevronRight, Clock, FileText, Sparkles,
|
||||
Download, Search, ArrowUpDown, Database, Table2,
|
||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
||||
KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
|
||||
PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
|
||||
} from 'lucide-react';
|
||||
// Card components replaced by design system utility classes
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { redisApi } from '@/api/redis';
|
||||
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||
import { instanceDetailApi } from '@/api/instanceDetail';
|
||||
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
|
||||
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
|
||||
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
|
||||
import type { RedisLineEditorState } from '@/features/redis/types';
|
||||
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
|
||||
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
|
||||
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
|
||||
import type { QueryHistoryItem } from '@/features/query/types';
|
||||
import { downloadCSV, exportToCSV } from '@/features/query/utils';
|
||||
import { QueryTopBar } from '@/features/query/components/QueryTopBar';
|
||||
import { SqlEditorSection } from '@/features/query/components/SqlEditorSection';
|
||||
import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePanel';
|
||||
import { QueryResultPanel } from '@/features/query/components/QueryResultPanel';
|
||||
import { dbTypeIcon, normalizeDbType } from '@/features/query/constants';
|
||||
import type {
|
||||
QueryHistoryItem, QueryInstanceOption, QueryResult,
|
||||
RedisKeyDetail, RedisKeyInfo, SortDirection, SortState,
|
||||
} from '@/features/query/types';
|
||||
import api from '@/api/request';
|
||||
import { readStorage, writeStorage } from '@/lib/storage';
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
total: number;
|
||||
count: number;
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
cost_ms?: number;
|
||||
raw_value?: boolean;
|
||||
raw_key?: string;
|
||||
raw_text?: string;
|
||||
raw_type?: string;
|
||||
raw_ttl?: string;
|
||||
error?: string;
|
||||
masked_columns?: string[];
|
||||
}
|
||||
|
||||
interface RedisKeyInfo {
|
||||
key?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
interface RedisKeyDetail {
|
||||
key: string;
|
||||
name?: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
type SortState = { column: string | null; direction: SortDirection };
|
||||
|
||||
const dbTypeLabel: Record<string, string> = {
|
||||
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
|
||||
};
|
||||
|
||||
const dbTypeIcon: Record<string, string> = {
|
||||
mysql: '/db-logos/mysql.svg',
|
||||
postgres: '/db-logos/postgres.svg',
|
||||
postgresql: '/db-logos/postgres.svg',
|
||||
redis: '/db-logos/redis.svg',
|
||||
mongo: '/db-logos/mongo.svg',
|
||||
mongodb: '/db-logos/mongo.svg',
|
||||
};
|
||||
|
||||
// 脱敏关键词不再硬编码——由后端 applyDataMasking 返回 masked_columns 标记
|
||||
// 后端根据 data_masking_enabled + data_masking_sensitive_fields + 实例 mask_fields 决定
|
||||
|
||||
const placeholderMap: Record<DBType, string> = {
|
||||
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||
};
|
||||
|
||||
function normalizeDbType(dbType?: string): DBType {
|
||||
if (dbType === 'postgres' || dbType === 'pg') return 'postgresql';
|
||||
if (dbType === 'mongo') return 'mongodb';
|
||||
if (dbType === 'redis') return 'redis';
|
||||
return 'mysql';
|
||||
}
|
||||
|
||||
const queryPolicyText: Record<DBType, string> = {
|
||||
mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。',
|
||||
postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。',
|
||||
redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。',
|
||||
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
|
||||
};
|
||||
|
||||
export default function QueriesPage() {
|
||||
// -- 编辑器 state --
|
||||
const [sql, setSql] = useState('');
|
||||
@@ -108,7 +30,7 @@ export default function QueriesPage() {
|
||||
const [database, setDatabase] = useState('');
|
||||
const [databases, setDatabases] = useState<string[]>([]);
|
||||
const [loadingDbs, setLoadingDbs] = useState(false);
|
||||
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number; db_type: string; cluster_mode?: boolean }>>([]);
|
||||
const [instances, setInstances] = useState<QueryInstanceOption[]>([]);
|
||||
const [editorSchema, setEditorSchema] = useState<Record<string, Record<string, string[]>>>({});
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -134,9 +56,6 @@ export default function QueriesPage() {
|
||||
const [showResult, setShowResult] = useState(true);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [sortState, setSortState] = useState<SortState>({ column: null, direction: null });
|
||||
const [rowHeight] = useState(36);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||
// -- Sidebar 拖拽宽度 --
|
||||
const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280);
|
||||
const sidebarDragRef = useRef<HTMLDivElement>(null);
|
||||
@@ -238,6 +157,10 @@ export default function QueriesPage() {
|
||||
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const handleInspectRedisValue = useCallback((field: string, value: unknown) => {
|
||||
setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
|
||||
if (/^\d+$/.test(database)) return;
|
||||
@@ -314,21 +237,6 @@ export default function QueriesPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const SortIcon = ({ col }: { col: string }) => {
|
||||
if (sortState.column !== col) return <ArrowUpDown className="w-3 h-3 opacity-30" />;
|
||||
if (sortState.direction === 'asc') return <ArrowUp className="w-3 h-3 text-blue-400" />;
|
||||
return <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
/* ---- Virtual Scroll ---- */
|
||||
const handleResultScroll = useCallback(() => {
|
||||
if (!resultContainerRef.current) return;
|
||||
const c = resultContainerRef.current; const start = Math.max(0, Math.floor(c.scrollTop / rowHeight) - 5);
|
||||
const count = Math.ceil(c.clientHeight / rowHeight);
|
||||
setVisibleRange({ start, end: Math.min((sortedResult?.rows?.length || 0), start + count + 10) });
|
||||
}, [sortedResult, rowHeight]);
|
||||
const totalResultHeight = (sortedResult?.rows?.length || 0) * rowHeight;
|
||||
|
||||
const handleExportResult = useCallback(async () => {
|
||||
if (!sql || !instanceId) return;
|
||||
try {
|
||||
@@ -348,17 +256,12 @@ export default function QueriesPage() {
|
||||
}
|
||||
}, [sql, instanceId, database]);
|
||||
|
||||
/* ---- 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 };
|
||||
}, []);
|
||||
// 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包)
|
||||
const handlePageSizeChange = useCallback((size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
handleExecute(1);
|
||||
}, [handleExecute]);
|
||||
|
||||
/* ---- History ---- */
|
||||
const toggleFavorite = useCallback((id: number) => {
|
||||
@@ -403,70 +306,38 @@ export default function QueriesPage() {
|
||||
return r;
|
||||
}, [history, favFilter, searchHistory]);
|
||||
|
||||
const handleToggleSidebar = useCallback(() => setShowSidebar(v => !v), []);
|
||||
const handleToggleTemplates = useCallback(() => setShowTemplates(v => !v), []);
|
||||
const handleToggleHistory = useCallback(() => setShowHistory(v => !v), []);
|
||||
const handleToggleResult = useCallback(() => setShowResult(v => !v), []);
|
||||
|
||||
return (
|
||||
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
||||
{/* ====== 主区域 ====== */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
||||
|
||||
{/* -- 顶栏: 实例+库选择 -- */}
|
||||
<div className="shrink-0 px-0 md:px-5 pt-1 md:pt-4 pb-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-full md:min-w-0 flex-wrap md:flex-nowrap">
|
||||
{selectedInstance && dbTypeIcon[dbType] && (
|
||||
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
<select value={instanceId} onChange={e => setInstanceId(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
|
||||
<option value="">选择实例</option>
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({dbTypeLabel[inst.db_type] || inst.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{dbType === 'redis' ? (
|
||||
<select value={database || '0'} onChange={e => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
|
||||
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<select value={database} onChange={e => setDatabase(e.target.value)}
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
|
||||
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
|
||||
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧工具按钮组 */}
|
||||
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
|
||||
{instanceId && (
|
||||
<button onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showSidebar ? '隐藏' : '导航'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowTemplates(!showTemplates)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
模板 ({templates.length})
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => setShowHistory(!showHistory)}
|
||||
className={`dg-btn dg-btn-ghost dg-btn-sm ${showHistory ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
|
||||
{showHistory ? <PanelRightClose className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
|
||||
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
|
||||
</button>
|
||||
<button onClick={handleExecute} disabled={executing || !instanceId}
|
||||
className="dg-btn dg-btn-primary ml-auto md:ml-0">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{executing ? '执行中...' : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<QueryTopBar
|
||||
instances={instances}
|
||||
instanceId={instanceId}
|
||||
selectedInstance={selectedInstance}
|
||||
database={database}
|
||||
databases={databases}
|
||||
loadingDbs={loadingDbs}
|
||||
dbType={dbType}
|
||||
showSidebar={showSidebar}
|
||||
showTemplates={showTemplates}
|
||||
showHistory={showHistory}
|
||||
executing={executing}
|
||||
templateCount={templates.length}
|
||||
historyCount={history.length}
|
||||
onInstanceIdChange={setInstanceId}
|
||||
onDatabaseChange={setDatabase}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
onToggleTemplates={handleToggleTemplates}
|
||||
onToggleHistory={handleToggleHistory}
|
||||
onExecute={handleExecute}
|
||||
/>
|
||||
|
||||
{showTemplates && (
|
||||
<QueryTemplatePanel
|
||||
@@ -512,145 +383,34 @@ export default function QueriesPage() {
|
||||
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{dbType === 'redis' && instanceId && (
|
||||
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${showSidebar ? 'md:hidden' : ''}`}>
|
||||
<div className="dg-card overflow-hidden">
|
||||
<div className="border-b border-[var(--dg-border)] px-3 py-2">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<KeyRound className="h-4 w-4 text-red-400" />
|
||||
Redis 键值可视化
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_130px_auto] lg:w-[620px]">
|
||||
<div className="relative">
|
||||
<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={redisPattern}
|
||||
onChange={e => setRedisPattern(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRedisSearch(); }}
|
||||
className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm"
|
||||
placeholder="输入 key/pattern,* 扫描全部"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={redisType}
|
||||
onChange={e => setRedisType(e.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="string">String</option>
|
||||
<option value="hash">Hash</option>
|
||||
<option value="list">List</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="zset">ZSet</option>
|
||||
</select>
|
||||
<button onClick={handleRedisSearch} disabled={redisLoading || !redisPattern.trim()} className="dg-btn dg-btn-primary h-9 disabled:opacity-40">
|
||||
{redisLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-[520px] min-h-[260px] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="min-h-[180px] overflow-y-auto border-b border-[var(--dg-border)] lg:border-b-0 lg:border-r">
|
||||
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-3 py-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>Keys</span>
|
||||
<span>{redisKeys.length} 条</span>
|
||||
</div>
|
||||
{redisLoading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }, (_, i) => <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />)}
|
||||
</div>
|
||||
) : redisKeys.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
|
||||
) : redisKeys.map((item, i) => {
|
||||
const keyName = item.key || item.name || '';
|
||||
const active = (redisSelectedKey?.key || redisSelectedKey?.name) === keyName;
|
||||
return (
|
||||
<button
|
||||
key={keyName + i}
|
||||
onClick={() => loadRedisKeyDetail(keyName)}
|
||||
className={`block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ${active ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
|
||||
{item.type && <span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{item.type}</span>}
|
||||
</div>
|
||||
{typeof item.ttl === 'number' && <div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL {formatTTL(item.ttl)}</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-auto">
|
||||
{redisDetailLoading ? (
|
||||
<div className="m-4 h-52 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
||||
) : redisSelectedKey ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col">
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--dg-border)] px-3 py-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{redisSelectedKey.key || redisSelectedKey.name}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--dg-text-muted)]">
|
||||
<span className="rounded bg-red-500/10 px-1.5 py-0.5 uppercase text-red-400">{redisSelectedKey.type}</span>
|
||||
<span>TTL {formatTTL(redisSelectedKey.ttl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<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={redisValueSearch} onChange={e => setRedisValueSearch(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>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{redisSelectedKey.type === 'string' ? (
|
||||
<pre className="min-h-[180px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||
{typeof redisSelectedKey.value === 'string' ? (tryFormatJsonText(redisSelectedKey.value) || redisSelectedKey.value) : JSON.stringify(redisSelectedKey.value, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
|
||||
<RedisStructuredValue
|
||||
type={redisSelectedKey.type}
|
||||
value={redisSelectedKey.value}
|
||||
redisKey={redisSelectedKey.key || redisSelectedKey.name || ''}
|
||||
search={redisValueSearch}
|
||||
onCopy={copyRedisText}
|
||||
onInspect={(title, value) => setRedisLineViewer({ open: true, field: title, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||
onEditHashLine={(field, value) => setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
|
||||
onDeleteHashLine={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[260px] items-center justify-center px-5 text-center text-sm text-[var(--dg-text-muted)]">选择左侧 Key 查看结构化值;命令行查询仍可在下方执行</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RedisKeyValuePanel
|
||||
pattern={redisPattern}
|
||||
typeFilter={redisType}
|
||||
keys={redisKeys}
|
||||
selectedKey={redisSelectedKey}
|
||||
loading={redisLoading}
|
||||
detailLoading={redisDetailLoading}
|
||||
valueSearch={redisValueSearch}
|
||||
hideOnWide={showSidebar}
|
||||
onPatternChange={setRedisPattern}
|
||||
onTypeFilterChange={setRedisType}
|
||||
onSearch={handleRedisSearch}
|
||||
onLoadKeyDetail={loadRedisKeyDetail}
|
||||
onValueSearchChange={setRedisValueSearch}
|
||||
onCopy={copyRedisText}
|
||||
onInspect={handleInspectRedisValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SQL 编辑器 */}
|
||||
<div className="shrink-0 p-0 md:p-4 pb-2">
|
||||
<div className="dg-card overflow-hidden flex flex-col">
|
||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||
language={editorLang} height="200px" onExecute={handleExecute}
|
||||
schema={(dbType === 'mysql' || dbType === 'postgresql') ? editorSchema : undefined} />
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[11px] text-[var(--dg-text-muted)] flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 mt-0.5 shrink-0" />
|
||||
<span>{queryPolicyText[dbType]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SqlEditorSection
|
||||
sql={sql}
|
||||
dbType={dbType}
|
||||
editorLang={editorLang}
|
||||
schema={editorSchema}
|
||||
onSqlChange={setSql}
|
||||
onExecute={handleExecute}
|
||||
/>
|
||||
|
||||
{/* -- 错误提示 -- */}
|
||||
{error && (
|
||||
@@ -661,190 +421,25 @@ export default function QueriesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
||||
{/* -- 查询结果 (编辑器下方,独立滚动,含虚拟滚动表格/分页/raw 展示) -- */}
|
||||
{result && (
|
||||
<div className="flex-1 min-h-0 px-0 md:px-4 pb-4 overflow-hidden flex flex-col">
|
||||
<div className="dg-card overflow-hidden flex-1 flex flex-col">
|
||||
{/* 结果标题栏 + 折叠按钮 */}
|
||||
<div
|
||||
className="shrink-0 border-b border-[var(--dg-border)] py-2 px-4 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
onClick={() => setShowResult(!showResult)}
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm font-semibold flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
查询结果
|
||||
{showResult
|
||||
? <ChevronDown className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
}
|
||||
</span>
|
||||
<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 || result.cost_ms || 0}ms</span>
|
||||
{result.raw_text !== undefined ? (
|
||||
<span>{result.raw_value ? 'JSON' : 'String'}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{result.total || result.rows?.length || 0} 总行</span>
|
||||
<span>{result.rows?.length || 0} 行/页</span>
|
||||
<span>{result.columns?.length || 0} 列</span>
|
||||
</>
|
||||
)}
|
||||
{result.rows?.length > 0 && (
|
||||
<button onClick={(e) => { e.stopPropagation(); handleExportResult(); }}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm">
|
||||
<Download className="w-3 h-3" />导出 CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果内容(可折叠) */}
|
||||
{showResult && (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-0">
|
||||
{result.raw_text !== undefined ? (
|
||||
/* Raw value display — ARDM-style FormatViewer */
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
{/* Key info bar (ARDM KeyHeader style) */}
|
||||
{result.raw_key && (
|
||||
<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">
|
||||
<pre className={`text-sm leading-relaxed overflow-auto max-h-[400px] p-4 rounded-lg font-mono ${
|
||||
result.raw_value
|
||||
? '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'
|
||||
}`}>{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)
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : result.columns && result.columns.length > 0 ? (
|
||||
<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' ? (
|
||||
<RedisHashQueryResult
|
||||
rows={sortedResult?.rows || []}
|
||||
rawKey={result.raw_key}
|
||||
rawType={result.raw_type}
|
||||
rawTtl={result.raw_ttl}
|
||||
command={sql}
|
||||
onView={setRedisLineViewer}
|
||||
/>
|
||||
) : (
|
||||
/* Standard table for other results */
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="dg-table-header">
|
||||
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>
|
||||
{result.columns.map(col => (
|
||||
<th key={col} onClick={() => handleSort(col)}
|
||||
className={`text-left px-4 py-2 font-semibold text-xs cursor-pointer hover:text-blue-400 transition-colors select-none ${
|
||||
dbType === 'redis' ? 'min-w-[200px]' : 'whitespace-nowrap'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{col} <SortIcon col={col} />
|
||||
{result.masked_columns?.includes(col) && (
|
||||
<span className="ml-1 px-1 py-0.5 bg-yellow-500/10 text-yellow-400 text-[9px] rounded">敏</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style={{ height: visibleRange.start * rowHeight }} />
|
||||
{(sortedResult?.rows || []).slice(visibleRange.start, visibleRange.end).map((row, i) => {
|
||||
const ri = visibleRange.start + i;
|
||||
return (
|
||||
<tr key={ri} className="dg-table-row" style={{ height: rowHeight }}>
|
||||
<td className="px-3 py-2 text-[10px] text-[var(--dg-text-muted)] font-mono">{ri + 1}</td>
|
||||
{result.columns.map(col => {
|
||||
const isMasked = result.masked_columns?.includes(col);
|
||||
const cellValue = row[col] ?? '';
|
||||
const { text: displayText, isJson } = formatCellValue(cellValue);
|
||||
return (
|
||||
<td key={col} className={`px-4 py-2.5 text-xs ${
|
||||
isJson
|
||||
? 'max-w-[600px] whitespace-pre-wrap break-all font-mono text-[12px] leading-relaxed text-blue-300'
|
||||
: dbType === 'redis'
|
||||
? 'whitespace-pre-wrap break-all font-mono text-[12px]'
|
||||
: 'whitespace-pre-wrap break-words'
|
||||
}`} title={String(cellValue)}>
|
||||
{isMasked ? <span className="text-yellow-400 italic">{String(cellValue)}</span> :
|
||||
isJson ? displayText :
|
||||
String(cellValue)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr style={{ height: Math.max(0, totalResultHeight - (visibleRange.end * rowHeight)) }} />
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{/* Pagination */}
|
||||
{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 gap-2">
|
||||
<span>每页</span>
|
||||
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); handleExecute(1); }}
|
||||
className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded px-1 py-0.5 text-xs">
|
||||
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button disabled={currentPage <= 1} onClick={() => handleExecute(1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3 rotate-180" /></button>
|
||||
<button disabled={currentPage <= 1} onClick={() => handleExecute(currentPage - 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3" /></button>
|
||||
<span className="px-2">{currentPage} / {Math.ceil(result.total / pageSize)}</span>
|
||||
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(currentPage + 1)}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLeft className="w-3 h-3 rotate-180" /></button>
|
||||
<button disabled={currentPage >= Math.ceil(result.total / pageSize)} onClick={() => handleExecute(Math.ceil(result.total / pageSize))}
|
||||
className="dg-btn dg-btn-ghost dg-btn-sm p-1 disabled:opacity-30"><ChevronLast className="w-3 h-3" /></button>
|
||||
</div>
|
||||
<span>共 {result.total} 条</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center dg-text-muted flex flex-col items-center gap-2">
|
||||
<Eye className="w-5 h-5" />
|
||||
<span className="text-sm">无结果数据</span>
|
||||
{result.affected_rows > 0 && <span className="text-xs">影响行数: {result.affected_rows}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QueryResultPanel
|
||||
result={result}
|
||||
sortedResult={sortedResult}
|
||||
sortState={sortState}
|
||||
dbType={dbType}
|
||||
sql={sql}
|
||||
showResult={showResult}
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
onToggleResult={handleToggleResult}
|
||||
onExportResult={handleExportResult}
|
||||
onSort={handleSort}
|
||||
onViewRedisLine={setRedisLineViewer}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onPageChange={handleExecute}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* -- 安全提示 -- */}
|
||||
<div className="shrink-0 px-4 pb-3">
|
||||
|
||||
Reference in new issue
Block a user