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:
logan committed 2026-05-24 15:10:31 +08:00
1 parent 8471f368eb
commit 0fb947dcb5
6 files changed
+1020 -476

No files matched your search

+365
View File
@@ -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>
);
}