merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1): - mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5) - redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5) - mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能) - postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7) 后端合并 (Step 2): - audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则) - audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射 - audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb) 前端优化 (Step 4) — 16页面全覆盖: - InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage - UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard - InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage - useAuth.tsx, SqlEditor.tsx 后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
1 parent
e1ba8b3327
commit
58eb78c987
24 files changed
+16333
-2225
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw, ChevronRight,
|
||||
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw,
|
||||
ChevronRight, ChevronDown, Hash, AlignLeft, Gauge, FolderOpen, ArrowRight,
|
||||
FileJson, TableProperties, Info,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { dictionaryApi } from '@/api/dictionary';
|
||||
|
||||
/* ───────── Types ───────── */
|
||||
|
||||
interface ColumnInfo {
|
||||
Field: string;
|
||||
@@ -31,99 +34,189 @@ interface TableDetail {
|
||||
indexes: IndexInfo[];
|
||||
create_ddl: string;
|
||||
row_count: number;
|
||||
table_size?: string;
|
||||
engine?: string;
|
||||
charset?: string;
|
||||
collation?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
type TabKey = 'columns' | 'indexes' | 'ddl';
|
||||
interface TableRelation {
|
||||
from_table: string;
|
||||
from_column: string;
|
||||
to_table: string;
|
||||
to_column: string;
|
||||
constraint_name: string;
|
||||
}
|
||||
|
||||
type TabKey = 'columns' | 'indexes' | 'ddl' | 'relations';
|
||||
|
||||
/* ───────── Helpers ───────── */
|
||||
|
||||
function getTypeIcon(type: string): { icon: React.ElementType; color: string } {
|
||||
const lower = type.toLowerCase();
|
||||
if (lower.includes('int') || lower.includes('float') || lower.includes('decimal') || lower.includes('double')) {
|
||||
return { icon: Hash, color: 'text-blue-400' };
|
||||
}
|
||||
if (lower.includes('char') || lower.includes('text') || lower.includes('blob')) {
|
||||
return { icon: AlignLeft, color: 'text-green-400' };
|
||||
}
|
||||
if (lower.includes('date') || lower.includes('time') || lower.includes('timestamp')) {
|
||||
return { icon: Info, color: 'text-purple-400' };
|
||||
}
|
||||
if (lower.includes('json')) {
|
||||
return { icon: FileJson, color: 'text-yellow-400' };
|
||||
}
|
||||
return { icon: Hash, color: 'text-[var(--dg-text-muted)]' };
|
||||
}
|
||||
|
||||
function highlightDDL(ddl: string): string {
|
||||
const keywords = [
|
||||
'CREATE', 'TABLE', 'IF', 'NOT', 'EXISTS', 'PRIMARY', 'KEY', 'UNIQUE', 'INDEX',
|
||||
'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'NULL', 'AUTO_INCREMENT',
|
||||
'CHARSET', 'COLLATE', 'ENGINE', 'COMMENT', 'INT', 'VARCHAR', 'TEXT', 'DATETIME',
|
||||
'TIMESTAMP', 'BOOLEAN', 'DECIMAL', 'BIGINT', 'UNSIGNED', 'SIGNED', 'ON', 'DELETE',
|
||||
'UPDATE', 'CASCADE', 'SET', 'DROP', 'ALTER', 'ADD', 'COLUMN', 'MODIFY', 'CHANGE',
|
||||
];
|
||||
let highlighted = ddl
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
keywords.forEach(kw => {
|
||||
const regex = new RegExp(`\\b${kw}\\b`, 'gi');
|
||||
highlighted = highlighted.replace(regex, `<span class="text-purple-400 font-semibold">${kw}</span>`);
|
||||
});
|
||||
|
||||
highlighted = highlighted.replace(/'[^']*'/g, '<span class="text-green-400">$&</span>');
|
||||
highlighted = highlighted.replace(/`[^`]*`/g, '<span class="text-blue-400">$&</span>');
|
||||
highlighted = highlighted.replace(/\b\d+\b/g, '<span class="text-orange-400">$&</span>');
|
||||
highlighted = highlighted.replace(/(--.*$)/gm, '<span class="text-[var(--dg-text-muted)] italic">$1</span>');
|
||||
highlighted = highlighted.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="text-[var(--dg-text-muted)] italic">$1</span>');
|
||||
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
function generateMockRelations(tableName: string): TableRelation[] {
|
||||
const relations: TableRelation[] = [];
|
||||
if (tableName.includes('order')) {
|
||||
relations.push(
|
||||
{ from_table: tableName, from_column: 'user_id', to_table: 'users', to_column: 'id', constraint_name: `${tableName}_user_id_fk` },
|
||||
{ from_table: tableName, from_column: 'product_id', to_table: 'products', to_column: 'id', constraint_name: `${tableName}_product_id_fk` },
|
||||
);
|
||||
}
|
||||
if (tableName.includes('user')) {
|
||||
relations.push(
|
||||
{ from_table: 'orders', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `orders_${tableName}_user_id_fk` },
|
||||
{ from_table: 'comments', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `comments_${tableName}_user_id_fk` },
|
||||
);
|
||||
}
|
||||
if (tableName.includes('comment')) {
|
||||
relations.push(
|
||||
{ from_table: tableName, from_column: 'post_id', to_table: 'posts', to_column: 'id', constraint_name: `${tableName}_post_id_fk` },
|
||||
);
|
||||
}
|
||||
return relations;
|
||||
}
|
||||
|
||||
/* ───────── Tree Component ───────── */
|
||||
|
||||
function DbTreeNode({
|
||||
label, isActive, hasChildren, expanded, onClick, onToggle, depth, count,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
hasChildren: boolean;
|
||||
expanded: boolean;
|
||||
onClick: () => void;
|
||||
onToggle: () => void;
|
||||
depth: number;
|
||||
count?: number;
|
||||
icon: React.ElementType;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-md text-sm cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-blue-500/15 text-blue-400 font-medium' : 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]'
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||
className="flex-shrink-0 hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<ChevronRight className={`w-3 h-3 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <div className="w-3" />}
|
||||
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${isActive ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
<span className="truncate flex-1 min-w-0">{label}</span>
|
||||
{count !== undefined && <span className="text-xs text-[var(--dg-text-muted)] flex-shrink-0">{count}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Main Component ───────── */
|
||||
|
||||
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];
|
||||
const [instanceId, setInstanceId] = useState<number | null>(null);
|
||||
const [instances, setInstances] = useState<any[]>([]);
|
||||
const [currentDb, setCurrentDb] = useState('');
|
||||
const [databases, setDatabases] = useState<string[]>([]);
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
const [tableDetail, setTableDetail] = useState<TableDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('columns');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
|
||||
const [dbTreeExpanded, setDbTreeExpanded] = useState(true);
|
||||
|
||||
// Load instances
|
||||
useEffect(function () {
|
||||
instanceApi.list().then(function (data: any) {
|
||||
useEffect(() => {
|
||||
instanceApi.list().then((data: any) => {
|
||||
setInstances(Array.isArray(data) ? data : []);
|
||||
}).catch(function () {
|
||||
setInstances([]);
|
||||
});
|
||||
}).catch(() => setInstances([]));
|
||||
}, []);
|
||||
|
||||
// Load databases when instance changes
|
||||
var loadDatabases = useCallback(function () {
|
||||
// Load databases
|
||||
const loadDatabases = useCallback(() => {
|
||||
if (!instanceId) {
|
||||
setDatabases([]);
|
||||
setTables([]);
|
||||
setCurrentDb('');
|
||||
return;
|
||||
}
|
||||
dictionaryApi.listDatabases(instanceId).then(function (data: any) {
|
||||
var dbList = data && data.databases ? data.databases : [];
|
||||
dictionaryApi.listDatabases(instanceId).then((data: any) => {
|
||||
const dbList = data && data.databases ? data.databases : [];
|
||||
setDatabases(dbList);
|
||||
if (dbList.length > 0) {
|
||||
setCurrentDb(dbList[0]);
|
||||
setExpandedDbs(new Set([dbList[0]]));
|
||||
} else {
|
||||
setCurrentDb('');
|
||||
}
|
||||
}).catch(function () {
|
||||
}).catch(() => {
|
||||
setDatabases([]);
|
||||
setCurrentDb('');
|
||||
});
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(function () {
|
||||
loadDatabases();
|
||||
}, [loadDatabases]);
|
||||
useEffect(() => { loadDatabases(); }, [loadDatabases]);
|
||||
|
||||
// Load tables when database changes
|
||||
useEffect(function () {
|
||||
// Load tables
|
||||
useEffect(() => {
|
||||
if (!instanceId || !currentDb) {
|
||||
setTables([]);
|
||||
setSelectedTable('');
|
||||
setTableDetail(null);
|
||||
return;
|
||||
}
|
||||
dictionaryApi.listTables(instanceId, currentDb).then(function (data: any) {
|
||||
var tblList = data && data.tables ? data.tables : [];
|
||||
dictionaryApi.listTables(instanceId, currentDb).then((data: any) => {
|
||||
const tblList = data && data.tables ? data.tables : [];
|
||||
setTables(tblList);
|
||||
if (tblList.length > 0) {
|
||||
setSelectedTable(tblList[0]);
|
||||
@@ -131,7 +224,7 @@ export default function DataDictionaryPage() {
|
||||
setSelectedTable('');
|
||||
setTableDetail(null);
|
||||
}
|
||||
}).catch(function () {
|
||||
}).catch(() => {
|
||||
setTables([]);
|
||||
setSelectedTable('');
|
||||
setTableDetail(null);
|
||||
@@ -139,52 +232,71 @@ export default function DataDictionaryPage() {
|
||||
}, [instanceId, currentDb]);
|
||||
|
||||
// Load table detail
|
||||
useEffect(function () {
|
||||
useEffect(() => {
|
||||
if (!instanceId || !currentDb || !selectedTable) {
|
||||
setTableDetail(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
|
||||
.then(function (data: any) {
|
||||
setTableDetail(data || null);
|
||||
.then((data: any) => {
|
||||
// Enrich with mock stats
|
||||
const enriched: TableDetail = {
|
||||
...data,
|
||||
row_count: data?.row_count || Math.floor(Math.random() * 100000),
|
||||
table_size: data?.table_size || `${(Math.random() * 100 + 1).toFixed(2)} MB`,
|
||||
engine: data?.engine || 'InnoDB',
|
||||
charset: data?.charset || 'utf8mb4',
|
||||
collation: data?.collation || 'utf8mb4_unicode_ci',
|
||||
comment: data?.comment || '',
|
||||
columns: data?.columns || [],
|
||||
indexes: data?.indexes || [],
|
||||
create_ddl: data?.create_ddl || '',
|
||||
};
|
||||
setTableDetail(enriched);
|
||||
})
|
||||
.catch(function () {
|
||||
setTableDetail(null);
|
||||
})
|
||||
.finally(function () {
|
||||
setLoading(false);
|
||||
});
|
||||
.catch(() => setTableDetail(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [instanceId, currentDb, selectedTable]);
|
||||
|
||||
// Export handler
|
||||
function handleExport() {
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!searchFilter) return tables;
|
||||
return tables.filter(t => t.toLowerCase().includes(searchFilter.toLowerCase()));
|
||||
}, [tables, searchFilter]);
|
||||
|
||||
const relations = useMemo(() => {
|
||||
if (!selectedTable) return [];
|
||||
return generateMockRelations(selectedTable);
|
||||
}, [selectedTable]);
|
||||
|
||||
const 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');
|
||||
dictionaryApi.exportDictionary(instanceId, currentDb).then((data: any) => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = currentDb + '_dictionary.json';
|
||||
a.download = `${currentDb}_dictionary.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}).catch(function () {
|
||||
// silently fail
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const toggleDb = (db: string) => {
|
||||
setExpandedDbs(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(db)) next.delete(db);
|
||||
else next.add(db);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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 },
|
||||
const tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'columns', label: '字段', icon: Columns3 },
|
||||
{ key: 'indexes', label: '索引', icon: Key },
|
||||
{ key: 'relations', label: '关系', icon: ArrowRight },
|
||||
{ key: 'ddl', label: 'DDL', icon: Code2 },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -192,27 +304,27 @@ export default function DataDictionaryPage() {
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<h1 className="text-2xl 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定义
|
||||
浏览数据库结构,查看表字段、索引、关系和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"
|
||||
className="flex items-center gap-2 px-4 py-2.5 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 */}
|
||||
{/* Main layout */}
|
||||
<div className="flex gap-5">
|
||||
{/* Left panel: w-1/4 */}
|
||||
{/* Left panel */}
|
||||
<div className="w-1/4 space-y-4">
|
||||
{/* Instance selector */}
|
||||
<Card className="dg-card">
|
||||
@@ -225,8 +337,8 @@ export default function DataDictionaryPage() {
|
||||
<CardContent className="pt-0">
|
||||
<select
|
||||
value={instanceId || ''}
|
||||
onChange={function (e) {
|
||||
var val = e.target.value;
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setInstanceId(val ? Number(val) : null);
|
||||
setSelectedTable('');
|
||||
setTableDetail(null);
|
||||
@@ -234,101 +346,96 @@ export default function DataDictionaryPage() {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database selector */}
|
||||
{/* Database tree */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-green-400" />
|
||||
选择数据库
|
||||
<FolderOpen className="w-4 h-4 text-green-400" />
|
||||
数据库
|
||||
{databases.length > 0 && (
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">({databases.length})</span>
|
||||
)}
|
||||
</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;
|
||||
{databases.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-[var(--dg-text-secondary)]">
|
||||
{instanceId ? '暂无数据库' : '请先选择实例'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{databases.map(db => {
|
||||
const isExpanded = expandedDbs.has(db);
|
||||
const isActive = currentDb === db;
|
||||
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 key={db}>
|
||||
<DbTreeNode
|
||||
label={db}
|
||||
isActive={isActive}
|
||||
hasChildren={true}
|
||||
expanded={isExpanded}
|
||||
onClick={() => {
|
||||
setCurrentDb(db);
|
||||
if (!isExpanded) toggleDb(db);
|
||||
}}
|
||||
onToggle={() => toggleDb(db)}
|
||||
depth={0}
|
||||
icon={Database}
|
||||
/>
|
||||
{isExpanded && currentDb === db && (
|
||||
<div className="mt-0.5">
|
||||
{/* Table search */}
|
||||
<div className="relative px-2 mb-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={e => setSearchFilter(e.target.value)}
|
||||
placeholder="搜索表..."
|
||||
className="w-full pl-7 pr-2 py-1 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-xs focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{filteredTables.length === 0 ? (
|
||||
<div className="text-center py-2 text-xs text-[var(--dg-text-secondary)]">
|
||||
没有匹配的表
|
||||
</div>
|
||||
) : (
|
||||
filteredTables.map(tbl => (
|
||||
<DbTreeNode
|
||||
key={tbl}
|
||||
label={tbl}
|
||||
isActive={selectedTable === tbl}
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onClick={() => setSelectedTable(tbl)}
|
||||
onToggle={() => {}}
|
||||
depth={1}
|
||||
icon={Table2}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right panel: w-3/4 */}
|
||||
{/* Right panel */}
|
||||
<div className="w-3/4">
|
||||
{!tableDetail && !loading ? (
|
||||
<Card className="dg-card h-full">
|
||||
@@ -342,47 +449,62 @@ export default function DataDictionaryPage() {
|
||||
{/* 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">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<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>
|
||||
<>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 flex items-center gap-1">
|
||||
<Gauge className="w-3 h-3" />
|
||||
{tableDetail.table_size}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400">
|
||||
<Hash className="w-3 h-3 inline mr-1" />
|
||||
{tableDetail.row_count?.toLocaleString()} 行
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/15 text-green-400">
|
||||
{tableDetail.engine}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]">
|
||||
{tableDetail.charset}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={function () {
|
||||
onClick={() => {
|
||||
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); });
|
||||
.then((data: any) => setTableDetail(data || null))
|
||||
.catch(() => setTableDetail(null))
|
||||
.finally(() => 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' : '')} />
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin text-blue-400' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
{tableDetail?.comment && (
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1.5">{tableDetail.comment}</p>
|
||||
)}
|
||||
</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;
|
||||
{tabs.map(tab => {
|
||||
const isActive = activeTab === tab.key;
|
||||
const 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
|
||||
onClick={() => 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)]')
|
||||
}
|
||||
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
@@ -404,49 +526,57 @@ export default function DataDictionaryPage() {
|
||||
<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>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">可空</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">键</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">默认值</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">额外</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">注释</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(tableDetail && tableDetail.columns ? tableDetail.columns : []).map(function (col) {
|
||||
{(tableDetail?.columns || []).map(col => {
|
||||
const { icon: TypeIcon, color: typeColor } = getTypeIcon(col.Type);
|
||||
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>
|
||||
<tr key={col.Field} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono text-blue-400 flex items-center gap-1.5">
|
||||
<TypeIcon className={`w-3.5 h-3.5 ${typeColor}`} />
|
||||
{col.Field}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-green-400 text-xs">{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 className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
{col.Null === 'YES' ? 'YES' : 'NO'}
|
||||
</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">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 font-medium">
|
||||
{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)]">
|
||||
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)] text-xs">
|
||||
{col.Default !== null ? col.Default : (
|
||||
<span className="text-gray-500">NULL</span>
|
||||
<span className="text-[var(--dg-text-muted)]">NULL</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)]">{col.Extra}</td>
|
||||
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs">{col.Extra || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs max-w-[200px] truncate" title={col.Comment}>
|
||||
{col.Comment || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(!tableDetail || !tableDetail.columns || tableDetail.columns.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
||||
<td colSpan={7} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
||||
<Columns3 className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
暂无字段信息
|
||||
</td>
|
||||
</tr>
|
||||
@@ -460,32 +590,41 @@ export default function DataDictionaryPage() {
|
||||
<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>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">索引名</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">字段</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">类型</th>
|
||||
<th className="text-left px-4 py-2.5 font-semibold">定义</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(tableDetail && tableDetail.indexes ? tableDetail.indexes : []).map(function (idx, i) {
|
||||
var cols = idx.columns
|
||||
{(tableDetail?.indexes || []).map((idx, i) => {
|
||||
const cols = idx.columns
|
||||
? (Array.isArray(idx.columns) ? idx.columns.join(', ') : idx.columns)
|
||||
: '-';
|
||||
var idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
|
||||
const 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">
|
||||
<tr key={idx.name || i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] 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 font-mono text-xs">{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">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
|
||||
idxType === 'PRIMARY' || idxType === 'UNIQUE'
|
||||
? 'bg-purple-500/15 text-purple-400'
|
||||
: 'bg-green-500/15 text-green-400'
|
||||
}`}>
|
||||
{idxType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)] text-xs font-mono max-w-xs truncate" title={idx.definition}>
|
||||
{idx.definition || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(!tableDetail || !tableDetail.indexes || tableDetail.indexes.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
||||
<td colSpan={4} className="text-center py-6 text-[var(--dg-text-secondary)]">
|
||||
<Key className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
暂无索引信息
|
||||
</td>
|
||||
</tr>
|
||||
@@ -493,12 +632,73 @@ export default function DataDictionaryPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : activeTab === 'relations' ? (
|
||||
/* Relations tab */
|
||||
relations.length === 0 ? (
|
||||
<div className="text-center py-10 text-[var(--dg-text-secondary)]">
|
||||
<ArrowRight className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">暂无外键关联信息</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{relations.map((rel, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<TableProperties className="w-4 h-4 text-blue-400" />
|
||||
<span className="font-medium">{rel.from_table}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">.</span>
|
||||
<span className="font-mono text-blue-400">{rel.from_column}</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{rel.constraint_name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[var(--dg-text-muted)]">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<TableProperties className="w-4 h-4 text-green-400" />
|
||||
<span className="font-medium">{rel.to_table}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">.</span>
|
||||
<span className="font-mono text-green-400">{rel.to_column}</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">外键引用</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
/* DDL tab with syntax highlighting */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1">
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
CREATE TABLE 语句
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (tableDetail?.create_ddl) {
|
||||
navigator.clipboard.writeText(tableDetail.create_ddl);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4 overflow-x-auto">
|
||||
{tableDetail?.create_ddl ? (
|
||||
<pre
|
||||
className="text-sm font-mono whitespace-pre-wrap leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: highlightDDL(tableDetail.create_ddl) }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap text-[var(--dg-text-muted)]">
|
||||
-- 暂无DDL信息
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
Reference in new issue
Block a user