forked from t8y2/dbx
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdbx-er-diagram-architecture.html
More file actions
800 lines (704 loc) · 50.1 KB
/
Copy pathdbx-er-diagram-architecture.html
File metadata and controls
800 lines (704 loc) · 50.1 KB
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
<!-- Generated by Trae Work -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DBX ER 图增强架构方案 v4</title>
<style>
@font-face { font-family: 'InstrumentSans'; src: url('./_shared/fonts/InstrumentSans-Regular.ttf') format('truetype'); font-weight: 400; }
@font-face { font-family: 'InstrumentSans'; src: url('./_shared/fonts/InstrumentSans-Bold.ttf') format('truetype'); font-weight: 700; }
@font-face { font-family: 'JetBrainsMono'; src: url('./_shared/fonts/JetBrainsMono-Regular.ttf') format('truetype'); font-weight: 400; }
</style>
<style>
:root {
--bg: #0f1117; --bg2: #1a1d28; --bg3: #232736;
--ink: #e4e6ef; --muted: #8b8fa7; --rule: #2e3348;
--accent: #38bdf8; --accent2: #a78bfa;
--accent-dim: rgba(56,189,248,0.12); --accent2-dim: rgba(167,139,250,0.12);
--green: #4ade80; --orange: #fb923c; --red: #f87171; --yellow: #facc15;
--font: 'InstrumentSans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrainsMono', 'Fira Code', monospace;
--max: 960px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body { font-family: var(--font); color: var(--ink); background: var(--bg); line-height: 1.75; }
.cover { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 4rem 2rem; position: relative; overflow: hidden; }
.cover::before { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse 60% 50% at 20% 50%, rgba(56,189,248,0.08) 0%, transparent 70%), radial-gradient(ellipse 50% 40% at 80% 30%, rgba(167,139,250,0.06) 0%, transparent 70%); pointer-events: none; }
.cover-badge { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.35rem 1rem; border: 1px solid var(--rule); border-radius: 999px; font-size: 0.8rem; color: var(--muted); margin-bottom: 2rem; }
.cover-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
.cover h1 { font-size: clamp(2rem, 5vw, 3.2rem); font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; margin-bottom: 1rem; color: #fff; }
.cover h1 span { color: var(--accent); }
.cover .subtitle { font-size: 1.1rem; color: var(--muted); max-width: 600px; line-height: 1.7; }
.cover-meta { margin-top: 3rem; display: flex; gap: 2rem; font-size: 0.82rem; color: var(--muted); }
.cover-meta div { display: flex; flex-direction: column; align-items: center; gap: 0.2rem; }
.cover-meta strong { color: var(--ink); font-size: 0.95rem; }
.cover-version { margin-top: 1.5rem; font-size: 0.78rem; color: var(--accent2); }
article.page { max-width: var(--max); margin: 0 auto; padding: 2rem 1.5rem 6rem; }
h2 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-top: 4rem; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--rule); }
h3 { font-size: 1.15rem; font-weight: 700; color: var(--accent); margin-top: 2.5rem; margin-bottom: 1rem; }
h4 { font-size: 1rem; font-weight: 700; color: var(--ink); margin-top: 2rem; margin-bottom: 0.75rem; }
p { margin-bottom: 1rem; color: var(--ink); }
strong { color: #fff; font-weight: 600; }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
code { font-family: var(--font-mono); font-size: 0.85em; background: var(--bg3); padding: 0.15em 0.4em; border-radius: 4px; color: var(--accent); }
pre { background: var(--bg2); border: 1px solid var(--rule); border-radius: 8px; padding: 1.25rem; overflow-x: auto; margin: 1.25rem 0; }
pre code { background: none; padding: 0; font-size: 0.82rem; color: var(--ink); line-height: 1.6; }
.table-wrap { overflow-x: auto; overflow-y: auto; max-height: 600px; margin: 1.25rem 0; border: 1px solid var(--rule); border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
thead { position: sticky; top: 0; z-index: 2; }
th { background: var(--bg3); color: #fff; font-weight: 600; text-align: left; padding: 0.75rem 1rem; border-bottom: 2px solid var(--rule); white-space: nowrap; }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--rule); color: var(--ink); vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(56,189,248,0.03); }
.callout { border-left: 3px solid var(--accent); background: var(--accent-dim); padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin: 1.5rem 0; font-size: 0.92rem; }
.callout.warn { border-left-color: var(--orange); background: rgba(251,146,60,0.08); }
.callout.danger { border-left-color: var(--red); background: rgba(248,113,113,0.08); }
.callout.success { border-left-color: var(--green); background: rgba(74,222,128,0.08); }
.callout strong { color: var(--accent); }
.callout.warn strong { color: var(--orange); }
.callout.danger strong { color: var(--red); }
.callout.success strong { color: var(--green); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin: 1.5rem 0; }
.card { background: var(--bg2); border: 1px solid var(--rule); border-radius: 10px; padding: 1.25rem; }
.card h4 { margin-top: 0; margin-bottom: 0.5rem; color: #fff; font-size: 0.95rem; }
.card p { font-size: 0.88rem; color: var(--muted); margin-bottom: 0; }
.diagram { margin: 2rem 0; text-align: center; }
.diagram figcaption { font-size: 0.82rem; color: var(--muted); margin-top: 0.75rem; }
.mermaid { background: var(--bg2); border: 1px solid var(--rule); border-radius: 10px; padding: 1.5rem; overflow-x: auto; }
.gap-tag { display: inline-block; font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 600; vertical-align: middle; margin-left: 0.3rem; }
.gap-tag.missing { background: rgba(248,113,113,0.15); color: var(--red); }
.gap-tag.ok { background: rgba(74,222,128,0.15); color: var(--green); }
.audit-table td:nth-child(1) { white-space: nowrap; }
.audit-table .old-val { color: var(--red); }
.audit-table .new-val { color: var(--green); }
.phases { display: flex; flex-direction: column; gap: 0; margin: 2rem 0; position: relative; }
.phases::before { content: ''; position: absolute; left: 18px; top: 8px; bottom: 8px; width: 2px; background: var(--rule); }
.phase { display: flex; gap: 1.25rem; padding: 1.25rem 0; }
.phase-dot { flex-shrink: 0; width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; color: #fff; position: relative; z-index: 1; }
.phase-dot.p1 { background: var(--accent); }
.phase-dot.p2 { background: var(--accent2); }
.phase-dot.p3 { background: var(--green); }
.phase-body h4 { margin-top: 0; color: #fff; }
.phase-body p { font-size: 0.9rem; margin-bottom: 0.5rem; }
.phase-body ul { padding-left: 1.2rem; }
.phase-body li { font-size: 0.88rem; color: var(--muted); margin-bottom: 0.3rem; }
.phase-body li strong { color: var(--ink); }
mark.key { background: none; color: var(--accent); font-weight: 600; }
.new-tag { display: inline-block; font-size: 0.7rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(56,189,248,0.15); color: var(--accent); font-weight: 600; vertical-align: middle; margin-left: 0.2rem; }
sup a { color: var(--accent); text-decoration: none; font-size: 0.75em; font-weight: 600; }
sup a:hover { text-decoration: underline; }
.test-case { background: var(--bg2); border: 1px solid var(--rule); border-radius: 8px; padding: 1rem 1.25rem; margin: 0.75rem 0; }
.test-case .tc-name { font-weight: 600; color: #fff; font-size: 0.9rem; margin-bottom: 0.3rem; }
.test-case .tc-desc { font-size: 0.85rem; color: var(--muted); }
.test-case .tc-assert { font-size: 0.82rem; color: var(--accent); margin-top: 0.3rem; }
.toolbar-wire { background: var(--bg2); border: 1px solid var(--rule); border-radius: 8px; padding: 0.75rem 1rem; margin: 1rem 0; font-family: var(--font-mono); font-size: 0.78rem; line-height: 2; color: var(--muted); overflow-x: auto; white-space: nowrap; }
.toolbar-wire .sep { display: inline-block; width: 1px; height: 20px; background: var(--rule); vertical-align: middle; margin: 0 0.3rem; }
.toolbar-wire .added { color: var(--green); }
.toolbar-wire .removed { color: var(--red); text-decoration: line-through; }
footer .sources { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
footer .sources h2 { font-size: 1.1rem; border: none; margin-top: 0; margin-bottom: 1rem; }
footer .sources ol { padding-left: 1.2rem; font-size: 0.82rem; color: var(--muted); }
footer .sources li { margin-bottom: 0.5rem; overflow-wrap: break-word; word-break: break-all; }
footer .sources .src-title { color: var(--ink); word-break: normal; }
footer .sources .src-url { display: block; margin-top: 0.15rem; font-size: 0.82rem; color: var(--accent); word-break: break-all; }
footer .sources a { word-break: break-all; }
@media (max-width: 768px) {
.cover h1 { font-size: 1.8rem; }
.cards { grid-template-columns: 1fr; }
.table-wrap { min-width: 100%; }
table { min-width: 600px; }
}
</style>
</head>
<body>
<section class="cover">
<div class="cover-badge"><span class="dot"></span> 开源贡献提案</div>
<h1>DBX <span>ER 图增强</span>架构方案</h1>
<p class="subtitle">面向 t8y2/dbx 项目的 ER 图模块重构与 ID 关联智能匹配方案,遵循 DBX 现有交互规范与存储模式</p>
<div class="cover-meta">
<div><strong>项目</strong>dbx v0.5.56</div>
<div><strong>技术栈</strong>Tauri 2 + Vue 3 + Rust</div>
<div><strong>协议</strong>Apache-2.0</div>
<div><strong>日期</strong>2026-07-15</div>
</div>
<div class="cover-version">v4 — 遵循 DBX 操作规范与存储模式</div>
</section>
<article class="page">
<h2>方案审计与修订说明</h2>
<div class="table-wrap audit-table">
<table class="audit-table">
<thead>
<tr><th style="width:5%">#</th><th style="width:15%">审计项</th><th style="width:30%">v3 方案(原)</th><th style="width:30%">v4 方案(修订)</th><th style="width:20%">修订原因</th></tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>工具栏交互</strong></td>
<td class="old-val">未对齐 DBX 现有按钮风格、布局结构和图标规范</td>
<td class="new-val">严格遵循现有 shadcn-vue Button 规范、lucide 图标、工具栏布局</td>
<td>PR 需要与现有 UI 风格完全一致,否则会被 maintainer 要求修改</td>
</tr>
<tr>
<td>2</td>
<td><strong>配置存储</strong></td>
<td class="old-val">新增 Rust 后端 <code>match_rules.rs</code> 持久化匹配规则</td>
<td class="new-val">沿用 <code>localStorage</code> + <code>dbx:diagram:...</code> key 前缀 + <code>safeLocalStorageGet/Set</code></td>
<td>DBX 自定义关系已用此模式存储,不引入新的存储机制</td>
</tr>
<tr>
<td>3</td>
<td><strong>后端改动</strong></td>
<td class="old-val">新增 <code>match_rules.rs</code>(Tauri Command)、修改 <code>schema.rs</code></td>
<td class="new-val">仅修改 <code>schema.rs</code>:新增 <code>get_all_columns</code> 和 <code>ColumnInfo.is_unique</code></td>
<td>删除存储相关后端改动,存储全部在前端 localStorage 完成</td>
</tr>
</tbody>
</table>
</div>
<h2>DBX 现有操作规范</h2>
<p>在描述新方案之前,先明确需要遵循的现有规范。以下内容基于对 <code>SchemaDiagramDialog.vue</code>(v0.5.58)的源码分析。</p>
<h3>Dialog 容器结构</h3>
<pre><code><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></code></pre>
<h3>按钮规范</h3>
<div class="table-wrap">
<table>
<thead><tr><th>场景</th><th>variant</th><th>size</th><th>额外 class</th><th>图标尺寸</th></tr></thead>
<tbody>
<tr><td>带文字的操作按钮(建模关系、复制 SQL)</td><td><code>outline</code></td><td><code>sm</code></td><td><code>h-8 px-2 text-xs</code></td><td><code>h-3.5 w-3.5</code> + <code>mr-1</code></td></tr>
<tr><td>纯图标按钮(缩放、刷新、导出)</td><td><code>ghost</code></td><td><code>icon</code></td><td><code>h-8 w-8</code></td><td><code>h-4 w-4</code></td></tr>
<tr><td>模式切换按钮组(表模式/工程模式)</td><td><code>ghost</code></td><td><code>sm</code></td><td><code>h-8 rounded-none px-2 text-xs</code></td><td><code>h-3.5 w-3.5</code> + <code>mr-1</code></td></tr>
<tr><td>面板内主要操作按钮(添加关系)</td><td><code>default</code></td><td><code>sm</code></td><td><code>h-8 px-2 text-xs</code></td><td><code>h-3.5 w-3.5</code> + <code>mr-1</code></td></tr>
</tbody>
</table>
</div>
<h3>现有工具栏布局(改造前)</h3>
<div class="toolbar-wire">
[Select:连接] [Select:数据库] [Select:Schema] | [🔍 搜索框] | [表模式][工程模式] | [🔗 建模关系] [📋 复制SQL] [关联表/全部表] | [Badge:表数] [Badge:关系数] [Badge:自定义关系数] | [⬇导出SVG] [🔄刷新] [➖缩小] [➕放大] [⧉重置布局]
</div>
<h3>现有存储模式</h3>
<p>DBX 的自定义关系存储使用 <strong>原生 <code>localStorage</code></strong>,key 格式为 <code>dbx:diagram:relationships:v1:<connectionId>:<database>:<schema></code>。全项目另有一层安全封装 <code>safeLocalStorageGet/Set/Remove</code>(位于 <code>lib/backend/safeStorage.ts</code>),使用 <code>globalThis.localStorage</code> + try-catch。当前 ER 图模块直接使用原生 <code>localStorage</code>,本方案统一迁移到 <code>safeLocalStorage</code> 封装。</p>
<div class="callout success">
<strong>存储约束</strong>:不引入 Tauri plugin-store,不新增 Rust 后端存储命令。匹配规则的存储沿用 <code>localStorage</code> + <code>dbx:diagram:...</code> key 前缀,按"连接 + 数据库 + schema"粒度隔离,与自定义关系保持一致的存储范式。
</div>
<h2>现状分析与核心问题</h2>
<h3>DBX ER 图当前实现</h3>
<p>
DBX 的 ER 图功能完全自研,基于原生 HTML/CSS + SVG 渲染,没有引入任何第三方图可视化库。整个功能封装在一个约 1150 行的 <code>SchemaDiagramDialog.vue</code> 组件中。支持两种视图模式(Table View 和 Engineering View),正交折线连线路由(自动绕开中间表卡片),简单网格布局,缩放范围 0.6x - 1.5x,搜索过滤,聚焦模式,自定义关系建模(localStorage 持久化)和 JOIN SQL 自动生成。
</p>
<h3>与 DataGrip / Navicat 的差距</h3>
<div class="table-wrap">
<table>
<thead><tr><th style="width:22%">交互维度</th><th style="width:22%">DataGrip</th><th style="width:22%">Navicat</th><th style="width:34%">DBX 现状</th></tr></thead>
<tbody>
<tr><td><strong>自动关系推断</strong></td><td>物理外键 + 正则匹配虚拟外键</td><td>仅物理外键</td><td>仅物理外键 + 手动自定义 <span class="gap-tag missing">缺少智能匹配</span></td></tr>
<tr><td><strong>布局算法</strong></td><td>多种可选布局 + 方向控制</td><td>Auto-Layout 一键排列</td><td>简单网格 <span class="gap-tag missing">无分层布局</span></td></tr>
<tr><td><strong>缩放范围</strong></td><td>无硬限制</td><td>无硬限制</td><td>0.6x - 1.5x <span class="gap-tag missing">范围过窄</span></td></tr>
<tr><td><strong>框选</strong></td><td>框选复制</td><td>搜索筛选</td><td>无 <span class="gap-tag missing">完全缺失</span></td></tr>
<tr><td><strong>撤销/重做</strong></td><td>Ctrl+Z/Y</td><td>无限次 Undo/Redo</td><td>无 <span class="gap-tag missing">完全缺失</span></td></tr>
<tr><td><strong>连线交互</strong></td><td>显示/隐藏虚拟外键</td><td>悬停高亮、编辑折点</td><td>SVG 箭头连线不可交互 <span class="gap-tag missing">连线无交互</span></td></tr>
</tbody>
</table>
</div>
<h3>核心问题</h3>
<div class="cards">
<div class="card"><h4>关系发现能力弱</h4><p>仅依赖物理外键。大量项目不建外键,或使用不支持外键的数据库(MongoDB、ClickHouse),ER 图上大量表呈现为孤岛。</p></div>
<div class="card"><h4>布局与交互原始</h4><p>网格布局无法体现表间逻辑关系,50+ 张表时连线交叉严重。缺少框选、撤销、连线交互等基本操作。</p></div>
<div class="card"><h4>单体组件架构瓶颈</h4><p>全部逻辑集中在单个 1150 行 Vue 组件中,渲染、布局、路由、交互、状态管理耦合,难以扩展和测试。</p></div>
</div>
<h2>整体架构设计</h2>
<h3>技术选型</h3>
<div class="table-wrap">
<table>
<thead><tr><th>能力层</th><th>Dify</th><th>Coze</th><th>DBX v4</th><th>选型理由</th></tr></thead>
<tbody>
<tr><td><strong>画布框架</strong></td><td>ReactFlow</td><td>FlowGram(Canvas)</td><td><strong>Vue Flow</strong></td><td>Vue 3 项目;ReactFlow 忠实移植<sup><a href="#cite-6">[6]</a></sup>,gzip 49.8KB</td></tr>
<tr><td><strong>布局引擎</strong></td><td>ELK.js(懒加载)</td><td>自研</td><td><strong>ELK.js(打包)</strong></td><td>布局 + 正交边路由<sup><a href="#cite-7">[7]</a></sup>;打包确保离线可用</td></tr>
<tr><td><strong>状态管理</strong></td><td>Zustand + Immer</td><td>MobX</td><td><strong>Pinia(现有)</strong></td><td>DBX 已用 Pinia,撤销/重做内嵌到 store</td></tr>
<tr><td><strong>配置存储</strong></td><td>localStorage + CRDT</td><td>未知</td><td><strong>localStorage(现有)</strong></td><td>沿用 <code>dbx:diagram:...</code> key + <code>safeLocalStorage</code></td></tr>
</tbody>
</table>
</div>
<h3>模块拆分</h3>
<figure class="diagram">
<pre class="mermaid">
graph TB
subgraph UI["UI 层 (Vue Components)"]
A["SchemaDiagramDialog.vue<br/>Dialog 容器 + 工具栏"]
B["TableNode.vue<br/>表卡片(Vue Flow 自定义节点)"]
C["RelationshipEdge.vue<br/>关系线(Vue Flow 自定义边)"]
D["MatchPanel.vue<br/>匹配规则管理面板"]
end
subgraph Adapter["适配层"]
E["VueFlowAdapter<br/>图数据 ↔ VueFlow 格式转换"]
end
subgraph Core["核心引擎 (lib/diagram/)"]
F["GraphStore<br/>Pinia + 撤销/重做历史栈"]
G["LayoutManager<br/>ELK.js 布局 + 正交边路由"]
H["MatchEngine<br/>ID 关联智能匹配"]
I["MatchStorage<br/>匹配规则 localStorage 读写"]
end
subgraph Backend["后端 (Rust) — 仅微调"]
J["schema.rs<br/>+get_all_columns<br/>+ColumnInfo.is_unique"]
end
A --> E
B --> E
C --> E
F --> E
A --> G
A --> H
H --> I
F --> J
</pre>
<figcaption>图 1: v4 模块拆分架构</figcaption>
</figure>
<h2>工具栏改造方案</h2>
<p>新工具栏严格沿用 DBX 现有布局:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增按钮遵循现有 Button 规范,新增图标使用 lucide-vue。Vue Flow 的 Controls 和 MiniMap 作为浮动组件叠加在画布上,不占用工具栏空间。</p>
<h3>改造后工具栏布局</h3>
<div class="toolbar-wire">
[Select:连接] [Select:数据库] [Select:Schema] <span class="sep">|</span> [🔍 搜索框] <span class="sep">|</span> [表模式][工程模式] <span class="sep">|</span> [🔗 建模关系] [<span class="added">🔍 自动匹配</span>] [<span class="added">📊 自动布局 ▾</span>] [📋 复制SQL] [关联表/全部表] <span class="sep">|</span> [Badge:表数] [Badge:关系数] [<span class="added">Badge:匹配关系数</span>] [Badge:自定义关系数] <span class="sep">|</span> [⬇导出SVG] [🔄刷新] <span class="removed">➖缩小 ➕放大</span> [<span class="removed">⧉重置布局</span>]
</div>
<h3>新增/变更按钮明细</h3>
<div class="table-wrap">
<table>
<thead><tr><th>按钮</th><th>位置</th><th>规范</th><th>图标</th><th>说明</th></tr></thead>
<tbody>
<tr>
<td><strong>自动匹配</strong><span class="new-tag">新增</span></td>
<td>"建模关系"按钮右侧</td>
<td><code>variant="outline" size="sm" class="h-8 px-2 text-xs"</code></td>
<td><code>ScanSearch</code>(lucide,h-3.5 w-3.5)</td>
<td>切换打开/关闭 MatchPanel 面板,复用现有关系面板的条件渲染模式</td>
</tr>
<tr>
<td><strong>自动布局</strong><span class="new-tag">新增</span></td>
<td>"自动匹配"按钮右侧</td>
<td><code>variant="outline" size="sm" class="h-8 px-2 text-xs"</code></td>
<td><code>LayoutGrid</code>(lucide,h-3.5 w-3.5)</td>
<td>点击触发 ELK 自动布局;下拉可选方向(LR / TB / RL / BT)</td>
</tr>
<tr>
<td><strong>匹配关系 Badge</strong><span class="new-tag">新增</span></td>
<td>Badge 区域,自定义关系 Badge 前</td>
<td><code>variant="secondary" class="h-6 text-xs"</code></td>
<td>无</td>
<td>显示当前自动匹配推断的关系数,点击切换显示/隐藏</td>
</tr>
<tr>
<td><strong>缩放按钮</strong><span class="new-tag">变更</span></td>
<td>工具栏右侧图标区</td>
<td>移除,由 Vue Flow Controls 替代</td>
<td>—</td>
<td>Vue Flow 的 <code><Controls /></code> 浮动在画布右下角,包含 +/-/fit/lock 按钮</td>
</tr>
<tr>
<td><strong>重置布局</strong><span class="new-tag">变更</span></td>
<td>工具栏右侧</td>
<td>移除,由"自动布局"按钮替代</td>
<td>—</td>
<td>"自动布局"按钮已包含重排功能,无需单独的重置按钮</td>
</tr>
</tbody>
</table>
</div>
<div class="callout">
<strong>面板复用模式</strong>:MatchPanel 面板的交互模式完全复用现有关系建模面板的设计——通过 <code>v-if="showMatchPanel"</code> 条件渲染在工具栏和画布之间,包含 Select 选择器 + Button 操作 + Badge 列表。用户在 DBX 中看到的是一个与现有"建模关系"面板风格完全一致的"自动匹配"面板。
</div>
<h2>存储方案</h2>
<p>所有匹配相关数据存储在前端 <code>localStorage</code>,使用 DBX 现有的 <code>safeLocalStorageGet/Set/Remove</code> 封装。key 格式与自定义关系保持一致的 <code>dbx:diagram:...</code> 前缀 + "连接 + 数据库 + schema"粒度。</p>
<h3>存储 key 设计</h3>
<div class="table-wrap">
<table>
<thead><tr><th>数据类型</th><th>key 格式</th><th>现有/新增</th></tr></thead>
<tbody>
<tr><td>自定义关系</td><td><code>dbx:diagram:relationships:v1:<connId>:<db>:<schema></code></td><td>现有(保持不变)</td></tr>
<tr><td>匹配确认记录</td><td><code>dbx:diagram:match-confirms:v1:<connId>:<db>:<schema></code></td><td>新增</td></tr>
<tr><td>匹配忽略记录</td><td><code>dbx:diagram:match-ignores:v1:<connId>:<db>:<schema></code></td><td>新增</td></tr>
<tr><td>用户自定义正则规则</td><td><code>dbx:diagram:match-rules:v1:<connId>:<db>:<schema></code></td><td>新增</td></tr>
<tr><td>匹配全局开关</td><td><code>dbx:diagram:match-enabled</code></td><td>新增</td></tr>
</tbody>
</table>
</div>
<h3>存储实现代码</h3>
<pre><code>// 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));
}</code></pre>
<div class="callout warn">
<strong>不新增 Rust 后端存储</strong>:v3 方案中的 <code>match_rules.rs</code>(Tauri Command: save_match_rules / load_match_rules)已删除。匹配规则的存储量很小(通常几十条 JSON),<code>localStorage</code> 完全胜任,且与 DBX 现有的自定义关系存储方式保持一致。
</div>
<h2>智能 ID 关联匹配引擎</h2>
<p>本方案中<strong>最有价值的增量能力</strong>。DataGrip 通过正则表达式虚拟外键实现了类似功能<sup><a href="#cite-1">[1]</a></sup>,但需要用户手动配置。本方案内置开箱即用的自动匹配策略。</p>
<h3>匹配策略分层</h3>
<div class="table-wrap">
<table>
<thead><tr><th style="width:10%">优先级</th><th style="width:18%">策略</th><th style="width:42%">规则</th><th style="width:30%">置信度</th></tr></thead>
<tbody>
<tr><td><strong>P0</strong></td><td>物理外键</td><td>读取 <code>INFORMATION_SCHEMA</code> 外键约束</td><td>100%</td></tr>
<tr><td><strong>P1</strong></td><td>命名约定</td><td><code>{table}_id</code> / <code>{table}_uuid</code> → 目标表主键,支持 snake_case / camelCase</td><td>高(自动确认)</td></tr>
<tr><td><strong>P2</strong></td><td>类型签名</td><td>P1 + 源列与目标列类型兼容(如都是 <code>bigint</code>)</td><td>高(自动确认)</td></tr>
<tr><td><strong>P3</strong></td><td>正则规则</td><td>用户自定义正则,如 <code>(.*)_id</code> → <code>$1.id</code><sup><a href="#cite-1">[1]</a></sup></td><td>中(需确认)</td></tr>
</tbody>
</table>
</div>
<h3>匹配算法核心逻辑</h3>
<pre><code>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);
}</code></pre>
<h3>匹配结果与存储交互</h3>
<p>匹配引擎运行时需要与 <code>match-storage.ts</code> 交互,过滤已确认和已忽略的记录:</p>
<pre><code>// 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, ... },
};
}</code></pre>
<div class="callout">
<strong>视觉区分</strong>:物理外键实线高亮;自动匹配(高置信)虚线半透明;待确认关系点线灰色。工具栏 Badge 区的"匹配关系"Badge 点击可切换显示/隐藏。
</div>
<h2>Vue Flow + ELK.js 交互设计</h2>
<h3>Vue Flow 提供的开箱即用能力</h3>
<div class="table-wrap">
<table>
<thead><tr><th>能力</th><th>Vue Flow 原生</th><th>DBX v1 中</th></tr></thead>
<tbody>
<tr><td>节点拖拽</td><td>内置 <code>draggable</code></td><td>手动 mousedown/move/up</td></tr>
<tr><td>缩放与平移</td><td>内置 viewport,无范围限制</td><td>自研 diagramZoom.ts,0.6x-1.5x</td></tr>
<tr><td>框选多选</td><td><code>SelectionMode.Partial</code></td><td>缺失</td></tr>
<tr><td>MiniMap</td><td><code><MiniMap /></code> 浮动组件</td><td>缺失</td></tr>
<tr><td>Controls</td><td><code><Controls /></code> 浮动组件(替代工具栏 +/- 按钮)</td><td>手动按钮</td></tr>
<tr><td>背景网格</td><td><code><Background /></code></td><td>CSS 背景</td></tr>
<tr><td>虚拟化</td><td><code>onlyRenderVisibleElements</code></td><td>缺失</td></tr>
</tbody>
</table>
</div>
<h3>ELK.js 布局配置</h3>
<pre><code>// 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);
}</code></pre>
<div class="table-wrap">
<table>
<thead><tr><th>配置项</th><th>值</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>elk.algorithm</code></td><td><code>layered</code></td><td>Sugiyama 分层布局</td></tr>
<tr><td><code>elk.direction</code></td><td><code>RIGHT</code> / <code>DOWN</code></td><td>工具栏下拉切换</td></tr>
<tr><td><code>edgeRouting</code></td><td><code>ORTHOGONAL</code></td><td>正交折线,自动避开节点</td></tr>
<tr><td><code>nodePlacement</code></td><td><code>BRANDES_KOEPF</code></td><td>平衡对齐(同 Dify)</td></tr>
<tr><td><code>crossingMinimization</code></td><td><code>LAYER_SWEEP</code></td><td>交叉最小化</td></tr>
<tr><td><code>layering.strategy</code></td><td><code>NETWORK_SIMPLEX</code></td><td>最小化边跨度</td></tr>
<tr><td><code>separateConnectedComponents</code></td><td><code>true</code></td><td>自动分离孤立子图</td></tr>
</tbody>
</table>
</div>
<h2>Pinia 原生撤销/重做</h2>
<p>不引入第三方撤销库。在 GraphStore(Pinia)中手动维护 <code>historyStack</code> + <code>redoStack</code>,使用 <code>lodash-es</code> 的 <code>cloneDeep</code> 做快照(DBX 已通过 shadcn-vue 间接依赖 lodash-es,无新增依赖)。</p>
<pre><code>// 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;
}
});</code></pre>
<h2>后端改动(最小化)</h2>
<p>v4 方案的后端改动仅限于 <code>schema.rs</code>,不新增任何 Tauri Command 或存储模块:</p>
<div class="table-wrap">
<table>
<thead><tr><th>改动点</th><th>文件</th><th>内容</th><th>说明</th></tr></thead>
<tbody>
<tr><td>批量列查询</td><td><code>schema.rs</code></td><td>新增 <code>get_all_columns</code> 命令</td><td>一次返回 Schema 下所有表的列,避免匹配引擎逐表 IPC</td></tr>
<tr><td>列唯一键标识</td><td><code>schema.rs</code></td><td><code>ColumnInfo</code> 新增 <code>is_unique</code> 字段</td><td>辅助匹配引擎判断目标列是否为主键或唯一键</td></tr>
</tbody>
</table>
</div>
<h2>文件结构规划</h2>
<pre><code>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 替代)</code></pre>
<h2>测试方案</h2>
<h3>单元测试(Vitest)</h3>
<h4>MatchEngine</h4>
<div class="test-case"><div class="tc-name">TC-M1: 命名约定匹配 — user_id → users.id</div><div class="tc-desc">输入 users(id PK)、orders(user_id)。期望匹配到 users.id,confidence='high'。</div><div class="tc-assert">assert(result.relationships[0].targetTable === 'users')</div></div>
<div class="test-case"><div class="tc-name">TC-M2: 类型不匹配时拒绝</div><div class="tc-desc">输入 users(id bigint)、orders(user_id varchar)。期望无匹配。</div><div class="tc-assert">assert(result.relationships.length === 0)</div></div>
<div class="test-case"><div class="tc-name">TC-M3: 冲突检测 — type_id 指向多表</div><div class="tc-desc">types 和 user_types 同时存在。期望标记冲突。</div><div class="tc-assert">assert(result.conflicts.length > 0)</div></div>
<div class="test-case"><div class="tc-name">TC-M4: 物理外键优先不重复</div><div class="tc-desc">物理外键 + 命名约定同时匹配。期望仅一条。</div><div class="tc-assert">assert(result.relationships.length === 1)</div></div>
<div class="test-case"><div class="tc-name">TC-M5: camelCase 列名</div><div class="tc-desc">Users(Id PK)、Orders(UserId)。期望匹配成功。</div><div class="tc-assert">assert(result.relationships.length === 1)</div></div>
<h4>MatchStorage</h4>
<div class="test-case"><div class="tc-name">TC-MS1: confirms 存储与加载</div><div class="tc-desc">保存 3 条 confirm ID 后加载。期望返回相同 3 条。</div><div class="tc-assert">assert(loaded.length === 3 && loaded.every(id => saved.includes(id)))</div></div>
<div class="test-case"><div class="tc-name">TC-MS2: ignores 与 confirms 互不干扰</div><div class="tc-desc">分别保存 confirms 和 ignores。期望各自加载正确。</div><div class="tc-assert">assert(confirms !== ignores)</div></div>
<div class="test-case"><div class="tc-name">TC-MS3: 全局开关默认开启</div><div class="tc-desc">未设置时调用 isAutoMatchEnabled()。期望 true。</div><div class="tc-assert">assert(isAutoMatchEnabled() === true)</div></div>
<h4>LayoutManager</h4>
<div class="test-case"><div class="tc-name">TC-L1: ELK 输出无重叠</div><div class="tc-desc">10 节点 + 9 边。期望无碰撞。</div><div class="tc-assert">assert(!hasCollision(result.nodes))</div></div>
<div class="test-case"><div class="tc-name">TC-L2: 正交边路径含 L 命令</div><div class="tc-desc">带端口的节点和边。期望 SVG path 包含 'L'。</div><div class="tc-assert">assert(result.edges.every(e => e.path.includes('L')))</div></div>
<div class="test-case"><div class="tc-name">TC-L3: pinned 节点位置不变</div><div class="tc-desc">2 个 pinned 节点。期望坐标不变。</div><div class="tc-assert">assert(pinned.every(n => n.position === original))</div></div>
<h4>GraphStore(撤销/重做)</h4>
<div class="test-case"><div class="tc-name">TC-S1: 撤销恢复位置</div><div class="tc-desc">拖拽 A 到 (100,100),undo。期望 A 回 (0,0)。</div><div class="tc-assert">assert(nodes[0].position.x === 0)</div></div>
<div class="test-case"><div class="tc-name">TC-S2: redo 恢复新位置</div><div class="tc-desc">拖拽,undo,redo。期望 A 在 (100,100)。</div><div class="tc-assert">assert(nodes[0].position.x === 100)</div></div>
<div class="test-case"><div class="tc-name">TC-S3: 新操作清空 redo</div><div class="tc-desc">拖拽 A,undo,拖拽 B。期望 canRedo = false。</div><div class="tc-assert">assert(!canRedo)</div></div>
<div class="test-case"><div class="tc-name">TC-S4: 栈深度限制 50</div><div class="tc-desc">连续 55 次 pushHistory。期望 historyStack.length === 50。</div><div class="tc-assert">assert(historyStack.length === 50)</div></div>
<h3>e2e 测试(Playwright)</h3>
<div class="test-case"><div class="tc-name">TC-E1: 完整 ER 图加载</div><div class="tc-desc">打开连接 → 选择数据库 → 点击"ER 图"。期望节点数 = 表数。</div><div class="tc-assert">assert(locator('.vue-flow__node').count() === tableCount)</div></div>
<div class="test-case"><div class="tc-name">TC-E2: 无外键库的智能匹配</div><div class="tc-desc">连接 SQLite → 打开 ER 图 → 点击"自动匹配"。期望虚线连线可见。</div><div class="tc-assert">assert(locator('[data-kind="inferred"]').count() > 0)</div></div>
<div class="test-case"><div class="tc-name">TC-E3: ELK 自动布局</div><div class="tc-desc">点击"自动布局"。期望节点按层级排列,连线无交叉。</div><div class="tc-assert">assert(noOverlapping() && noIntersectingEdges())</div></div>
<div class="test-case"><div class="tc-name">TC-E4: 框选 + 批量拖拽</div><div class="tc-desc">拖拽框选 3 节点 → 移动。期望 3 个同时移动。</div><div class="tc-assert">assert(selectedCount === 3)</div></div>
<div class="test-case"><div class="tc-name">TC-E5: Ctrl+Z/Y 撤销重做</div><div class="tc-desc">拖拽 → Ctrl+Z → Ctrl+Y。期望先回原位再回新位。</div><div class="tc-assert">assert(posAfterRedo === draggedPos)</div></div>
<div class="test-case"><div class="tc-name">TC-E6: 匹配面板交互</div><div class="tc-desc">点击"自动匹配"按钮 → 面板出现 → 点击确认一条 → 虚线变实线。</div><div class="tc-assert">assert(confirmedEdge.getStyle().strokeDasharray === 'none')</div></div>
<h2>分阶段实施计划</h2>
<div class="phases">
<div class="phase">
<div class="phase-dot p1">1</div>
<div class="phase-body">
<h4>Phase 1: Vue Flow 迁移 + 智能匹配 + 撤销重做</h4>
<p><strong>目标</strong>:Vue Flow 替换自研渲染,智能匹配核心可用,Pinia 撤销/重做。</p>
<ul>
<li><strong>Vue Flow 集成</strong>:安装 <code>@vue-flow/core</code> + <code>@vue-flow/minimap</code> + <code>@vue-flow/controls</code>,实现适配层</li>
<li><strong>ELK 集成</strong>:npm 安装 <code>elkjs</code>(直接打包),实现分层布局 + 正交边路由</li>
<li><strong>GraphStore</strong>:Pinia + Immer,包含手动历史栈的撤销/重做</li>
<li><strong>匹配引擎</strong>:实现 P1/P2 策略,<code>match-storage.ts</code> 使用 <code>safeLocalStorage</code></li>
<li><strong>MatchPanel</strong>:复用现有关系面板的 UI 模式(Select + Button + Badge)</li>
<li><strong>工具栏</strong>:新增"自动匹配"和"自动布局"按钮,遵循现有 Button 规范</li>
<li><strong>后端</strong>:<code>schema.rs</code> 新增 <code>get_all_columns</code> 和 <code>is_unique</code></li>
<li><strong>单元测试</strong>:MatchEngine、MatchStorage、LayoutManager、GraphStore</li>
</ul>
</div>
</div>
<div class="phase">
<div class="phase-dot p2">2</div>
<div class="phase-body">
<h4>Phase 2: 交互增强 + e2e 测试</h4>
<p><strong>目标</strong>:框选、连线交互、MiniMap、e2e 覆盖。</p>
<ul>
<li><strong>框选</strong>:启用 <code>SelectionMode.Partial</code></li>
<li><strong>连线交互</strong>:<code>RelationshipEdge.vue</code> 悬停高亮</li>
<li><strong>MiniMap</strong>:50+ 表时自动显示</li>
<li><strong>显示控制</strong>:工具栏开关控制列/注释/匹配关系</li>
<li><strong>e2e 测试</strong>:Playwright 覆盖 6 个核心流程</li>
</ul>
</div>
</div>
<div class="phase">
<div class="phase-dot p3">3</div>
<div class="phase-body">
<h4>Phase 3: 高级功能 + 性能优化</h4>
<p><strong>目标</strong>:大 Schema 支持、高级分析、P3 正则规则。</p>
<ul>
<li><strong>虚拟化</strong>:<code>onlyRenderVisibleElements</code>,支持 200+ 表</li>
<li><strong>路径过滤</strong>:选中两节点,仅显示关联路径</li>
<li><strong>正则规则</strong>:P3 用户自定义正则匹配,存储到 localStorage</li>
<li><strong>侧边栏拖入</strong>:Vue Flow DnD 增量添加表</li>
</ul>
</div>
</div>
</div>
<h2>关键设计决策</h2>
<h3>为什么工具栏新增按钮而非重新设计</h3>
<p>
DBX 的工具栏已有固定的布局节奏:选择器 → 搜索 → 模式切换 → 操作按钮 → Badge → 图标按钮。新增的"自动匹配"和"自动布局"按钮插入到操作按钮区域("建模关系"按钮右侧),遵循 <code>variant="outline" size="sm" class="h-8 px-2 text-xs"</code> + lucide 图标 <code>h-3.5 w-3.5</code> + <code>mr-1</code> 的规范。缩放和重置按钮移除后由 Vue Flow 的浮动 Controls 组件替代,不占用工具栏空间。这种增量式改动与现有 UI 风格完全一致,PR 审查时不会因为"风格不统一"被要求返工。
</p>
<h3>为什么用 localStorage 而非新增后端存储</h3>
<p>
DBX 的自定义关系已使用 <code>localStorage</code> + <code>dbx:diagram:relationships:v1:...</code> key 模式存储。匹配规则的存储需求与自定义关系完全相同(按连接+数据库+schema 隔离、数据量小、JSON 序列化),没有必要引入新的存储机制。使用已有的 <code>safeLocalStorageGet/Set/Remove</code> 封装(而非直接 <code>localStorage</code>),可以统一错误处理,比现有自定义关系代码更健壮。
</p>
<h3>为什么 ELK.js 直接打包</h3>
<p>
DBA 常在无网络的内网环境使用数据库管理工具。懒加载在离线场景下会导致布局功能不可用。直接打包后 ELK.js 随安装包分发,增量约 300KB(gzip ~50KB),对 20MB 的 DBX 影响约 1.7%。
</p>
<h3>为什么不选 Coze 的 FlowGram</h3>
<p>
FlowGram 基于 Canvas 自研渲染引擎,定位是 AI 工作流编排(内置变量引擎、表单引擎)。Canvas 渲染文本排版远不如 HTML,不适合包含多行列信息的表卡片。对 ER 图来说严重过度设计。
</p>
<h2>测试用例索引</h2>
<div class="table-wrap">
<table>
<thead><tr><th>编号</th><th>模块</th><th>类型</th><th>数量</th></tr></thead>
<tbody>
<tr><td>TC-M1 ~ M5</td><td>MatchEngine</td><td>单元</td><td>5</td></tr>
<tr><td>TC-MS1 ~ MS3</td><td>MatchStorage</td><td>单元</td><td>3</td></tr>
<tr><td>TC-L1 ~ L3</td><td>LayoutManager</td><td>单元</td><td>3</td></tr>
<tr><td>TC-S1 ~ S4</td><td>GraphStore</td><td>单元</td><td>4</td></tr>
<tr><td>TC-E1 ~ E6</td><td>ER 图全流程</td><td>e2e</td><td>6</td></tr>
</tbody>
</table>
</div>
<footer>
<div class="sources">
<h2>Sources</h2>
<ol>
<li id="cite-1"><span class="src-title">JetBrains, Virtual foreign keys | DataGrip 2026.1 Documentation</span><a class="src-url" href="https://www.jetbrains.com/help/datagrip/2026.1/virtual-foreign-keys.html" target="_blank" rel="noopener">https://www.jetbrains.com/help/datagrip/2026.1/virtual-foreign-keys.html</a></li>
<li id="cite-2"><span class="src-title">JetBrains, Database diagrams | DataGrip 2026.1 Documentation</span><a class="src-url" href="https://www.jetbrains.com/help/datagrip/2026.1/creating-diagrams.html" target="_blank" rel="noopener">https://www.jetbrains.com/help/datagrip/2026.1/creating-diagrams.html</a></li>
<li id="cite-5"><span class="src-title">t8y2, DBX GitHub Repository</span><a class="src-url" href="https://github.com/t8y2/dbx" target="_blank" rel="noopener">https://github.com/t8y2/dbx</a></li>
<li id="cite-6"><span class="src-title">bcakmakoglu, Vue Flow — ReactFlow 的 Vue 3 移植</span><a class="src-url" href="https://github.com/bcakmakoglu/vue-flow" target="_blank" rel="noopener">https://github.com/bcakmakoglu/vue-flow</a></li>
<li id="cite-7"><span class="src-title">Eclipse Foundation, ELK Layout Engine</span><a class="src-url" href="https://eclipse.dev/elk/" target="_blank" rel="noopener">https://eclipse.dev/elk/</a></li>
<li id="cite-8"><span class="src-title">langgenius, Dify — ReactFlow + ELK.js 实践</span><a class="src-url" href="https://github.com/langgenius/dify" target="_blank" rel="noopener">https://github.com/langgenius/dify</a></li>
</ol>
</div>
</footer>
</article>
<script src="./_shared/js/mermaid.min.js"></script>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark', securityLevel: 'loose', themeVariables: { darkMode: true, background: '#1a1d28', primaryColor: '#2e3348', primaryTextColor: '#e4e6ef', primaryBorderColor: '#38bdf8', lineColor: '#8b8fa7', secondaryColor: '#232736', tertiaryColor: '#1a1d28', fontFamily: 'InstrumentSans, sans-serif', fontSize: '13px' } });
</script>
</body>
</html>