方案审计与修订说明
| # | 审计项 | v3 方案(原) | v4 方案(修订) | 修订原因 |
|---|---|---|---|---|
| 1 | 工具栏交互 | 未对齐 DBX 现有按钮风格、布局结构和图标规范 | 严格遵循现有 shadcn-vue Button 规范、lucide 图标、工具栏布局 | PR 需要与现有 UI 风格完全一致,否则会被 maintainer 要求修改 |
| 2 | 配置存储 | 新增 Rust 后端 match_rules.rs 持久化匹配规则 |
沿用 localStorage + dbx:diagram:... key 前缀 + safeLocalStorageGet/Set |
DBX 自定义关系已用此模式存储,不引入新的存储机制 |
| 3 | 后端改动 | 新增 match_rules.rs(Tauri Command)、修改 schema.rs |
仅修改 schema.rs:新增 get_all_columns 和 ColumnInfo.is_unique |
删除存储相关后端改动,存储全部在前端 localStorage 完成 |
DBX 现有操作规范
在描述新方案之前,先明确需要遵循的现有规范。以下内容基于对 SchemaDiagramDialog.vue(v0.5.58)的源码分析。
Dialog 容器结构
<Dialog :open="open" @update:open="(v) => model = v">
<DialogContent class="w-[94vw] h-[86vh] flex flex-col p-0">
<DialogHeader class="px-4 py-3 border-b">
<DialogTitle>Network图标 + "ER 图"标题</DialogTitle>
</DialogHeader>
<!-- 工具栏 -->
<div class="flex items-center gap-2 border-b px-3 py-2 shrink-0 overflow-x-auto">
...按钮和选择器...
</div>
<!-- 可折叠面板(关系建模 / 匹配管理) -->
<div v-if="showPanel" class="shrink-0 border-b">...</div>
<!-- 画布 -->
<div class="min-h-0 flex-1 bg-muted/20">...</div>
</DialogContent>
</Dialog>
按钮规范
| 场景 | variant | size | 额外 class | 图标尺寸 |
|---|---|---|---|---|
| 带文字的操作按钮(建模关系、复制 SQL) | outline | sm | h-8 px-2 text-xs | h-3.5 w-3.5 + mr-1 |
| 纯图标按钮(缩放、刷新、导出) | ghost | icon | h-8 w-8 | h-4 w-4 |
| 模式切换按钮组(表模式/工程模式) | ghost | sm | h-8 rounded-none px-2 text-xs | h-3.5 w-3.5 + mr-1 |
| 面板内主要操作按钮(添加关系) | default | sm | h-8 px-2 text-xs | h-3.5 w-3.5 + mr-1 |
现有工具栏布局(改造前)
现有存储模式
DBX 的自定义关系存储使用 原生 localStorage,key 格式为 dbx:diagram:relationships:v1:<connectionId>:<database>:<schema>。全项目另有一层安全封装 safeLocalStorageGet/Set/Remove(位于 lib/backend/safeStorage.ts),使用 globalThis.localStorage + try-catch。当前 ER 图模块直接使用原生 localStorage,本方案统一迁移到 safeLocalStorage 封装。
localStorage + dbx:diagram:... key 前缀,按"连接 + 数据库 + schema"粒度隔离,与自定义关系保持一致的存储范式。
现状分析与核心问题
DBX ER 图当前实现
DBX 的 ER 图功能完全自研,基于原生 HTML/CSS + SVG 渲染,没有引入任何第三方图可视化库。整个功能封装在一个约 1150 行的 SchemaDiagramDialog.vue 组件中。支持两种视图模式(Table View 和 Engineering View),正交折线连线路由(自动绕开中间表卡片),简单网格布局,缩放范围 0.6x - 1.5x,搜索过滤,聚焦模式,自定义关系建模(localStorage 持久化)和 JOIN SQL 自动生成。
与 DataGrip / Navicat 的差距
| 交互维度 | DataGrip | Navicat | DBX 现状 |
|---|---|---|---|
| 自动关系推断 | 物理外键 + 正则匹配虚拟外键 | 仅物理外键 | 仅物理外键 + 手动自定义 缺少智能匹配 |
| 布局算法 | 多种可选布局 + 方向控制 | Auto-Layout 一键排列 | 简单网格 无分层布局 |
| 缩放范围 | 无硬限制 | 无硬限制 | 0.6x - 1.5x 范围过窄 |
| 框选 | 框选复制 | 搜索筛选 | 无 完全缺失 |
| 撤销/重做 | Ctrl+Z/Y | 无限次 Undo/Redo | 无 完全缺失 |
| 连线交互 | 显示/隐藏虚拟外键 | 悬停高亮、编辑折点 | SVG 箭头连线不可交互 连线无交互 |
核心问题
关系发现能力弱
仅依赖物理外键。大量项目不建外键,或使用不支持外键的数据库(MongoDB、ClickHouse),ER 图上大量表呈现为孤岛。
布局与交互原始
网格布局无法体现表间逻辑关系,50+ 张表时连线交叉严重。缺少框选、撤销、连线交互等基本操作。
单体组件架构瓶颈
全部逻辑集中在单个 1150 行 Vue 组件中,渲染、布局、路由、交互、状态管理耦合,难以扩展和测试。
整体架构设计
技术选型
| 能力层 | Dify | Coze | DBX v4 | 选型理由 |
|---|---|---|---|---|
| 画布框架 | ReactFlow | FlowGram(Canvas) | Vue Flow | Vue 3 项目;ReactFlow 忠实移植[6],gzip 49.8KB |
| 布局引擎 | ELK.js(懒加载) | 自研 | ELK.js(打包) | 布局 + 正交边路由[7];打包确保离线可用 |
| 状态管理 | Zustand + Immer | MobX | Pinia(现有) | DBX 已用 Pinia,撤销/重做内嵌到 store |
| 配置存储 | localStorage + CRDT | 未知 | localStorage(现有) | 沿用 dbx:diagram:... key + safeLocalStorage |
模块拆分
graph TB
subgraph UI["UI 层 (Vue Components)"]
A["SchemaDiagramDialog.vue
Dialog 容器 + 工具栏"]
B["TableNode.vue
表卡片(Vue Flow 自定义节点)"]
C["RelationshipEdge.vue
关系线(Vue Flow 自定义边)"]
D["MatchPanel.vue
匹配规则管理面板"]
end
subgraph Adapter["适配层"]
E["VueFlowAdapter
图数据 ↔ VueFlow 格式转换"]
end
subgraph Core["核心引擎 (lib/diagram/)"]
F["GraphStore
Pinia + 撤销/重做历史栈"]
G["LayoutManager
ELK.js 布局 + 正交边路由"]
H["MatchEngine
ID 关联智能匹配"]
I["MatchStorage
匹配规则 localStorage 读写"]
end
subgraph Backend["后端 (Rust) — 仅微调"]
J["schema.rs
+get_all_columns
+ColumnInfo.is_unique"]
end
A --> E
B --> E
C --> E
F --> E
A --> G
A --> H
H --> I
F --> J
工具栏改造方案
新工具栏严格沿用 DBX 现有布局:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增按钮遵循现有 Button 规范,新增图标使用 lucide-vue。Vue Flow 的 Controls 和 MiniMap 作为浮动组件叠加在画布上,不占用工具栏空间。
改造后工具栏布局
新增/变更按钮明细
| 按钮 | 位置 | 规范 | 图标 | 说明 |
|---|---|---|---|---|
| 自动匹配新增 | "建模关系"按钮右侧 | variant="outline" size="sm" class="h-8 px-2 text-xs" |
ScanSearch(lucide,h-3.5 w-3.5) |
切换打开/关闭 MatchPanel 面板,复用现有关系面板的条件渲染模式 |
| 自动布局新增 | "自动匹配"按钮右侧 | variant="outline" size="sm" class="h-8 px-2 text-xs" |
LayoutGrid(lucide,h-3.5 w-3.5) |
点击触发 ELK 自动布局;下拉可选方向(LR / TB / RL / BT) |
| 匹配关系 Badge新增 | Badge 区域,自定义关系 Badge 前 | variant="secondary" class="h-6 text-xs" |
无 | 显示当前自动匹配推断的关系数,点击切换显示/隐藏 |
| 缩放按钮变更 | 工具栏右侧图标区 | 移除,由 Vue Flow Controls 替代 | — | Vue Flow 的 <Controls /> 浮动在画布右下角,包含 +/-/fit/lock 按钮 |
| 重置布局变更 | 工具栏右侧 | 移除,由"自动布局"按钮替代 | — | "自动布局"按钮已包含重排功能,无需单独的重置按钮 |
v-if="showMatchPanel" 条件渲染在工具栏和画布之间,包含 Select 选择器 + Button 操作 + Badge 列表。用户在 DBX 中看到的是一个与现有"建模关系"面板风格完全一致的"自动匹配"面板。
存储方案
所有匹配相关数据存储在前端 localStorage,使用 DBX 现有的 safeLocalStorageGet/Set/Remove 封装。key 格式与自定义关系保持一致的 dbx:diagram:... 前缀 + "连接 + 数据库 + schema"粒度。
存储 key 设计
| 数据类型 | key 格式 | 现有/新增 |
|---|---|---|
| 自定义关系 | dbx:diagram:relationships:v1:<connId>:<db>:<schema> | 现有(保持不变) |
| 匹配确认记录 | dbx:diagram:match-confirms:v1:<connId>:<db>:<schema> | 新增 |
| 匹配忽略记录 | dbx:diagram:match-ignores:v1:<connId>:<db>:<schema> | 新增 |
| 用户自定义正则规则 | dbx:diagram:match-rules:v1:<connId>:<db>:<schema> | 新增 |
| 匹配全局开关 | dbx:diagram:match-enabled | 新增 |
存储实现代码
// match-storage.ts
import { safeLocalStorageGet, safeLocalStorageSet, safeLocalStorageRemove }
from "@/lib/backend/safeStorage";
function matchStorageKey(
type: "match-confirms" | "match-ignores" | "match-rules",
connectionId: string,
database: string,
schema: string,
): string {
return ["dbx", "diagram", type, "v1", connectionId, database, schema].join(":");
}
// 加载已确认的匹配关系
export function loadMatchConfirms(
connectionId: string, database: string, schema: string
): string[] {
const key = matchStorageKey("match-confirms", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch { return []; }
}
// 保存已确认的匹配关系
export function saveMatchConfirms(
ids: string[], connectionId: string, database: string, schema: string
): void {
const key = matchStorageKey("match-confirms", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(ids));
}
// 加载已忽略的匹配关系
export function loadMatchIgnores(
connectionId: string, database: string, schema: string
): string[] {
const key = matchStorageKey("match-ignores", connectionId, database, schema);
try {
return JSON.parse(safeLocalStorageGet(key) || "[]");
} catch { return []; }
}
// 保存已忽略的匹配关系
export function saveMatchIgnores(
ids: string[], connectionId: string, database: string, schema: string
): void {
const key = matchStorageKey("match-ignores", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(ids));
}
// 匹配全局开关(跨连接共享)
export function isAutoMatchEnabled(): boolean {
return safeLocalStorageGet("dbx:diagram:match-enabled") !== "false";
}
export function setAutoMatchEnabled(enabled: boolean): void {
safeLocalStorageSet("dbx:diagram:match-enabled", String(enabled));
}
match_rules.rs(Tauri Command: save_match_rules / load_match_rules)已删除。匹配规则的存储量很小(通常几十条 JSON),localStorage 完全胜任,且与 DBX 现有的自定义关系存储方式保持一致。
智能 ID 关联匹配引擎
本方案中最有价值的增量能力。DataGrip 通过正则表达式虚拟外键实现了类似功能[1],但需要用户手动配置。本方案内置开箱即用的自动匹配策略。
匹配策略分层
| 优先级 | 策略 | 规则 | 置信度 |
|---|---|---|---|
| P0 | 物理外键 | 读取 INFORMATION_SCHEMA 外键约束 | 100% |
| P1 | 命名约定 | {table}_id / {table}_uuid → 目标表主键,支持 snake_case / camelCase | 高(自动确认) |
| P2 | 类型签名 | P1 + 源列与目标列类型兼容(如都是 bigint) | 高(自动确认) |
| P3 | 正则规则 | 用户自定义正则,如 (.*)_id → $1.id[1] | 中(需确认) |
匹配算法核心逻辑
function inferRelationships(tables: TableMeta[]): InferredRelationship[] {
const results: InferredRelationship[] = [];
const tableNameSet = new Set(tables.map(t => t.name));
const primaryKeys = buildPrimaryKeyIndex(tables);
for (const table of tables) {
for (const column of table.columns) {
if (column.is_primary_key) continue;
// P1: 命名约定匹配
const match = column.name.match(/^(.+?)_(?:id|uuid|pk)$/i);
if (!match) continue;
const candidateTable = toSnakeCase(match[1]);
if (!tableNameSet.has(candidateTable)) continue;
const targetPK = primaryKeys.get(candidateTable);
if (!targetPK) continue;
// P2: 类型签名校验
if (!isTypeCompatible(column.data_type, targetPK.data_type)) continue;
results.push({
sourceTable: table.name,
sourceColumn: column.name,
targetTable: candidateTable,
targetColumn: targetPK.name,
confidence: 'high',
strategy: 'naming_convention',
});
}
}
return deduplicate(results);
}
匹配结果与存储交互
匹配引擎运行时需要与 match-storage.ts 交互,过滤已确认和已忽略的记录:
// match-engine.ts 中的过滤逻辑
function filterByStorage(
inferred: InferredRelationship[],
confirms: string[], // 从 localStorage 加载
ignores: string[], // 从 localStorage 加载
): MatchResult {
const confirmed = inferred.filter(r => confirms.includes(r.id));
const pending = inferred.filter(r =>
!confirms.includes(r.id) && !ignores.includes(r.id)
&& r.confidence === 'high'
);
const conflicts = pending.filter(r => hasMultipleTargets(r, pending));
return {
relationships: [...confirmed, ...pending.filter(r => !conflicts.includes(r))],
conflicts,
pending: conflicts,
stats: { total: inferred.length, high: confirmed.length + pending.length, ... },
};
}
Vue Flow + ELK.js 交互设计
Vue Flow 提供的开箱即用能力
| 能力 | Vue Flow 原生 | DBX v1 中 |
|---|---|---|
| 节点拖拽 | 内置 draggable | 手动 mousedown/move/up |
| 缩放与平移 | 内置 viewport,无范围限制 | 自研 diagramZoom.ts,0.6x-1.5x |
| 框选多选 | SelectionMode.Partial | 缺失 |
| MiniMap | <MiniMap /> 浮动组件 | 缺失 |
| Controls | <Controls /> 浮动组件(替代工具栏 +/- 按钮) | 手动按钮 |
| 背景网格 | <Background /> | CSS 背景 |
| 虚拟化 | onlyRenderVisibleElements | 缺失 |
ELK.js 布局配置
// elk-layout.ts
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
export async function computeLayout(
graph: DiagramGraph, options: LayoutOptions
): Promise<LayoutResult> {
const elkGraph = buildElkGraph(graph, options);
const result = await elk.layout(elkGraph);
return extractLayoutResult(result);
}
| 配置项 | 值 | 说明 |
|---|---|---|
elk.algorithm | layered | Sugiyama 分层布局 |
elk.direction | RIGHT / DOWN | 工具栏下拉切换 |
edgeRouting | ORTHOGONAL | 正交折线,自动避开节点 |
nodePlacement | BRANDES_KOEPF | 平衡对齐(同 Dify) |
crossingMinimization | LAYER_SWEEP | 交叉最小化 |
layering.strategy | NETWORK_SIMPLEX | 最小化边跨度 |
separateConnectedComponents | true | 自动分离孤立子图 |
Pinia 原生撤销/重做
不引入第三方撤销库。在 GraphStore(Pinia)中手动维护 historyStack + redoStack,使用 lodash-es 的 cloneDeep 做快照(DBX 已通过 shadcn-vue 间接依赖 lodash-es,无新增依赖)。
// graph-store.ts(核心片段)
export const useGraphStore = defineStore('diagram-graph', () => {
const nodes = ref<DiagramNode[]>([]);
const edges = ref<DiagramEdge[]>([]);
const historyStack = ref<HistorySnapshot[]>([]);
const redoStack = ref<HistorySnapshot[]>([]);
const maxHistorySize = 50;
function pushHistory() {
historyStack.value.push({
nodes: cloneDeep(nodes.value),
edges: cloneDeep(edges.value),
});
if (historyStack.value.length > maxHistorySize) historyStack.value.shift();
redoStack.value = [];
}
function undo() {
if (!historyStack.value.length) return;
redoStack.value.push({ nodes: cloneDeep(nodes.value), edges: cloneDeep(edges.value) });
const prev = historyStack.value.pop()!;
nodes.value = prev.nodes;
edges.value = prev.edges;
}
function redo() { /* 对称实现 */ }
// 仅布局调整和关系操作记录历史,选择/缩放不记录
function applyLayout(newNodes: DiagramNode[], newEdges: DiagramEdge[]) {
pushHistory();
nodes.value = newNodes;
edges.value = newEdges;
}
});
后端改动(最小化)
v4 方案的后端改动仅限于 schema.rs,不新增任何 Tauri Command 或存储模块:
| 改动点 | 文件 | 内容 | 说明 |
|---|---|---|---|
| 批量列查询 | schema.rs | 新增 get_all_columns 命令 | 一次返回 Schema 下所有表的列,避免匹配引擎逐表 IPC |
| 列唯一键标识 | schema.rs | ColumnInfo 新增 is_unique 字段 | 辅助匹配引擎判断目标列是否为主键或唯一键 |
文件结构规划
apps/desktop/src/
├── components/diagram/
│ ├── SchemaDiagramDialog.vue (重构:拆分为容器 + 工具栏)
│ ├── **TableNode.vue** (新增:Vue Flow 自定义节点)
│ ├── **RelationshipEdge.vue** (新增:Vue Flow 自定义边)
│ ├── **MatchPanel.vue** (新增:匹配管理面板)
│ └── **DiagramToolbar.vue** (新增:工具栏提取)
│
├── lib/diagram/
│ ├── erDiagram.ts (保留:核心数据模型)
│ ├── engineeringDiagram.ts (保留:工程 ER 图)
│ ├── **vue-flow-adapter.ts** (新增:Vue Flow 适配层)
│ ├── **graph-store.ts** (新增:Pinia + 撤销/重做历史栈)
│ ├── **layout-manager.ts** (新增:布局调度)
│ ├── **elk-layout.ts** (新增:ELK.js 布局配置)
│ ├── **layout-grid.ts** (新增:网格布局 fallback)
│ ├── **match-engine.ts** (新增:智能匹配引擎)
│ ├── **match-strategies.ts** (新增:匹配策略)
│ ├── **match-storage.ts** (新增:localStorage 读写,复用 safeLocalStorage)
│ └── fieldLineage.ts (保留)
│
├── types/
│ └── **diagram.ts** (新增:类型定义)
│
└── tests/
├── unit/
│ ├── **match-engine.test.ts**
│ ├── **match-storage.test.ts**
│ ├── **layout-manager.test.ts**
│ ├── **vue-flow-adapter.test.ts**
│ └── **graph-store.test.ts**
└── e2e/
└── **er-diagram.spec.ts**
src-tauri/src/commands/
└── schema.rs (微调:+get_all_columns, +is_unique)
// v3 中的 match_rules.rs 已删除
// v1 中的 edge-router.ts / interaction-manager.ts 已删除(Vue Flow / ELK 替代)
测试方案
单元测试(Vitest)
MatchEngine
MatchStorage
LayoutManager
GraphStore(撤销/重做)
e2e 测试(Playwright)
分阶段实施计划
Phase 1: Vue Flow 迁移 + 智能匹配 + 撤销重做
目标:Vue Flow 替换自研渲染,智能匹配核心可用,Pinia 撤销/重做。
- Vue Flow 集成:安装
@vue-flow/core+@vue-flow/minimap+@vue-flow/controls,实现适配层 - ELK 集成:npm 安装
elkjs(直接打包),实现分层布局 + 正交边路由 - GraphStore:Pinia + Immer,包含手动历史栈的撤销/重做
- 匹配引擎:实现 P1/P2 策略,
match-storage.ts使用safeLocalStorage - MatchPanel:复用现有关系面板的 UI 模式(Select + Button + Badge)
- 工具栏:新增"自动匹配"和"自动布局"按钮,遵循现有 Button 规范
- 后端:
schema.rs新增get_all_columns和is_unique - 单元测试:MatchEngine、MatchStorage、LayoutManager、GraphStore
Phase 2: 交互增强 + e2e 测试
目标:框选、连线交互、MiniMap、e2e 覆盖。
- 框选:启用
SelectionMode.Partial - 连线交互:
RelationshipEdge.vue悬停高亮 - MiniMap:50+ 表时自动显示
- 显示控制:工具栏开关控制列/注释/匹配关系
- e2e 测试:Playwright 覆盖 6 个核心流程
Phase 3: 高级功能 + 性能优化
目标:大 Schema 支持、高级分析、P3 正则规则。
- 虚拟化:
onlyRenderVisibleElements,支持 200+ 表 - 路径过滤:选中两节点,仅显示关联路径
- 正则规则:P3 用户自定义正则匹配,存储到 localStorage
- 侧边栏拖入:Vue Flow DnD 增量添加表
关键设计决策
为什么工具栏新增按钮而非重新设计
DBX 的工具栏已有固定的布局节奏:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增的"自动匹配"和"自动布局"按钮插入到操作按钮区域("建模关系"按钮右侧),遵循 variant="outline" size="sm" class="h-8 px-2 text-xs" + lucide 图标 h-3.5 w-3.5 + mr-1 的规范。缩放和重置按钮移除后由 Vue Flow 的浮动 Controls 组件替代,不占用工具栏空间。这种增量式改动与现有 UI 风格完全一致,PR 审查时不会因为"风格不统一"被要求返工。
为什么用 localStorage 而非新增后端存储
DBX 的自定义关系已使用 localStorage + dbx:diagram:relationships:v1:... key 模式存储。匹配规则的存储需求与自定义关系完全相同(按连接+数据库+schema 隔离、数据量小、JSON 序列化),没有必要引入新的存储机制。使用已有的 safeLocalStorageGet/Set/Remove 封装(而非直接 localStorage),可以统一错误处理,比现有自定义关系代码更健壮。
为什么 ELK.js 直接打包
DBA 常在无网络的内网环境使用数据库管理工具。懒加载在离线场景下会导致布局功能不可用。直接打包后 ELK.js 随安装包分发,增量约 300KB(gzip ~50KB),对 20MB 的 DBX 影响约 1.7%。
为什么不选 Coze 的 FlowGram
FlowGram 基于 Canvas 自研渲染引擎,定位是 AI 工作流编排(内置变量引擎、表单引擎)。Canvas 渲染文本排版远不如 HTML,不适合包含多行列信息的表卡片。对 ER 图来说严重过度设计。
测试用例索引
| 编号 | 模块 | 类型 | 数量 |
|---|---|---|---|
| TC-M1 ~ M5 | MatchEngine | 单元 | 5 |
| TC-MS1 ~ MS3 | MatchStorage | 单元 | 3 |
| TC-L1 ~ L3 | LayoutManager | 单元 | 3 |
| TC-S1 ~ S4 | GraphStore | 单元 | 4 |
| TC-E1 ~ E6 | ER 图全流程 | e2e | 6 |