feat: TicketCreatePage tab化 + SchemaSidebar + Redis快速搜索 + Dashboard导航修复
TicketCreatePage 重构: - SQL编辑器右侧新增数据库浏览器面板(tab化) - 表结构Tab: 表列表→字段展开(PK/索引标记)→点击插入SQL - 索引Tab: 选中表的索引详情(索引名/类型/字段/基数) - Redis Keys Tab: DB选择器+Key模式搜索+Key列表+类型/TTL/值查看 SchemaSidebar 新组件(365行): - MySQL/PostgreSQL: 左侧数据库表树→字段→点击插入SQL - Redis: Key树(DB选择→模式搜索→Key值查看, 类似Another Redis Desktop Manager) - 用于 QueriesPage 查询页面 Dashboard: - 快捷按钮改用 useNavigate + onClick 替代 <a href> API增强: - redis.ts: 新增 getKeys/createKey/deleteKey 方法 - instanceDetail.ts: 导出 TableMeta/TableDetail/ColumnInfo/IndexInfo 类型接口
This commit is contained in:
1 parent
8471f368eb
commit
0fb947dcb5
6 files changed
+1020
-476
No files matched your search
@@ -0,0 +1,365 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ChevronDown, ChevronRight, Database, Table2, Columns3,
|
||||
KeyRound, Search, Loader2, FolderOpen, Server,
|
||||
} from 'lucide-react';
|
||||
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||
import { redisApi } from '@/api/redis';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface Props {
|
||||
instanceId: string;
|
||||
database: string;
|
||||
dbType: string;
|
||||
onInsert: (text: string) => void;
|
||||
}
|
||||
|
||||
interface RedisKeyItem {
|
||||
key: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function formatTTL(ttl: number): string {
|
||||
if (ttl < 0) return '永不过期';
|
||||
if (ttl < 60) return `${ttl}s`;
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`;
|
||||
if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`;
|
||||
return `${Math.floor(ttl / 86400)}d`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* COMPONENT */
|
||||
/* ================================================================== */
|
||||
|
||||
export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) {
|
||||
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
|
||||
const isRedis = dbType === 'redis';
|
||||
|
||||
// -- MySQL State --
|
||||
const [tables, setTables] = useState<TableMeta[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [tableSearch, setTableSearch] = useState('');
|
||||
|
||||
// -- Redis State --
|
||||
const [redisDb, setRedisDb] = useState(0);
|
||||
const [redisPattern, setRedisPattern] = useState('*');
|
||||
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
|
||||
const [loadingRedis, setLoadingRedis] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [keyDetail, setKeyDetail] = useState<any>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
// -- Tab --
|
||||
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch tables */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !database || !isMySQL) { setTables([]); return; }
|
||||
setLoadingTables(true);
|
||||
instanceDetailApi.listTables(Number(instanceId), database)
|
||||
.then((data) => setTables(Array.isArray(data) ? data : []))
|
||||
.catch(() => setTables([]))
|
||||
.finally(() => setLoadingTables(false));
|
||||
}, [instanceId, database, isMySQL]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch columns on expand */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleExpandTable = useCallback(async (tableName: string) => {
|
||||
if (expandedTable === tableName) { setExpandedTable(null); return; }
|
||||
setExpandedTable(tableName);
|
||||
setTableColumns([]);
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName);
|
||||
setTableColumns(detail?.columns || []);
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingColumns(false); }
|
||||
}, [expandedTable, instanceId, database]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch Redis keys */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const fetchRedisKeys = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoadingRedis(true);
|
||||
try {
|
||||
const res = await redisApi.scanKeys(Number(instanceId), {
|
||||
db: redisDb, pattern: redisPattern || '*', count: 100,
|
||||
});
|
||||
const keys = (res?.keys || []).map((k: any) => ({
|
||||
key: typeof k === 'string' ? k : k.key,
|
||||
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
|
||||
ttl: k.ttl ?? -1,
|
||||
}));
|
||||
setRedisKeys(keys);
|
||||
} catch { setRedisKeys([]); }
|
||||
finally { setLoadingRedis(false); }
|
||||
}, [instanceId, redisDb, redisPattern]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedis && instanceId) fetchRedisKeys();
|
||||
}, [isRedis, instanceId, fetchRedisKeys]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch Redis key detail */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const handleSelectKey = useCallback(async (key: string) => {
|
||||
setSelectedKey(key);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
|
||||
setKeyDetail(detail);
|
||||
} catch { setKeyDetail(null); }
|
||||
finally { setLoadingDetail(false); }
|
||||
}, [instanceId, redisDb]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Filtered tables */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const filteredTables = tables.filter(t =>
|
||||
!tableSearch || t.name.toLowerCase().includes(tableSearch.toLowerCase())
|
||||
);
|
||||
|
||||
/* ================================================================== */
|
||||
/* RENDER */
|
||||
/* ================================================================== */
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<FolderOpen className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-medium text-[var(--dg-text-muted)]">数据库导航</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center px-4">请先选择实例</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[var(--dg-bg-secondary)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<Server className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-medium truncate flex-1">{dbType.toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{(isMySQL || isRedis) && (
|
||||
<div className="flex border-b border-[var(--dg-border)]">
|
||||
{isMySQL && (
|
||||
<button
|
||||
onClick={() => setActiveTab('tables')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
||||
activeTab === 'tables'
|
||||
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
||||
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<Table2 className="w-3 h-3" /> 表
|
||||
</button>
|
||||
)}
|
||||
{isRedis && (
|
||||
<button
|
||||
onClick={() => setActiveTab('redis')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
||||
activeTab === 'redis'
|
||||
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
||||
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="w-3 h-3" /> Keys
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'tables' && isMySQL && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Search */}
|
||||
<div className="px-2 py-2 border-b border-[var(--dg-border-light)]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={tableSearch}
|
||||
onChange={(e) => setTableSearch(e.target.value)}
|
||||
className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
placeholder="搜索表..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingTables ? (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : filteredTables.length === 0 ? (
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无表</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||
{filteredTables.map(t => (
|
||||
<div key={t.name}>
|
||||
<button
|
||||
onClick={() => handleExpandTable(t.name)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||
>
|
||||
{expandedTable === t.name
|
||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
}
|
||||
<Table2 className="w-3 h-3 text-blue-400" />
|
||||
<span
|
||||
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
|
||||
title="点击插入"
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
{t.rows !== undefined && (
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{t.rows.toLocaleString()}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Columns */}
|
||||
{expandedTable === t.name && (
|
||||
<div className="ml-5 border-l border-[var(--dg-border-light)]">
|
||||
{loadingColumns ? (
|
||||
<div className="flex items-center gap-1 p-2 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||
</div>
|
||||
) : (
|
||||
tableColumns.map(col => (
|
||||
<button
|
||||
key={col.Field}
|
||||
onClick={() => onInsert(col.Field)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
||||
>
|
||||
<Columns3 className="w-2.5 h-2.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400">{col.Field}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto">{col.Type}</span>
|
||||
{col.Key === 'PRI' && <span className="text-[8px] px-1 bg-yellow-500/10 text-yellow-400 rounded">PK</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'redis' && isRedis && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* DB + Search */}
|
||||
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
|
||||
<select
|
||||
value={redisDb}
|
||||
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
|
||||
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<option key={i} value={i}>DB{i}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={redisPattern}
|
||||
onChange={(e) => setRedisPattern(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
|
||||
className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
placeholder="*"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRedisKeys}
|
||||
disabled={loadingRedis}
|
||||
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Key List + Detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{redisKeys.length === 0 ? (
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">
|
||||
{loadingRedis ? '扫描中...' : '无 Keys'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||
{redisKeys.map(k => (
|
||||
<div key={k.key}>
|
||||
<button
|
||||
onClick={() => handleSelectKey(k.key)}
|
||||
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs transition-colors ${
|
||||
selectedKey === k.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="font-mono truncate flex-1 text-left">{k.key}</span>
|
||||
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{k.type}</span>
|
||||
</button>
|
||||
|
||||
{/* Key Detail */}
|
||||
{selectedKey === k.key && (
|
||||
<div className="px-2 pb-2">
|
||||
{loadingDetail ? (
|
||||
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||
</div>
|
||||
) : keyDetail ? (
|
||||
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||
{typeof keyDetail.value === 'object'
|
||||
? JSON.stringify(keyDetail.value, null, 2)
|
||||
: String(keyDetail.value)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user