DBView 开发日志 ② — 侧边栏重构与 Navicat 风格化

DBView 开发日志 ② — 侧边栏重构与 Navicat 风格化

日期: 2026-06-08
项目: DBView — Database Visual Explorer(数据库可视化工具)
状态: v1.0 功能完善与修复阶段


一、本期概要

在 v1.0 全部功能模块开发完成后,我们进入了一个重要的体验优化阶段。本期主要完成了两件大事:

  1. 侧边栏数据库树全面重构 — 从简陋的列表升级为 Navicat 风格的交互式树形结构
  2. 连接管理体验重塑 — 侧边栏内直接完成连接增删改查,无需跳转页面

同时修复了数据库树展开的一系列 Bug,确保基本交互流程的稳定性。


二、重构动机:从功能完备到体验顺滑

v1.0 阶段我们一口气完成了 10 大功能模块,但侧边栏的数据库树一直是个”短板”:

问题 影响
连接列表纯文本展示 无法直观区分数据库类型和连接状态
连接管理需要跳转页面 增删改查操作流程割裂
数据库节点无结构化展示 表/视图/存储过程混在一起
无右键菜单 所有操作依赖顶部按钮
无连接分组 连接多了以后难以管理

重构目标: 参考 Navicat 的侧边栏设计,让数据库树成为一个功能完整的操作中心。


三、Navicat 风格侧边栏设计

3.1 连接节点增强

每个连接节点现在展示:

1
2
3
4
5
6
7
8
9
┌──────────────────────────────────────┐
│ 💾 my_database [MySQL] │ ← 连接名 + 数据库类型徽章
│ ├── 📁 表 (12) │ ← 始终显示
│ ├── 📁 视图 (3) │ ← 始终显示
│ ├── 📁 查询 │ ← 始终显示(查询历史)
│ ├── 📁 用户 (5) │ ← 始终显示
│ ├── 📁 存储过程 (2) │ ← 仅存在时显示
│ └── 📁 函数 (1) │ ← 仅存在时显示
└──────────────────────────────────────┘

