refactor: 数据源菜单合一+布局优化
- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」 - InstancePage 改为统一入口,顶部Tab切换类型过滤 - 添加数据源改为弹框Modal(默认MySQL) - 去掉左下角用户区,退出登录移到右上角header - 侧边栏收窄220px,整体布局更紧凑 - 后端202 tests passing - 前端构建零错误
This commit is contained in:
1 parent
bab55d3927
commit
5cbd0e3441
23 files changed
+2881
-94
No files matched your search
@@ -15,11 +15,12 @@ import WorkflowPage from '@/pages/WorkflowPage';
|
||||
import AutoTaskPage from '@/pages/AutoTaskPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import AuditLogsPage from '@/pages/AuditLogsPage';
|
||||
import DataDictionaryPage from '@/pages/DataDictionaryPage';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
const { isLoggedIn, hasPermission } = useAuth();
|
||||
const path = location.pathname.slice(1) || 'dashboard';
|
||||
const path = location.pathname.split('/')[1] || 'dashboard';
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" replace />;
|
||||
@@ -50,10 +51,8 @@ function AppRoutes() {
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="mysql" element={<InstancePage />} />
|
||||
<Route path="postgres" element={<InstancePage />} />
|
||||
<Route path="redis" element={<InstancePage />} />
|
||||
<Route path="mongo" element={<InstancePage />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import request from './request';
|
||||
|
||||
export var dictionaryApi = {
|
||||
listDatabases: function (instanceId: number) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/databases');
|
||||
},
|
||||
listTables: function (instanceId: number, database: string) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/tables', {
|
||||
params: { database: database },
|
||||
});
|
||||
},
|
||||
getTableDetail: function (instanceId: number, tableName: string, database: string) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, {
|
||||
params: { database: database },
|
||||
});
|
||||
},
|
||||
exportDictionary: function (instanceId: number, database: string) {
|
||||
return request.get('/dictionary/instances/' + instanceId + '/export', {
|
||||
params: { database: database },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import request from './request';
|
||||
|
||||
export const instanceDetailApi = {
|
||||
// Databases
|
||||
listDatabases: (id: number) =>
|
||||
request.get<any, string[]>('/instances/' + id + '/detail/databases'),
|
||||
|
||||
// Tables
|
||||
listTables: (id: number, database: string) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/tables', { params: { database: database } }),
|
||||
|
||||
// Table Detail
|
||||
getTableDetail: (id: number, database: string, table: string) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }),
|
||||
|
||||
// Users
|
||||
listUsers: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/users'),
|
||||
|
||||
// Grants
|
||||
getGrants: (id: number, user: string, host: string) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }),
|
||||
|
||||
// Slow Queries
|
||||
getSlowQueries: (id: number, database?: string) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/slow-queries', { params: database ? { database: database } : {} }),
|
||||
|
||||
// Processlist
|
||||
getProcesslist: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
|
||||
|
||||
// Kill Process
|
||||
killProcess: (id: number, pid: number) =>
|
||||
request.post<any, any>('/instances/' + id + '/detail/processlist/' + pid + '/kill'),
|
||||
|
||||
// Status
|
||||
getStatus: (id: number) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/status'),
|
||||
|
||||
// Variables
|
||||
getVariables: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/variables'),
|
||||
};
|
||||
|
||||
export const backupApi = {
|
||||
list: (instanceId?: number) =>
|
||||
request.get<any, any[]>('/backups', { params: instanceId ? { instance_id: instanceId } : {} }),
|
||||
create: (data: any) =>
|
||||
request.post<any, any>('/backups', data),
|
||||
get: (id: number) =>
|
||||
request.get<any, any>('/backups/' + id),
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import request from './request';
|
||||
|
||||
export var redisApi = {
|
||||
getInfo: function (id: number) {
|
||||
return request.get('/redis/' + id + '/info');
|
||||
},
|
||||
getDbsize: function (id: number, db: number) {
|
||||
return request.get('/redis/' + id + '/dbsize', { params: { db: db } });
|
||||
},
|
||||
getKeys: function (id: number, params: Record<string, any>) {
|
||||
return request.get('/redis/' + id + '/keys', { params: params });
|
||||
},
|
||||
getKeyDetail: function (id: number, key: string, db: number) {
|
||||
return request.get('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
|
||||
},
|
||||
createKey: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/redis/' + id + '/keys', data);
|
||||
},
|
||||
deleteKey: function (id: number, key: string, db: number) {
|
||||
return request.delete('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
|
||||
},
|
||||
setExpire: function (id: number, key: string, data: Record<string, any>) {
|
||||
return request.put('/redis/' + id + '/keys/' + encodeURIComponent(key) + '/expire', data);
|
||||
},
|
||||
flushDb: function (id: number, db: number) {
|
||||
return request.delete('/redis/' + id + '/flushdb', { params: { db: db } });
|
||||
},
|
||||
executeCommand: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/redis/' + id + '/command', data);
|
||||
},
|
||||
getSlowlog: function (id: number, count: number) {
|
||||
return request.get('/redis/' + id + '/slowlog', { params: { count: count } });
|
||||
},
|
||||
getClients: function (id: number) {
|
||||
return request.get('/redis/' + id + '/clients');
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Database, Server, Zap, Leaf,
|
||||
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
|
||||
LogOut, Settings, Clock, Megaphone, Sun, Moon
|
||||
LogOut, Settings, Clock, Megaphone, Sun, Moon, BookOpen
|
||||
} from 'lucide-react';
|
||||
import { menuSections } from '@/lib/mock';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -11,7 +11,7 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
LayoutDashboard, Database, Server, Zap, Leaf,
|
||||
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
|
||||
Settings, Clock, Megaphone,
|
||||
Settings, Clock, Megaphone, BookOpen,
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
@@ -19,7 +19,7 @@ export default function Layout() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentPath = location.pathname.slice(1) || 'dashboard';
|
||||
const currentPath = location.pathname.split('/')[1] || 'dashboard';
|
||||
|
||||
const getPageTitle = () => {
|
||||
const item = visibleMenuItems.find(m => m.id === currentPath);
|
||||
@@ -29,27 +29,27 @@ export default function Layout() {
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
||||
{/* 侧边栏 */}
|
||||
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[260px] min-w-[260px]">
|
||||
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center px-5 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
<div className="h-14 flex items-center px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold dg-gradient-text whitespace-nowrap">
|
||||
<span className="text-base font-bold dg-gradient-text whitespace-nowrap">
|
||||
SQL-Platform
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单 */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-3">
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-1">
|
||||
{menuSections.map(section => {
|
||||
const items = visibleMenuItems.filter(item => item.section === section.id);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={section.id} className="mb-2">
|
||||
<div className="px-5 py-2 text-[11px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
|
||||
<div key={section.id} className="mb-1">
|
||||
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
|
||||
{section.label}
|
||||
</div>
|
||||
{items.map(item => {
|
||||
@@ -58,20 +58,19 @@ export default function Layout() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
flex items-center mx-3 px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-200 relative
|
||||
${isActive
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]'
|
||||
}
|
||||
`}
|
||||
onClick={() => navigate(`/${item.id}`)}
|
||||
onClick={() => navigate('/' + item.id)}
|
||||
className={
|
||||
'flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 relative text-[13px] '
|
||||
+ (isActive
|
||||
? 'bg-blue-500/10 text-blue-400 font-medium'
|
||||
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]')
|
||||
}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 bg-blue-400 rounded-r" />
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-blue-400 rounded-r" />
|
||||
)}
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
<span className="ml-3 text-sm font-medium">{item.label}</span>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="ml-2.5">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -79,56 +78,46 @@ export default function Layout() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="p-4 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer transition-colors">
|
||||
<div className="w-9 h-9 rounded-full dg-gradient-br flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
||||
{user.avatar}
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<div className="text-sm font-medium truncate">{user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{user.roleName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 w-full flex items-center justify-center gap-2 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors text-xs"
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* 顶部栏 */}
|
||||
<header className="h-16 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-6 flex-shrink-0">
|
||||
<header className="h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">SQL-Platform</span>
|
||||
<span className="text-[var(--dg-text-muted)]">/</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{getPageTitle()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-blue-500/15 text-blue-400">
|
||||
{user.roleName}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
title={theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={theme === 'dark' ? '浅色模式' : '深色模式'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-[18px] h-[18px]" /> : <Moon className="w-[18px] h-[18px]" />}
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
<button className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors relative">
|
||||
<Bell className="w-[18px] h-[18px]" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full" />
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors relative">
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="absolute top-1 right-1 w-1.5 h-1.5 bg-red-500 rounded-full" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||
{user.avatar}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login'); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 页面内容 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-6">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-5">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -36,10 +36,8 @@ const roleNameMap: Record<UserRole, string> = {
|
||||
|
||||
const routePermissionMap: Record<string, UserRole[]> = {
|
||||
dashboard: ['admin', 'dba', 'developer', 'guest'],
|
||||
mysql: ['admin', 'dba'],
|
||||
postgres: ['admin', 'dba'],
|
||||
redis: ['admin', 'dba'],
|
||||
mongo: ['admin', 'dba'],
|
||||
instances: ['admin', 'dba'],
|
||||
'data-dictionary': ['admin', 'dba', 'developer'],
|
||||
tickets: ['admin', 'dba', 'developer'],
|
||||
queries: ['admin', 'dba', 'developer', 'guest'],
|
||||
roles: ['admin'],
|
||||
|
||||
@@ -22,10 +22,7 @@ export const menuSections = [
|
||||
|
||||
export const menuItems = [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'mysql', label: 'MySQL', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'postgres', label: 'PostgreSQL', icon: 'Server', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'redis', label: 'Redis', icon: 'Zap', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'mongo', label: 'MongoDB', icon: 'Leaf', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
||||
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
||||
Activity, HardDrive, ChevronRight, Trash2, Edit2,
|
||||
@@ -51,11 +50,11 @@ function getStatusInfo(status: string) {
|
||||
}
|
||||
|
||||
export default function InstancePage() {
|
||||
const { type = 'mysql' } = useParams<{ type: string }>();
|
||||
const canAdd = useActionPermission('instance.add');
|
||||
const canEdit = useActionPermission('instance.edit');
|
||||
const canDelete = useActionPermission('instance.delete');
|
||||
|
||||
const [activeType, setActiveType] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterEnv, setFilterEnv] = useState('');
|
||||
@@ -65,12 +64,18 @@ export default function InstancePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const config = dbTypeConfig[type] || dbTypeConfig.mysql;
|
||||
const typeTabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'mysql', label: 'MySQL' },
|
||||
{ key: 'postgres', label: 'PostgreSQL' },
|
||||
{ key: 'redis', label: 'Redis' },
|
||||
{ key: 'mongo', label: 'MongoDB' },
|
||||
];
|
||||
|
||||
const fetchInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await instanceApi.list({ db_type: type });
|
||||
const res = await instanceApi.list({});
|
||||
const data = Array.isArray(res) ? res : [];
|
||||
setInstances(data);
|
||||
} catch {
|
||||
@@ -78,11 +83,12 @@ export default function InstancePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [type]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchInstances(); }, [fetchInstances]);
|
||||
|
||||
const filtered = instances.filter(i => {
|
||||
if (activeType !== 'all' && i.db_type !== activeType) return false;
|
||||
if (filterStatus && i.status !== filterStatus) return false;
|
||||
if (filterEnv && i.env !== filterEnv) return false;
|
||||
if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false;
|
||||
@@ -104,22 +110,37 @@ export default function InstancePage() {
|
||||
};
|
||||
|
||||
if (showCreate) {
|
||||
return <CreateInstanceForm defaultType={type} onCancel={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchInstances(); }} />;
|
||||
return <CreateInstanceForm defaultType={activeType === 'all' ? 'mysql' : activeType} onCancel={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchInstances(); }} />;
|
||||
}
|
||||
|
||||
const selectedConfig = selectedInstance ? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql) : dbTypeConfig.mysql;
|
||||
|
||||
if (view === 'detail' && selectedInstance) {
|
||||
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={config} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} />;
|
||||
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={selectedConfig} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title={`${config.label} 实例管理`}
|
||||
description={`管理 ${config.label} 数据库实例,监控连接状态`}
|
||||
title="数据源管理"
|
||||
description="管理所有数据库实例,监控连接状态"
|
||||
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: fetchInstances }}
|
||||
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
||||
{typeTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveType(tab.key)}
|
||||
className={'px-4 py-1.5 rounded-md text-sm font-medium transition-colors ' + (activeType === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="dg-card">
|
||||
@@ -168,23 +189,27 @@ export default function InstancePage() {
|
||||
<EmptyState type="search" />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{filtered.map(instance => (
|
||||
{filtered.map(instance => {
|
||||
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
|
||||
const IconComp = ic.icon;
|
||||
return (
|
||||
<div key={instance.id} className="dg-card p-5 cursor-pointer dg-card-hover group" onClick={() => { setSelectedInstance(instance); setView('detail'); }}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`w-11 h-11 rounded-xl ${config.bg} flex items-center justify-center`}>
|
||||
<config.icon className={`w-6 h-6 ${config.color}`} />
|
||||
<div className={'w-11 h-11 rounded-xl ' + ic.bg + ' flex items-center justify-center'}>
|
||||
<IconComp className={'w-6 h-6 ' + ic.color} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{envLabel[instance.env] || instance.env}</span>
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${getStatusInfo(instance.status).online ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : 'bg-red-400'}`} />
|
||||
<div className={'w-2.5 h-2.5 rounded-full ' + (getStatusInfo(instance.status).online ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : 'bg-red-400')} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-1">{instance.name}</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">{config.label} {instance.version || '-'} · {envLabel[instance.env] || instance.env}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">{ic.label} {instance.version || ''} {instance.env ? ' · ' + (envLabel[instance.env] || instance.env) : ''}</p>
|
||||
{instance.description && <p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2">{instance.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -360,11 +385,13 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
||||
const labelCls = 'block text-sm font-medium mb-1.5';
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">添加数据源</h1>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
|
||||
<h2 className="text-lg font-semibold">添加数据源</h2>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
|
||||
{/* 数据源信息 */}
|
||||
<Card className="dg-card">
|
||||
@@ -487,13 +514,14 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
||||
</Card>
|
||||
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-3 pb-6">
|
||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white disabled:opacity-50">
|
||||
{submitting ? '创建中...' : '添加数据源'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user