From e81eed288c2f57e52a713a1071462fdb6dadfde3 Mon Sep 17 00:00:00 2001 From: logan Date: Thu, 28 May 2026 22:31:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SchemaSidebar=E5=AD=97=E6=AE=B5/?= =?UTF-8?q?=E7=B4=A2=E5=BC=95tab=E5=88=87=E6=8D=A2+=E9=80=89=E5=BA=93?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=B1=95=E5=BC=80+singleDb=E8=BF=87=E6=BB=A4?= =?UTF-8?q?+=E8=A1=A8=E5=8D=95=E5=87=BB=E5=B1=95=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchemaSidebar: 展开表后显示字段/索引两个tab,可切换查看 - 新增 tableIndexes state + IndexInfo import - 选库后自动调用 handleExpandDb 加载表(修复之前无表bug) - singleDb 模式下只显示当前选中库 - 表按钮交互改为: 单击展开/折叠, Alt+点击插入表名 - 菜单重组为工作台/管理/系统三段式 - TicketCreatePage 数据库必选(非Redis不自动选) --- frontend/src/components/SchemaSidebar.tsx | 124 +++++++++++++++++----- frontend/src/constants/menu.ts | 25 +++-- frontend/src/pages/QueriesPage.tsx | 4 +- frontend/src/pages/TicketCreatePage.tsx | 111 +++++++++---------- 4 files changed, 169 insertions(+), 95 deletions(-) diff --git a/frontend/src/components/SchemaSidebar.tsx b/frontend/src/components/SchemaSidebar.tsx index 869679a..b6fc431 100644 --- a/frontend/src/components/SchemaSidebar.tsx +++ b/frontend/src/components/SchemaSidebar.tsx @@ -3,7 +3,7 @@ import { ChevronDown, ChevronRight, Database, Table2, Columns3, KeyRound, Search, Loader2, FolderOpen, Server, } from 'lucide-react'; -import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail'; +import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail'; import { redisApi } from '@/api/redis'; /* ------------------------------------------------------------------ */ @@ -15,6 +15,9 @@ interface Props { database: string; dbType: string; onInsert: (text: string) => void; + onDatabaseChange?: (db: string) => void; + /** 为 true 时只显示当前选中的库,不允许切换(数据变更场景防误操作) */ + singleDb?: boolean; } interface RedisKeyItem { @@ -190,7 +193,7 @@ function formatTTL(ttl: number): string { /* COMPONENT */ /* ================================================================== */ -export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) { +export default function SchemaSidebar({ instanceId, database, dbType, onInsert, onDatabaseChange, singleDb }: Props) { const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; const isRedis = dbType === 'redis'; @@ -201,7 +204,9 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } const [dbNodes, setDbNodes] = useState>({}); const [expandedTable, setExpandedTable] = useState(null); const [tableColumns, setTableColumns] = useState([]); + const [tableIndexes, setTableIndexes] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); + const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns'); const [search, setSearch] = useState(''); // -- Redis State -- @@ -237,10 +242,10 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } .finally(() => setLoadingDbs(false)); }, [instanceId, isMySQL]); - // 当 database prop 变化时自动展开 + // 当 database prop 变化时自动展开并加载表 useEffect(() => { if (database && databases.includes(database) && expandedDb !== database) { - setExpandedDb(database); + handleExpandDb(database); } }, [database, databases]); @@ -278,10 +283,13 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } if (expandedTable === key) { setExpandedTable(null); return; } setExpandedTable(key); setTableColumns([]); + setTableIndexes([]); + setDetailTab('columns'); setLoadingColumns(true); try { const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName); setTableColumns(detail?.columns || []); + setTableIndexes(detail?.indexes || []); } catch { /* ignore */ } finally { setLoadingColumns(false); } }, [expandedTable, instanceId]); @@ -330,13 +338,10 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } /* ---------------------------------------------------------------- */ const filteredDatabases = databases.filter(dbName => { - // 如果已选目标数据库,只显示该库 - if (database && dbName !== database) return false; + if (singleDb && database && dbName !== database) return false; if (!search) return true; const kw = search.toLowerCase(); - // 库名匹配 if (dbName.toLowerCase().includes(kw)) return true; - // 该库下的表名匹配 const node = dbNodes[dbName]; if (node?.loaded) { return node.tables.some(t => t.name.toLowerCase().includes(kw)); @@ -450,7 +455,10 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert }
{/* Level 1: Database */} - {/* Level 3: Columns */} + {/* Level 3: Columns & Indexes */} {isTableExpanded && (
{loadingColumns ? ( @@ -516,19 +524,77 @@ export default function SchemaSidebar({ instanceId, database, dbType, onInsert } ...
) : ( - tableColumns.map(col => ( - - )) + <> + {/* 字段/索引 Tab */} +
+ + +
+ + {/* 字段列表 */} + {detailTab === 'columns' && tableColumns.map(col => ( + + ))} + + {/* 索引列表 */} + {detailTab === 'indexes' && ( + tableIndexes.length === 0 ? ( +
无索引
+ ) : ( + Object.entries( + tableIndexes.reduce((acc, idx) => { + if (!acc[idx.Key_name]) acc[idx.Key_name] = []; + acc[idx.Key_name].push(idx); + return acc; + }, {} as Record) + ).map(([keyName, cols]) => ( +
+
+ + {keyName} + {!cols[0].Non_unique && UNI} + {cols[0].Index_type} +
+
+ {cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => ( + {c.Column_name} + ))} +
+
+ )) + ) + )} + )}
)} diff --git a/frontend/src/constants/menu.ts b/frontend/src/constants/menu.ts index a2dc725..dd64ff1 100644 --- a/frontend/src/constants/menu.ts +++ b/frontend/src/constants/menu.ts @@ -1,19 +1,22 @@ export const menuSections = [ - { id: 'overview', label: '概览' }, - { id: 'data', label: '数据源' }, - { id: 'ticket', label: '工单' }, - { id: 'permission', label: '权限' }, + { id: 'workspace', label: '工作台' }, + { id: 'admin', label: '管理' }, { id: 'system', label: '系统' }, ]; export const menuItems = [ - { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard.view' }, - { id: 'instances', label: '数据源', icon: 'Database', section: 'data', roles: ['admin', 'dba'], permission: 'instances.manage' }, - { id: 'queries', label: '查询数据', icon: 'Code2', section: 'data', roles: ['admin', 'dba', 'developer'], permission: 'queries.execute' }, - { id: 'tickets', label: '数据变更', icon: 'FileEdit', section: 'ticket', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'tickets.manage' }, - { id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'], permission: 'users.manage' }, - { id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'permission', roles: ['admin'], permission: 'roles.manage' }, - { id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'permission', roles: ['admin'], permission: 'instance-groups.manage' }, + // 工作台 — 日常操作 + { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard.view' }, + { id: 'queries', label: '查询数据', icon: 'Code2', section: 'workspace', roles: ['admin', 'dba', 'developer'], permission: 'queries.execute' }, + { id: 'tickets', label: '数据变更', icon: 'FileEdit', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'tickets.manage' }, + + // 管理 — 数据源 + 权限 + { id: 'instances', label: '数据源', icon: 'Database', section: 'admin', roles: ['admin', 'dba'], permission: 'instances.manage' }, + { id: 'users', label: '用户管理', icon: 'Users', section: 'admin', roles: ['admin'], permission: 'users.manage' }, + { id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'admin', roles: ['admin'], permission: 'roles.manage' }, + { id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'admin', roles: ['admin'], permission: 'instance-groups.manage' }, + + // 系统 { id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'], permission: 'workflow.manage' }, { id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba'], permission: 'audit-logs.view' }, { id: 'auto-task', label: '自动任务', icon: 'Timer', section: 'system', roles: ['admin'], permission: 'auto-task.manage' }, diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index fbd86d1..a9200f4 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -443,7 +443,9 @@ export default function QueriesPage() { onInsert={(text) => { const sep = dbType === 'redis' ? (sql.trim() ? '\n' : '') : (sql.trim() ? ' ' : ''); setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' ')); - }} /> + }} + onDatabaseChange={setDatabase} + singleDb={!!database} /> )} diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index c050cb7..ebb620b 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -106,7 +106,7 @@ export default function TicketCreatePage() { useEffect(() => { if (!instanceId) { setDatabases([]); setDatabase(''); return; } - setDatabase(''); setDatabases([]); setTables([]); + setDatabase(''); setDatabases([]); const inst = instances.find(i => i.id === Number(instanceId)); if (!inst) return; setLoadingDatabases(true); @@ -115,8 +115,8 @@ export default function TicketCreatePage() { const data = (res as { databases?: string[] })?.databases || (res as string[]) || []; const list = Array.isArray(data) ? data as string[] : []; setDatabases(list); - if (list.length > 0) { - setDatabase(inst.db_type === 'redis' ? 'db0' : String(list[0])); + if (inst.db_type === 'redis') { + setDatabase('db0'); } }) .catch(() => setDatabases([])) @@ -290,7 +290,7 @@ export default function TicketCreatePage() { ) : databases.length > 0 ? ( ) : ( @@ -350,59 +350,71 @@ export default function TicketCreatePage() { {showBrowser && instanceId && (
+ onInsert={handleSchemaInsert} singleDb />
)} - {/* SQL 编辑器 */} + {/* SQL 编辑器卡片 */}
-
+
+ {/* 编辑器顶栏:标题 + SQL检测按钮 */} +
+ SQL 变更语句 + +
-
- - {sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''} - {sqlContent.length} 字符 - -
- {validated && 检测通过} - Ctrl+Enter 格式化 + {/* 编辑器底栏 */} +
+ + {sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''} + {sqlContent.length} 字符 + +
+ {validated && 检测通过} + Ctrl+Enter 格式化 +
-
-
- {/* ====== 审核结果 ====== */} - {auditResult && ( -
-
- - 审核结果 - - {errorCount > 0 && {errorCount} 错误} - {warnCount > 0 && {warnCount} 警告} - {infoCount > 0 && {infoCount} 提示} - {errorCount === 0 && warnCount === 0 && infoCount === 0 && ( - 通过 - )} -
- {auditResult.findings.length > 0 && ( -
- {auditResult.findings.map((f, idx) => ( -
- - {LEVEL_LABEL[f.level] || f.level} - · - {f.rule} - — - {f.message} + {/* 审核结果(紧跟编辑器) */} + {auditResult && ( +
+
+ + 审核结果 + + {errorCount > 0 && {errorCount} 错误} + {warnCount > 0 && {warnCount} 警告} + {infoCount > 0 && {infoCount} 提示} + {errorCount === 0 && warnCount === 0 && infoCount === 0 && ( + 通过 + )} +
+ {auditResult.findings.length > 0 && ( +
+ {auditResult.findings.map((f, idx) => ( +
+ + {LEVEL_LABEL[f.level] || f.level} + · + {f.rule} + — + {f.message} +
+ ))}
- ))} + )}
)}
- )} +
{/* ====== 变更原因 ====== */}
@@ -414,17 +426,8 @@ export default function TicketCreatePage() { placeholder="请描述变更原因、影响范围、回滚方案等" />
- {/* ====== 底部操作栏 ====== */} -
- {/* SQL 检测 */} - - - {/* 提交审批 */} + {/* ====== 提交审批 ====== */} +