关键设计决策:

  • 数据库类型徽章 — 每个连接旁显示彩色标签(MySQL 蓝、PostgreSQL 绿、SQLite 橙、Oracle 粉),一目了然
  • 连接状态颜色 — 未连接时图标灰色(#999),已连接时绿色(#52c41a
  • 文件夹始终展示 — 表/视图/查询/用户四个文件夹始终可见,保持结构一致;存储过程和函数仅在存在时显示
  • 数量标注 — 文件夹标题附带数量(如”表 (12)”),用户无需展开即可了解数据规模

3.2 连接分组管理

支持将连接归类到不同分组:

1
2
3
4
5
6
7
// 分组节点
{
key: `group:${group.id}`,
title: group.name,
icon: <FolderOutlined />,
children: [/* 组内连接节点 */]
}

分组通过 connectionApi.listGroups() 加载,支持创建、重命名、删除分组。未分组的连接直接显示在根层级。

3.3 右键菜单体系

为每种节点类型设计了专属的右键菜单:

节点类型 菜单项
连接(未连接) 连接 / 编辑连接 / 复制连接 / 删除连接
连接(已连接) 断开连接 / 编辑连接 / 复制连接 / 删除连接
分组 新建连接 / 删除分组
数据库 新建查询 / 刷新 / 复制数据库名
表/视图 查看数据 / 查看结构 / 复制表名 / 在新查询中打开
存储过程/函数 查看定义

3.4 折叠/展开布局

侧边栏支持折叠为仅图标模式(36px 宽度),保留新建连接按钮,适合小屏幕或专注编辑的场景:

1
2
3
// uiStore 中管理折叠状态
sidebarCollapsed: boolean
toggleSidebar: () => void

四、关键技术实现

4.1 DatabaseTree 组件重构

重构后的 DatabaseTree.tsx 从约 400 行增长到 943 行,是项目中最大的组件。核心架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const DatabaseTree: React.FC = () => {
// 状态管理
const [treeData, setTreeData] = React.useState<DataNode[]>([])
const [expandedKeys, setExpandedKeys] = React.useState<React.Key[]>([])
const treeDataRef = useRef(treeData)

// 1. 构建根节点(连接 + 分组)
React.useEffect(() => {
// 从 connections + groups 重建树
// 保留已加载子节点的 children 缓存
}, [connections, groups])

// 2. 连接状态颜色更新(独立 effect)
React.useEffect(() => {
// 仅当 connectedIds 变化时更新图标颜色
// 不触及其他节点的 children
}, [connectedIds])

// 3. 自动展开新连接
React.useEffect(() => {
// 检测 newlyConnected,自动展开并加载子节点
}, [connectedIds])

// 4. 异步加载子节点
const loadChildren = useCallback(async (nodeKey) => {
// 按 key 类型分发:conn → db → folder → table
}, [connectedIds])

// 5. 右键菜单
const getContextMenu = (node) => { /* ... */ }

// 6. 双击事件
const onDoubleClick = (event, node) => { /* ... */ }
}

4.2 异步加载链

数据库树的加载是一个多级异步链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户点击连接节点
→ loadChildren("conn:xxx")
→ 自动连接数据库(如未连接)
→ 获取数据库列表
→ 返回数据库节点

用户展开数据库节点
→ loadChildren("db:connId:schema")
→ 并行获取表/视图/存储过程
→ 返回文件夹节点

用户展开文件夹
→ loadChildren("folder:tables:connId:schema")
→ 获取具体数据
→ 返回叶子节点

4.3 关键数据结构

每个树节点通过 itemTypekey 模式区分类型:

1
2
3
4
5
6
7
8
9
10
11
// key 编码规则
`conn:${id}` // 连接节点
`db:${connId}:${schema}` // 数据库节点
`folder:${type}:${connId}:${schema}` // 文件夹节点(type: tables/views/queries/users/procedures/functions/columns/indexes)
`table:${connId}:${schema}:${name}` // 表节点
`view:${connId}:${schema}:${name}` // 视图节点
`col:${connId}:${schema}:${table}:${name}` // 列节点
`idx:${connId}:${schema}:${table}:${name}` // 索引节点
`user:${connId}:${schema}:${name}` // 用户节点
`routine:${connId}:${schema}:${type}:${name}` // 存储过程/函数节点
`query:${connId}:${schema}:${id}` // 查询历史节点

4.4 用户列表接口

新增 getUsers() 接口,四种数据库驱动各自实现:

驱动 实现方式
MySQL SELECT User, Host FROM mysql.user
PostgreSQL SELECT usename AS name FROM pg_catalog.pg_user
SQLite 无用户概念,返回空数组
Oracle SELECT username AS name FROM dba_users
1
2
3
4
5
// 统一接口
export interface UserInfo {
name: string
host?: string // MySQL 特有
}

4.5 treeData 缓存策略

一个值得注意的优化点:treeData 重建时保留已加载子节点

当新建/编辑连接后,connections 状态变化会触发 treeData 重建。如果不做缓存,已展开的子树会被清空,用户需要重新展开所有节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 重建前索引所有已加载的 children
const prevChildrenByKey = new Map<React.Key, DataNode[] | undefined>()
const collect = (nodes: DataNode[]) => {
for (const n of nodes) {
if (n.children !== undefined) prevChildrenByKey.set(n.key, n.children)
if (n.children) collect(n.children)
}
}
collect(prev)

// 重建时恢复缓存
const node = buildConnectionNode(conn)
const cached = prevChildrenByKey.get(node.key)
roots.push(cached !== undefined ? { ...node, children: cached } : node)

五、Bug 修复实录

Bug 1:MySQL 系统库过滤导致空列表

症状: 连接 MySQL 后展开,数据库列表为空。

根因: MySQL 驱动 getDatabases() 过滤了系统库(information_schemamysqlperformance_schemasys),但当用户只有系统库权限时,过滤后返回空列表。

修复: 有用户库时只显示用户库,没有则 fallback 显示全部(含系统库)。

1
2
3
4
// mysql-driver.ts
const systemDbs = new Set(['information_schema', 'mysql', 'performance_schema', 'sys'])
const userDbs = databases.filter((db) => !systemDbs.has(db.Database))
return userDbs.length > 0 ? userDbs : databases.map((db) => db.Database)

Bug 2:文件夹节点 key 解析错位

症状: 展开”表/视图/查询/用户”文件夹时 API 请求传入错误的连接 ID。

根因: 文件夹节点的 key 格式为 folder:${folderType}:${connId}:${schema},但 loadChildren 中解析时把 folderType 当成了 connId

修复: 统一 key 解析逻辑,确保各段索引正确:

1
2
3
4
// key format: folder:FOLDERTYPE:CONNID:SCHEMA[:TABLE]
const folderType = parts[1]
connId = parts[2] // 原来是 parts[1],导致取到 folderType
const schema = parts[3]

Bug 3:连接状态图标独立更新

问题: connectedIds 变化时,如果触发 treeData 全量重建,会清空 antd Tree 内部的 loadedKeys 缓存,导致已展开的节点重新加载。

修复: 将连接图标颜色更新拆为独立的 useEffect,仅替换连接节点的 icon 属性,不触及其他节点的 children

1
2
3
4
5
6
7
8
9
React.useEffect(() => {
setTreeData((prev) => {
let changed = false
const patch = (nodes: DataNode[]): DataNode[] => {
// 只更新 __connected 和 icon,保留 children
}
return changed ? patch(prev) : prev
})
}, [connectedIds])

六、项目数据

指标 数值
总提交数 7
源文件数 46(.ts/.tsx)
最大组件 DatabaseTree.tsx(943 行)
本期新增/修改文件 18 个
本期新增代码 +961 行
本期删除代码 -321 行

七、经验与反思

做得好的

  • treeData 缓存策略 — 保留已加载子节点避免重建丢失,这个设计在后续多次 connections 变化中证明了价值
  • 独立 icon 更新 effect — 将连接状态颜色更新与 treeData 重建分离,避免了 antd Tree 内部状态被意外清空
  • key 编码规范 — 统一的 key 格式(type:connId:schema:name)让解析逻辑清晰可维护

可以改进的

  • DatabaseTree 组件过大 — 943 行承载了树渲染、异步加载、右键菜单、连接表单等太多职责,后续应拆分
  • 异步加载缺少错误重试 — 网络波动时加载失败没有重试机制
  • 连接自动展开时序 — 自动展开新连接时与 antd Tree 的 onLoadData 存在竞态,需要更精细的协调

八、下期预告

下一期将深入讲解 数据库驱动架构设计与实现,包括:

  • DatabaseDriver 接口的设计考量与演化
  • 四种数据库驱动的实现对比(MySQL / PostgreSQL / SQLite / Oracle)
  • 查询取消机制的跨驱动实现
  • 驱动测试策略

敬请期待!


DBView 开发日志系列 — 记录一个数据库可视化工具从 0 到 1 的完整历程