refactor: 数据源菜单合一+布局优化

- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」
- InstancePage 改为统一入口,顶部Tab切换类型过滤
- 添加数据源改为弹框Modal(默认MySQL)
- 去掉左下角用户区,退出登录移到右上角header
- 侧边栏收窄220px,整体布局更紧凑
- 后端202 tests passing
- 前端构建零错误
This commit is contained in:
logan committed 2026-05-22 22:53:48 +08:00
1 parent bab55d3927
commit 5cbd0e3441
23 files changed
+2881 -94

No files matched your search

+511
View File
@@ -0,0 +1,511 @@
import { useState, useEffect, useCallback } from 'react';
import {
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw, ChevronRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { instanceApi } from '@/api/instance';
import { dictionaryApi } from '@/api/dictionary';
interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
Comment?: string;
}
interface IndexInfo {
name: string;
columns: string[];
non_unique?: number;
index_type?: string;
unique?: number;
definition?: string;
}
interface TableDetail {
columns: ColumnInfo[];
indexes: IndexInfo[];
create_ddl: string;
row_count: number;
}
type TabKey = 'columns' | 'indexes' | 'ddl';
export default function DataDictionaryPage() {
// Left panel state
var selectedInstance = useState<number | null>(null);
var instanceId = selectedInstance[0];
var setInstanceId = selectedInstance[1];
var instanceListState = useState<any[]>([]);
var instances = instanceListState[0];
var setInstances = instanceListState[1];
var selectedDb = useState<string>('');
var currentDb = selectedDb[0];
var setCurrentDb = selectedDb[1];
var databaseListState = useState<string[]>([]);
var databases = databaseListState[0];
var setDatabases = databaseListState[1];
var tableListState = useState<string[]>([]);
var tables = tableListState[0];
var setTables = tableListState[1];
var searchState = useState<string>('');
var searchFilter = searchState[0];
var setSearchFilter = searchState[1];
// Right panel state
var selectedTableState = useState<string>('');
var selectedTable = selectedTableState[0];
var setSelectedTable = selectedTableState[1];
var detailState = useState<TableDetail | null>(null);
var tableDetail = detailState[0];
var setTableDetail = detailState[1];
var tabState = useState<TabKey>('columns');
var activeTab = tabState[0];
var setActiveTab = tabState[1];
var loadingState = useState<boolean>(false);
var loading = loadingState[0];
var setLoading = loadingState[1];
// Load instances
useEffect(function () {
instanceApi.list().then(function (data: any) {
setInstances(Array.isArray(data) ? data : []);
}).catch(function () {
setInstances([]);
});
}, []);
// Load databases when instance changes
var loadDatabases = useCallback(function () {
if (!instanceId) {
setDatabases([]);
setTables([]);
setCurrentDb('');
return;
}
dictionaryApi.listDatabases(instanceId).then(function (data: any) {
var dbList = data && data.databases ? data.databases : [];
setDatabases(dbList);
if (dbList.length > 0) {
setCurrentDb(dbList[0]);
} else {
setCurrentDb('');
}
}).catch(function () {
setDatabases([]);
setCurrentDb('');
});
}, [instanceId]);
useEffect(function () {
loadDatabases();
}, [loadDatabases]);
// Load tables when database changes
useEffect(function () {
if (!instanceId || !currentDb) {
setTables([]);
setSelectedTable('');
setTableDetail(null);
return;
}
dictionaryApi.listTables(instanceId, currentDb).then(function (data: any) {
var tblList = data && data.tables ? data.tables : [];
setTables(tblList);
if (tblList.length > 0) {
setSelectedTable(tblList[0]);
} else {
setSelectedTable('');
setTableDetail(null);
}
}).catch(function () {
setTables([]);
setSelectedTable('');
setTableDetail(null);
});
}, [instanceId, currentDb]);
// Load table detail
useEffect(function () {
if (!instanceId || !currentDb || !selectedTable) {
setTableDetail(null);
return;
}
setLoading(true);
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
.then(function (data: any) {
setTableDetail(data || null);
})
.catch(function () {
setTableDetail(null);
})
.finally(function () {
setLoading(false);
});
}, [instanceId, currentDb, selectedTable]);
// Export handler
function handleExport() {
if (!instanceId || !currentDb) return;
dictionaryApi.exportDictionary(instanceId, currentDb).then(function (data: any) {
var text = JSON.stringify(data, null, 2);
var blob = new Blob([text], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = currentDb + '_dictionary.json';
a.click();
URL.revokeObjectURL(url);
}).catch(function () {
// silently fail
});
}
// Filter tables
var filteredTables = tables.filter(function (t) {
if (!searchFilter) return true;
return t.toLowerCase().indexOf(searchFilter.toLowerCase()) >= 0;
});
// Render tabs
var tabs = [
{ key: 'columns' as TabKey, label: '字段信息', icon: Columns3 },
{ key: 'indexes' as TabKey, label: '索引信息', icon: Key },
{ key: 'ddl' as TabKey, label: 'DDL', icon: Code2 },
];
return (
<div className="space-y-5">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" />
数据字典
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
浏览数据库结构,查看表字段、索引和DDL定义
</p>
</div>
<button
onClick={handleExport}
disabled={!instanceId || !currentDb}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" />
导出字典
</button>
</div>
{/* Main layout: left panel + right panel */}
<div className="flex gap-5">
{/* Left panel: w-1/4 */}
<div className="w-1/4 space-y-4">
{/* Instance selector */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="w-4 h-4 text-blue-400" />
选择实例
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<select
value={instanceId || ''}
onChange={function (e) {
var val = e.target.value;
setInstanceId(val ? Number(val) : null);
setSelectedTable('');
setTableDetail(null);
}}
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<option value="">-- 请选择实例 --</option>
{instances.map(function (inst) {
return (
<option key={inst.id} value={inst.id}>
{inst.name + ' (' + inst.db_type + ')'}
</option>
);
})}
</select>
</CardContent>
</Card>
{/* Database selector */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="w-4 h-4 text-green-400" />
选择数据库
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<select
value={currentDb}
onChange={function (e) {
setCurrentDb(e.target.value);
setSelectedTable('');
setTableDetail(null);
}}
disabled={!instanceId || databases.length === 0}
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50"
>
<option value="">-- 请选择数据库 --</option>
{databases.map(function (db) {
return <option key={db} value={db}>{db}</option>;
})}
</select>
</CardContent>
</Card>
{/* Table list with search */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Table2 className="w-4 h-4 text-purple-400" />
表列表
{tables.length > 0 && (
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">
{'(' + tables.length + ')'}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-secondary)]" />
<input
type="text"
value={searchFilter}
onChange={function (e) { setSearchFilter(e.target.value); }}
placeholder="搜索表名..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
{/* Table list */}
<div className="max-h-[400px] overflow-y-auto space-y-0.5">
{filteredTables.length === 0 ? (
<div className="text-center py-6 text-sm text-[var(--dg-text-secondary)]">
{instanceId && currentDb ? '没有匹配的表' : '请先选择实例和数据库'}
</div>
) : (
filteredTables.map(function (tbl) {
var isSelected = tbl === selectedTable;
return (
<button
key={tbl}
onClick={function () { setSelectedTable(tbl); }}
className={
'w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ' +
(isSelected
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]')
}
>
<ChevronRight className={'w-3 h-3 transition-transform ' + (isSelected ? 'rotate-90' : '')} />
{tbl}
</button>
);
})
)}
</div>
</CardContent>
</Card>
</div>
{/* Right panel: w-3/4 */}
<div className="w-3/4">
{!tableDetail && !loading ? (
<Card className="dg-card h-full">
<CardContent className="flex flex-col items-center justify-center py-20 text-[var(--dg-text-secondary)]">
<Table2 className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">选择一个表以查看详细信息</p>
</CardContent>
</Card>
) : (
<Card className="dg-card">
{/* Table header */}
<CardHeader className="pb-3 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base font-semibold">{selectedTable}</CardTitle>
{tableDetail && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400">
{'~' + tableDetail.row_count + ' 行'}
</span>
)}
</div>
<button
onClick={function () {
if (instanceId && currentDb && selectedTable) {
setLoading(true);
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
.then(function (data: any) { setTableDetail(data || null); })
.catch(function () { setTableDetail(null); })
.finally(function () { setLoading(false); });
}
}}
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] transition-colors"
title="刷新"
>
<RefreshCw className={'w-4 h-4 ' + (loading ? 'animate-spin' : '')} />
</button>
</div>
</CardHeader>
{/* Tabs */}
<div className="flex border-b border-[var(--dg-border)] px-5">
{tabs.map(function (tab) {
var isActive = activeTab === tab.key;
var Icon = tab.icon;
return (
<button
key={tab.key}
onClick={function () { setActiveTab(tab.key); }}
className={
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ' +
(isActive
? 'border-blue-500 text-blue-400'
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]')
}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<CardContent className="p-5">
{loading ? (
<div className="flex items-center justify-center py-10">
<RefreshCw className="w-6 h-6 animate-spin text-blue-400" />
<span className="ml-2 text-sm text-[var(--dg-text-secondary)]">加载中...</span>
</div>
) : activeTab === 'columns' ? (
/* Columns tab */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">Field</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
<th className="text-left px-4 py-2.5 font-semibold">Null</th>
<th className="text-left px-4 py-2.5 font-semibold">Key</th>
<th className="text-left px-4 py-2.5 font-semibold">Default</th>
<th className="text-left px-4 py-2.5 font-semibold">Extra</th>
</tr>
</thead>
<tbody>
{(tableDetail && tableDetail.columns ? tableDetail.columns : []).map(function (col) {
return (
<tr key={col.Field} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400">{col.Field}</td>
<td className="px-4 py-2.5 font-mono text-green-400">{col.Type}</td>
<td className="px-4 py-2.5">
<span className={
'text-xs px-1.5 py-0.5 rounded ' +
(col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-gray-500/15 text-gray-400')
}>
{col.Null}
</span>
</td>
<td className="px-4 py-2.5">
{col.Key ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400">
{col.Key}
</span>
) : (
<span className="text-[var(--dg-text-secondary)]">-</span>
)}
</td>
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)]">
{col.Default !== null ? col.Default : (
<span className="text-gray-500">NULL</span>
)}
</td>
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)]">{col.Extra}</td>
</tr>
);
})}
{(!tableDetail || !tableDetail.columns || tableDetail.columns.length === 0) && (
<tr>
<td colSpan={6} className="text-center py-6 text-[var(--dg-text-secondary)]">
暂无字段信息
</td>
</tr>
)}
</tbody>
</table>
</div>
) : activeTab === 'indexes' ? (
/* Indexes tab */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">Index Name</th>
<th className="text-left px-4 py-2.5 font-semibold">Columns</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
</tr>
</thead>
<tbody>
{(tableDetail && tableDetail.indexes ? tableDetail.indexes : []).map(function (idx, i) {
var cols = idx.columns
? (Array.isArray(idx.columns) ? idx.columns.join(', ') : idx.columns)
: '-';
var idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
return (
<tr key={idx.name || i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400">{idx.name}</td>
<td className="px-4 py-2.5 font-mono">{cols}</td>
<td className="px-4 py-2.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/15 text-green-400">
{idxType}
</span>
</td>
</tr>
);
})}
{(!tableDetail || !tableDetail.indexes || tableDetail.indexes.length === 0) && (
<tr>
<td colSpan={3} className="text-center py-6 text-[var(--dg-text-secondary)]">
暂无索引信息
</td>
</tr>
)}
</tbody>
</table>
</div>
) : (
/* DDL tab */
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4">
<pre className="text-sm font-mono whitespace-pre-wrap text-green-400 leading-relaxed">
{tableDetail && tableDetail.create_ddl ? tableDetail.create_ddl : '-- No DDL available'}
</pre>
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}