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:
Logan.Li committed 2026-10-26 17:54:15 +08:00
1 parent ac563d1c5e
commit ac498c7ba2
92 files changed
+8224 -6146

No files matched your search

+91 -496
View File
@@ -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">