dsh-db-tool 1.5.5 → 1.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -92,11 +92,8 @@ window.__ModuleLoader__.load({
92
92
  emptyStructure: "暂无字段信息",
93
93
  emptyData: "暂无数据",
94
94
  retry: "重试",
95
- browsePanelHint: "数据浏览已在浮动窗口打开,可继续操作其他功能。",
96
- reopenBrowse: "打开数据浏览",
97
95
  selectTableHint: "在左侧选择表后查看数据",
98
- // 单元格查看/编辑(Navicat 式就地写回)
99
- editCell: "编辑",
96
+ // 单元格底部详情栏(Navicat 式就地写回)
100
97
  cellValue: "完整值",
101
98
  copyBtn: "复制",
102
99
  copied: "已复制",
@@ -201,11 +198,8 @@ window.__ModuleLoader__.load({
201
198
  emptyStructure: "No columns",
202
199
  emptyData: "No rows",
203
200
  retry: "Retry",
204
- browsePanelHint: "Browse is open in a floating window; you can keep working here.",
205
- reopenBrowse: "Open Data Browser",
206
201
  selectTableHint: "Select a table on the left to view data",
207
- // Cell view/edit (Navicat-style in-place write-back)
208
- editCell: "Edit",
202
+ // Cell bottom detail bar (Navicat-style in-place write-back)
209
203
  cellValue: "Full value",
210
204
  copyBtn: "Copy",
211
205
  copied: "Copied",
@@ -322,22 +316,25 @@ window.__ModuleLoader__.load({
322
316
  if (styleEl) return;
323
317
  styleEl = document.createElement("style");
324
318
  styleEl.textContent = [
325
- "/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转;.dbt-browse-root 并入以承载 portal 弹窗) ===== */",
319
+ "/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转;.dbt-browse-root 并入以承载内嵌浏览面板与单元格浮层) ===== */",
326
320
  ".dbt-panel,.dbt-browse-root{--dbt-accent:var(--dsh-accent,#0a84ff);--dbt-bg:transparent;--dbt-surface:rgba(120,120,128,.12);--dbt-surface-strong:rgba(120,120,128,.18);--dbt-separator:rgba(120,120,128,.24);--dbt-text:rgba(235,235,245,.92);--dbt-text-secondary:rgba(235,235,245,.6);--dbt-danger:#ff453a;--dbt-success:#30d158;--dbt-warning:#ffd60a;--dbt-dialog-bg:rgba(40,40,44,.85);--dbt-th-bg:rgba(30,30,32,.72);--dbt-seg-active:rgba(255,255,255,.14);--dbt-shadow:0 8px 32px rgba(0,0,0,.28);--dbt-radius-card:12px;--dbt-radius-ctrl:8px;--dbt-radius-pill:6px;--dbt-ease:cubic-bezier(.25,.1,.25,1);--dbt-mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif;}",
327
321
  "@media (prefers-color-scheme: light){.dbt-panel,.dbt-browse-root{--dbt-accent:var(--dsh-accent,#007aff);--dbt-surface:rgba(120,120,128,.08);--dbt-surface-strong:rgba(120,120,128,.14);--dbt-separator:rgba(60,60,67,.18);--dbt-text:rgba(30,30,32,.92);--dbt-text-secondary:rgba(60,60,67,.6);--dbt-danger:#ff3b30;--dbt-success:#34c759;--dbt-warning:#ff9f0a;--dbt-dialog-bg:rgba(252,252,252,.9);--dbt-th-bg:rgba(255,255,255,.72);--dbt-seg-active:rgba(255,255,255,.9);--dbt-shadow:0 8px 32px rgba(0,0,0,.12);}}",
328
- // 弹窗根自持文字色:portal 挂 document.body 后不再依赖侧栏祖先的 color 继承(ocr medium)
322
+ // 浏览面板根自持文字色:PiP 独立文档挂 body 后无侧栏祖先 color 可继承(内嵌/浮窗两态共用)
329
323
  ".dbt-browse-root{color:var(--dbt-text);}",
330
324
  "/* ===== 根容器 ===== */",
331
- ".dbt-panel{font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:12px;padding:16px;height:100%;box-sizing:border-box;overflow-y:auto;}",
325
+ ".dbt-panel{font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:10px;padding:16px;height:100%;box-sizing:border-box;overflow-y:auto;}",
332
326
  "/* ===== iOS segmented control(顶部 tab) ===== */",
333
327
  ".dbt-tabs{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
334
328
  ".dbt-tabs button{flex:1;border:none;background:transparent;color:inherit;border-radius:var(--dbt-radius-pill);padding:5px 8px;font-size:12px;font-weight:500;cursor:pointer;transition:all .18s var(--dbt-ease);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
335
329
  ".dbt-tabs button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
336
330
  "/* ===== inset grouped 卡片 ===== */",
337
- ".dbt-card{background:var(--dbt-surface);border:none;border-radius:var(--dbt-radius-card);padding:12px;display:flex;flex-direction:column;gap:10px;}",
331
+ ".dbt-card{background:var(--dbt-surface);border:none;border-radius:var(--dbt-radius-card);padding:12px;display:flex;flex-direction:column;gap:8px;}",
338
332
  ".dbt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}",
333
+ "/* ===== 共享列布局(T1 逐步替换各视图内联 flex-column) ===== */",
334
+ ".dbt-col{display:flex;flex-direction:column;gap:8px;}",
335
+ ".dbt-col.dbt-col-tight{gap:2px;}",
339
336
  "/* ===== 填充式无边框输入 ===== */",
340
- ".dbt-row input,.dbt-row select,.dbt-card input,.dbt-card select,.dbt-card textarea,.dbt-group input,.dbt-group select{flex:1;min-width:60px;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:6px 10px;font-size:13px;font-family:inherit;transition:all .18s var(--dbt-ease);}",
337
+ ".dbt-row input,.dbt-row select,.dbt-card input,.dbt-card select,.dbt-card textarea,.dbt-group input,.dbt-group select{flex:1;min-width:60px;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:5px 12px;font-size:13px;font-family:inherit;transition:all .18s var(--dbt-ease);}",
341
338
  ".dbt-row input:focus,.dbt-row select:focus,.dbt-card input:focus,.dbt-card select:focus,.dbt-card textarea:focus,.dbt-group input:focus,.dbt-group select:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
342
339
  ".dbt-card textarea{font-family:var(--dbt-mono);min-height:96px;resize:vertical;}",
343
340
  "/* ===== 按钮 ===== */",
@@ -350,24 +347,25 @@ window.__ModuleLoader__.load({
350
347
  ".dbt-btn.danger:hover{background:rgba(255,69,58,.12);}",
351
348
  ".dbt-btn:disabled{opacity:.4;cursor:default;transform:none;}",
352
349
  "/* ===== 文本反馈 ===== */",
350
+ ".dbt-panel strong,.dbt-browse-root strong{font-size:13px;font-weight:600;}",
353
351
  ".dbt-muted,.dbt-readhint{color:var(--dbt-text-secondary);font-size:11px;}",
354
352
  ".dbt-err{color:var(--dbt-danger);font-size:12px;white-space:pre-wrap;}",
355
353
  ".dbt-msg{color:var(--dbt-success);font-size:12px;white-space:pre-wrap;}",
356
354
  "/* ===== 行 hairline 表格 + 毛玻璃 sticky 表头 ===== */",
357
355
  ".dbt-table{width:100%;border-collapse:collapse;font-size:12px;}",
358
- ".dbt-table th,.dbt-table td{border:none;border-bottom:1px solid var(--dbt-separator);padding:6px 8px;text-align:left;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
359
- ".dbt-table th{position:sticky;top:0;z-index:1;background:var(--dbt-th-bg);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);font-weight:600;font-size:11px;}",
356
+ ".dbt-table th,.dbt-table td{border:none;border-bottom:1px solid var(--dbt-separator);padding:7px 10px;text-align:left;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
357
+ ".dbt-table th{position:sticky;top:0;z-index:1;background:var(--dbt-th-bg);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);font-weight:600;font-size:12px;}",
360
358
  ".dbt-table td{font-family:var(--dbt-mono);}",
361
359
  ".dbt-tablewrap{border:none;border-radius:var(--dbt-radius-card);overflow:auto;max-height:320px;background:var(--dbt-surface);}",
362
- "/* ===== 浏览弹窗表格:列分明(列间 hairline + 表头加深一档 + 行 hover) ===== */",
360
+ "/* ===== 浏览面板表格:列分明(列间 hairline + 表头加深一档 + 行 hover) ===== */",
363
361
  ".dbt-browse-root .dbt-table th + th,.dbt-browse-root .dbt-table td + td{border-left:1px solid var(--dbt-separator);}",
364
- ".dbt-browse-root .dbt-table th{background:rgba(30,30,32,.85);font-size:12px;}",
365
- ".dbt-browse-root .dbt-table td{font-size:13px;padding:7px 10px;}",
362
+ ".dbt-browse-root .dbt-table th{background:rgba(30,30,32,.85);}",
363
+ ".dbt-browse-root .dbt-table td{font-size:12px;}",
366
364
  ".dbt-browse-root .dbt-table tbody tr:hover td{background:var(--dbt-surface-strong);}",
367
365
  "@media (prefers-color-scheme: light){.dbt-browse-root .dbt-table th{background:rgba(242,242,247,.9);}}",
368
366
  "/* ===== 对话框(macOS alert 材质) ===== */",
369
367
  ".dbt-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;z-index:9999;}",
370
- ".dbt-dialog{background:var(--dbt-dialog-bg);color:inherit;border:1px solid var(--dbt-separator);border-radius:14px;padding:16px;max-width:460px;width:90%;box-sizing:border-box;display:flex;flex-direction:column;gap:10px;font-size:13px;-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);box-shadow:var(--dbt-shadow);}",
368
+ ".dbt-dialog{background:var(--dbt-dialog-bg);color:inherit;border:1px solid var(--dbt-separator);border-radius:var(--dbt-radius-card);padding:16px;max-width:460px;width:90%;box-sizing:border-box;display:flex;flex-direction:column;gap:10px;font-size:13px;-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);box-shadow:var(--dbt-shadow);}",
371
369
  ".dbt-dialog pre{background:var(--dbt-surface-strong);border-radius:var(--dbt-radius-pill);padding:8px;font-family:var(--dbt-mono);font-size:12px;white-space:pre-wrap;word-break:break-all;max-height:160px;overflow:auto;}",
372
370
  ".dbt-dialog .dbt-row{justify-content:flex-end;}",
373
371
  "/* ===== 授权/连接列表:inset grouped(一张卡多行 hairline) ===== */",
@@ -377,7 +375,7 @@ window.__ModuleLoader__.load({
377
375
  ".dbt-listrow:hover{background:var(--dbt-surface-strong);}",
378
376
  ".dbt-tree{display:flex;flex-direction:column;}",
379
377
  "/* ===== Navicat 式对象树(数据浏览) ===== */",
380
- ".dbt-treerow{display:flex;gap:6px;align-items:center;padding:8px 14px;font-size:14px;cursor:pointer;user-select:none;border-bottom:1px solid var(--dbt-separator);transition:background .15s var(--dbt-ease);}",
378
+ ".dbt-treerow{display:flex;gap:6px;align-items:center;padding:10px 12px;font-size:13px;cursor:pointer;user-select:none;border-bottom:1px solid var(--dbt-separator);transition:background .15s var(--dbt-ease);}",
381
379
  ".dbt-treerow:last-child{border-bottom:none;}",
382
380
  ".dbt-treerow:hover{background:var(--dbt-surface-strong);}",
383
381
  ".dbt-treerow.active{color:var(--dbt-accent);font-weight:600;}",
@@ -385,34 +383,27 @@ window.__ModuleLoader__.load({
385
383
  ".dbt-chev.open{transform:rotate(90deg);}",
386
384
  ".dbt-chev.leaf{visibility:hidden;}",
387
385
  ".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
388
- /* ===== Navicat 式浏览弹窗 ===== */
389
- ".dbt-browse-dialog{position:fixed;z-index:2147483000;width:min(1100px,calc(100vw - 48px));height:min(720px,calc(100vh - 48px));min-width:min(680px,calc(100vw - 16px));min-height:min(420px,calc(100vh - 16px));background:var(--dbt-dialog-bg);color:inherit;border:1px solid var(--dbt-separator);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,.32),0 4px 16px rgba(0,0,0,.18);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);display:flex;flex-direction:column;overflow:hidden;box-sizing:border-box;font-size:14px;animation:dbt-in .22s var(--dbt-ease);}",
390
- ".dbt-browse-dialog.dragging{user-select:none;}",
391
- ".dbt-browse-header{flex:none;display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--dbt-separator);}",
392
- ".dbt-browse-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:17px;}",
393
- ".dbt-browse-close{flex:none;width:26px;height:26px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--dbt-text-secondary);border-radius:var(--dbt-radius-pill);cursor:pointer;transition:all .18s var(--dbt-ease);}",
394
- ".dbt-browse-close:hover{background:var(--dbt-surface-strong);color:inherit;}",
395
- ".dbt-browse-close:active{transform:scale(.92);}",
396
- ".dbt-browse-body{flex:1;min-height:0;display:flex;}",
397
- ".dbt-browse-tree{flex:none;width:260px;overflow-y:auto;border-right:1px solid var(--dbt-separator);}",
398
- ".dbt-browse-main{flex:1;min-width:0;display:flex;flex-direction:column;}",
399
- ".dbt-browse-tabs{flex:none;padding:10px 14px 0;}",
400
- ".dbt-browse-content{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;gap:10px;padding:12px 14px;overflow:hidden;}",
401
- ".dbt-browse-tablewrap{flex:1;min-height:0;overflow:auto;border:none;border-radius:var(--dbt-radius-card);background:var(--dbt-surface);}",
402
- ".dbt-browse-footer{flex:none;display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--dbt-separator);}",
403
- ".dbt-browse-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--dbt-text-secondary);font-size:13px;}",
404
- ".dbt-browse-errorbox{flex:1;display:flex;flex-direction:column;gap:10px;align-items:flex-start;justify-content:center;}",
386
+ /* ===== Navicat 式浏览(内嵌面板,纵向流式;浮窗壳类已随 DOM 回收) ===== */
387
+ ".dbt-browse-body{display:flex;flex-direction:column;gap:10px;min-height:0;}",
388
+ ".dbt-browse-tree{flex:none;max-height:280px;overflow-y:auto;background:var(--dbt-surface);border-radius:var(--dbt-radius-card);}",
389
+ ".dbt-browse-main{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;}",
390
+ ".dbt-browse-tabs{flex:none;}",
391
+ ".dbt-browse-content{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}",
392
+ ".dbt-browse-tablewrap{flex:none;max-height:320px;overflow:auto;border:none;border-radius:var(--dbt-radius-card);background:var(--dbt-surface);}",
393
+ ".dbt-browse-footer{flex:none;display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);}",
394
+ ".dbt-browse-empty{display:flex;align-items:center;justify-content:center;color:var(--dbt-text-secondary);font-size:13px;padding:24px 12px;}",
395
+ ".dbt-browse-errorbox{display:flex;flex-direction:column;gap:8px;align-items:flex-start;}",
405
396
  ".dbt-browse-skel{display:flex;flex-direction:column;gap:8px;}",
406
397
  ".dbt-browse-skelrow{display:flex;gap:8px;}",
407
398
  ".dbt-browse-skelcell{flex:1;height:24px;background:var(--dbt-surface-strong);border-radius:var(--dbt-radius-pill);}",
408
- "/* ===== 浏览弹窗:单元格查看/编辑浮层(定位锚点为 .dbt-browse-content) ===== */",
399
+ "/* ===== 浏览面板:单元格按钮与详情底栏(Apple 分区卡) ===== */",
409
400
  ".dbt-cellbtn{border:none;background:transparent;color:inherit;font:inherit;padding:0;margin:0;cursor:pointer;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;width:100%;text-align:left;}",
410
401
  ".dbt-cellbtn:focus-visible{outline:2px solid var(--dbt-accent);outline-offset:-1px;border-radius:2px;}",
411
- ".dbt-cellpop{position:absolute;z-index:20;width:min(460px,100%);background:var(--dbt-dialog-bg);border:1px solid var(--dbt-separator);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.3);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);padding:12px;display:flex;flex-direction:column;gap:8px;box-sizing:border-box;font-size:13px;}",
412
- ".dbt-cellpop-value{font-family:var(--dbt-mono);font-size:13px;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow:auto;background:var(--dbt-surface-strong);border-radius:8px;padding:8px;}",
413
- ".dbt-cellpop-actions{display:flex;gap:8px;align-items:center;}",
402
+ ".dbt-celldetail{margin-top:10px;padding:12px;background:var(--dbt-surface);border-radius:var(--dbt-radius-card);display:flex;flex-direction:column;gap:8px;}",
403
+ ".dbt-celldetail-input{font-family:var(--dbt-mono);font-size:12px;min-height:72px;resize:vertical;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:8px;width:100%;box-sizing:border-box;}",
404
+ ".dbt-celldetail-input:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
414
405
  ".dbt-celledit{display:flex;flex-direction:column;gap:8px;}",
415
- ".dbt-celledit textarea{font-family:var(--dbt-mono);font-size:13px;min-height:96px;resize:vertical;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:8px;padding:8px;}",
406
+ ".dbt-celledit textarea{font-family:var(--dbt-mono);font-size:13px;min-height:96px;resize:vertical;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:5px 12px;}",
416
407
  ".dbt-celledit textarea:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
417
408
  ".dbt-nullchip{display:inline-flex;align-items:center;border:1px solid var(--dbt-warning);color:var(--dbt-warning);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;}",
418
409
  "/* ===== 视图切换入场(仅 transform/opacity;reduced-motion 全局已禁) ===== */",
@@ -420,12 +411,11 @@ window.__ModuleLoader__.load({
420
411
  ".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
421
412
  "/* ===== mini segmented(ro/rw 等切换) ===== */",
422
413
  ".dbt-seg{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
423
- ".dbt-seg button{flex:none;border:none;background:transparent;color:inherit;font-size:11px;padding:3px 10px;border-radius:var(--dbt-radius-pill);cursor:pointer;transition:all .18s var(--dbt-ease);}",
414
+ ".dbt-seg button{flex:none;border:none;background:transparent;color:inherit;font-size:12px;padding:5px 12px;border-radius:var(--dbt-radius-pill);cursor:pointer;transition:all .18s var(--dbt-ease);}",
424
415
  ".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
425
- ".dbt-browse-root .dbt-seg button{font-size:12px;padding:4px 12px;}",
426
416
  "/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
427
- "@media (prefers-reduced-transparency: reduce){.dbt-overlay{backdrop-filter:none;-webkit-backdrop-filter:none;background:rgba(0,0,0,.55);}.dbt-dialog,.dbt-browse-dialog,.dbt-cellpop{backdrop-filter:none;-webkit-backdrop-filter:none;background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}.dbt-browse-root .dbt-table th{background:#1e1e20;}}",
428
- "@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog,.dbt-browse-dialog,.dbt-cellpop{background:#f5f5f7;}.dbt-table th,.dbt-browse-root .dbt-table th{background:#f2f2f7;}}",
417
+ "@media (prefers-reduced-transparency: reduce){.dbt-overlay{backdrop-filter:none;-webkit-backdrop-filter:none;background:rgba(0,0,0,.55);}.dbt-dialog{backdrop-filter:none;-webkit-backdrop-filter:none;background:#2c2c2e;}.dbt-celldetail{background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}.dbt-browse-root .dbt-table th{background:#1e1e20;}}",
418
+ "@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#f5f5f7;}.dbt-celldetail{background:#f2f2f7;}.dbt-table th,.dbt-browse-root .dbt-table th{background:#f2f2f7;}}",
429
419
  "@media (prefers-reduced-motion: reduce){.dbt-panel *,.dbt-browse-root *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
430
420
  ].join("\n");
431
421
  document.head.appendChild(styleEl);
@@ -460,79 +450,6 @@ window.__ModuleLoader__.load({
460
450
  );
461
451
  }
462
452
 
463
- // --- 弹窗挂载:优先 createPortal 挂 document.body(避免侧栏容器裁剪);宿主未提供 react-dom 时回退 Panel 内 fixed overlay(DangerDialog 同模式) ---
464
- // 三重降级解析(模块级缓存,全模块一次):①模块加载时同步 require(dsh.client.inject 声明 react-dom 后应成功)
465
- // ②宿主模块系统异步 import(ctx.modules,宿主 rc.8+ 提供)③均不可用则保持 Panel 内回退渲染。
466
- let portalImpl = null; // 解析成功的 createPortal(缓存,全模块一次)
467
- let portalTried = false; // 是否已尝试解析(异步 import 只发起一次)
468
- let portalPending = null; // 订阅解析成功的回调列表
469
- try {
470
- var rd = require("react-dom");
471
- if (rd && typeof rd.createPortal === "function") { portalImpl = rd.createPortal.bind(rd); portalTried = true; }
472
- } catch (e) { /* 留 null,等异步解析 */ }
473
- function resolvePortalAsync(ctx) {
474
- if (portalTried) return;
475
- portalTried = true;
476
- if (portalImpl) return;
477
- var mods = ctx && ctx.modules;
478
- var p = mods && typeof mods.import === "function" ? mods.import("react-dom") : null;
479
- if (p && typeof p.then === "function") {
480
- p.then(function (m) {
481
- if (m && typeof m.createPortal === "function") {
482
- portalImpl = m.createPortal.bind(m);
483
- var waiters = portalPending || []; portalPending = null;
484
- for (var i = 0; i < waiters.length; i++) waiters[i]();
485
- }
486
- }).catch(function () { /* 解析失败保持回退渲染 */ });
487
- }
488
- }
489
- function onPortalReady(cb) {
490
- if (portalImpl) { cb(); return; }
491
- (portalPending = portalPending || []).push(cb);
492
- }
493
- function mountDialog(children) {
494
- return (portalImpl && !portalDisabled) ? portalImpl(children, document.body) : children;
495
- }
496
- // PiP 独立窗口渲染时禁 portal:PiP root 本身就挂在 pipDoc.body 上,再 portal 回主 document 会把 DOM 拉出独立窗口
497
- let portalDisabled = false;
498
- // createRoot 三重解析(PiP 独立窗口需要独立 React root,结构对齐上方 portal 解析):
499
- // ①模块加载时同步 require react-dom → react-dom/client(宿主按 specifier 解析,两者是不同键)
500
- // ②宿主模块系统异步 import("react-dom/client") ③均不可用则 PiP 超时降级 DOM 浮窗
501
- let reactCreateRoot = null;
502
- let rootTried = false; // 异步 import 只发起一次
503
- let rootPending = null; // 订阅解析成功的回调列表
504
- let rootSource = null; // 就绪方式:sync / async / null(未就绪,诊断日志用)
505
- try {
506
- var rd2 = require("react-dom");
507
- if (rd2 && typeof rd2.createRoot === "function") {
508
- reactCreateRoot = rd2.createRoot.bind(rd2); rootTried = true; rootSource = "sync";
509
- } else {
510
- try { var rdc = require("react-dom/client"); if (rdc && typeof rdc.createRoot === "function") { reactCreateRoot = rdc.createRoot.bind(rdc); rootTried = true; rootSource = "sync"; } } catch (e2) { /* 保持 null */ }
511
- }
512
- } catch (e) { /* 保持 null,等异步解析 */ }
513
- function resolveRootAsync(ctx) {
514
- if (rootTried) return;
515
- rootTried = true;
516
- if (reactCreateRoot) return;
517
- var mods = ctx && ctx.modules;
518
- var p = mods && typeof mods.import === "function" ? mods.import("react-dom/client") : null;
519
- if (p && typeof p.then === "function") {
520
- p.then(function (m) {
521
- if (m && typeof m.createRoot === "function") {
522
- reactCreateRoot = m.createRoot.bind(m);
523
- rootSource = "async";
524
- var waiters = rootPending || []; rootPending = null;
525
- for (var i = 0; i < waiters.length; i++) waiters[i]();
526
- }
527
- }).catch(function () { /* 解析失败,PiP 走超时降级 */ });
528
- }
529
- }
530
- function onRootReady(cb) {
531
- if (reactCreateRoot) { cb(); return; }
532
- (rootPending = rootPending || []).push(cb);
533
- }
534
- let pipResult = "not-tried"; // 最近一次 PiP 尝试结果:opened / rejected:<err.name>(诊断日志用)
535
-
536
453
  /* ---------------- 连接管理 ---------------- */
537
454
  const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
538
455
 
@@ -866,7 +783,7 @@ window.__ModuleLoader__.load({
866
783
  draftTest && draftTest.ok === null ? t("testing") : t("test")),
867
784
  React.createElement("button", { className: "dbt-btn", onClick: props.onCancel }, t("cancel")),
868
785
  draftTest && draftTest.ok !== null
869
- ? React.createElement("span", { style: { color: draftTest.ok ? "var(--dbt-success, #30d158)" : "var(--dbt-danger, #ff453a)", fontSize: 12, alignSelf: "center" } }, draftTest.msg)
786
+ ? React.createElement("span", { className: draftTest.ok ? "dbt-msg" : "dbt-err" }, draftTest.msg)
870
787
  : null,
871
788
  ),
872
789
  );
@@ -958,7 +875,7 @@ window.__ModuleLoader__.load({
958
875
  { className: "dbt-muted", style: { fontFamily: MONO_FONT, fontSize: 11, wordBreak: "break-all" } },
959
876
  c.safeUrl || (c.host + ":" + (c.port || "")),
960
877
  ) : null,
961
- testInfo[c.id] ? React.createElement("div", { style: { fontSize: 12, color: testInfo[c.id].startsWith("✓") ? "var(--dbt-success, #30d158)" : "var(--dbt-danger, #ff453a)" } }, testInfo[c.id]) : null,
878
+ testInfo[c.id] ? React.createElement("div", { className: testInfo[c.id].startsWith("✓") ? "dbt-msg" : "dbt-err" }, testInfo[c.id]) : null,
962
879
  ),
963
880
  // 右列:动作按钮(次要语义,danger 仅删除)
964
881
  React.createElement(
@@ -1038,7 +955,7 @@ window.__ModuleLoader__.load({
1038
955
  }
1039
956
 
1040
957
  /* ---------------- 数据浏览 ---------------- */
1041
- // Navicat 式对象树:连接 ▸ 库/[schema] ▸ 表,懒加载展开(弹窗唯一实例,展开状态随弹窗生命周期)
958
+ // Navicat 式对象树:连接 ▸ 库/[schema] ▸ 表,懒加载展开(浏览面板唯一树实例,展开状态随面板生命周期)
1042
959
  function BrowseTree(props) {
1043
960
  const { conns, projectPath, sel, onSelect, autoExpand } = props;
1044
961
  // sel 由父组件持有(弹窗内 active 高亮与初始选中都依赖);onSelect(选中记录, 触发元素) 上报
@@ -1186,7 +1103,7 @@ window.__ModuleLoader__.load({
1186
1103
  return React.createElement(
1187
1104
  "div",
1188
1105
  { style: { display: "flex", flexDirection: "column", gap: 8 } },
1189
- // Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件决定打开弹窗)
1106
+ // Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件就地预览)
1190
1107
  conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
1191
1108
  React.createElement("div", { className: "dbt-group" },
1192
1109
  React.createElement("div", { className: "dbt-tree" }, treeRows)),
@@ -1208,9 +1125,8 @@ window.__ModuleLoader__.load({
1208
1125
  };
1209
1126
  function PreviewGrid(props) {
1210
1127
  const { preview, schema, kind, editable, tableType, tableName, dbRef, schemaName, connId, projectPath, onSaved, askConfirm } = props;
1211
- const [pop, setPop] = React.useState(null); // {rowIdx, colName, cell} 单元格浮层
1212
- const [editing, setEditing] = React.useState(false);
1213
- const [text, setText] = React.useState(""); // 编辑态 textarea 值(进入浮层时就位)
1128
+ const [sel, setSel] = React.useState(null); // {rowIdx, colName, cell} 底部详情栏当前单元格(null=收起底栏)
1129
+ const [text, setText] = React.useState(""); // 详情栏 textarea 值(选中即就位)
1214
1130
  const [editNull, setEditNull] = React.useState(false); // 「设为 NULL」勾选(null 单元格默认勾选)
1215
1131
  const [cellErr, setCellErr] = React.useState("");
1216
1132
  const [busy, setBusy] = React.useState(false);
@@ -1221,23 +1137,23 @@ window.__ModuleLoader__.load({
1221
1137
  ? schema.filter((c) => c && c.key === "PRI").map((c) => c.name)
1222
1138
  : [];
1223
1139
 
1224
- // 打开浮层(查看态):编辑态初值一并就位——textarea 空 ↔ 勾选设为 NULL
1225
- function openCell(rowIdx, colName, cell) {
1226
- setPop({ rowIdx, colName, cell });
1227
- setEditing(false);
1140
+ // 点单元格开合底栏:同格再点收起;新格选中即把 textarea 初值就位——textarea 空 ↔ 勾选设为 NULL
1141
+ function toggleCell(rowIdx, colName, cell) {
1228
1142
  setCellErr("");
1143
+ if (sel && sel.rowIdx === rowIdx && sel.colName === colName) { setSel(null); return; }
1144
+ setSel({ rowIdx, colName, cell });
1229
1145
  setEditNull(cell === null);
1230
1146
  setText(cell === null ? "" : String(cell));
1231
1147
  }
1232
1148
  function copyCell() {
1233
- if (!pop) return;
1234
- navigator.clipboard.writeText(String(pop.cell)).then(() => {
1149
+ if (!sel) return;
1150
+ navigator.clipboard.writeText(String(sel.cell)).then(() => {
1235
1151
  setCopied(true);
1236
1152
  window.setTimeout(() => setCopied(false), 1500);
1237
1153
  }, () => { /* 剪贴板不可用(无权限等):静默,按钮文案不变 */ });
1238
1154
  }
1239
1155
 
1240
- // 单元格编辑资格:返回 null=可编辑,否则为原因 i18n key(浮层 .dbt-readhint 展示)
1156
+ // 单元格编辑资格:返回 null=可编辑,否则为原因 i18n key(底栏 .dbt-readhint 展示)
1241
1157
  function cellReason(colName, shown) {
1242
1158
  if (!editable || !dialectOf(kind)) return "readOnlyRo";
1243
1159
  if (isMongo) return colName === "_id" ? "readOnlyRo" : null;
@@ -1252,11 +1168,11 @@ window.__ModuleLoader__.load({
1252
1168
  return null;
1253
1169
  }
1254
1170
 
1255
- // 保存:构造写回命令(按 kind 分派)→ 顺序执行(redis 两步)→ 全部成功关浮层并回调 onSaved
1171
+ // 保存:构造写回命令(按 kind 分派)→ 顺序执行(redis 两步)→ 全部成功收起底栏并回调 onSaved
1256
1172
  async function saveCell() {
1257
- if (!pop) return;
1173
+ if (!sel) return;
1258
1174
  setCellErr("");
1259
- const { rowIdx, colName } = pop;
1175
+ const { rowIdx, colName } = sel;
1260
1176
  const row = (preview.rows || [])[rowIdx] || [];
1261
1177
  let r;
1262
1178
  if (kind === "redis") {
@@ -1281,26 +1197,32 @@ window.__ModuleLoader__.load({
1281
1197
  askConfirm,
1282
1198
  );
1283
1199
  }
1284
- setPop(null);
1200
+ setSel(null);
1285
1201
  if (onSaved) onSaved();
1286
1202
  } catch (e) {
1287
- // 取消确认(e.cancelled)不算失败:浮层保持打开供重试
1203
+ // 取消确认(e.cancelled)不算失败:底栏保持打开供重试
1288
1204
  if (!e.cancelled) setCellErr(t("error") + ": " + String(e && e.message ? e.message : e));
1289
1205
  } finally {
1290
1206
  setBusy(false);
1291
1207
  }
1292
1208
  }
1293
1209
 
1294
- const popReason = pop ? cellReason(pop.colName, pop.cell === null ? "NULL" : String(pop.cell)) : null;
1210
+ // 切表/翻页联动:预览数据引用变化即收起底栏,避免行号错位指向旧行
1211
+ React.useEffect(() => { setSel(null); }, [preview]);
1212
+
1213
+ const shown = sel ? (sel.cell === null ? "NULL" : String(sel.cell)) : null;
1214
+ const selReason = sel ? cellReason(sel.colName, shown) : null;
1215
+ // 截断/BLOB 独立提示:命中即补显,若已作为只读原因展示过则不再重复
1216
+ const truncHint = !sel ? null : isTruncatedCell(shown) ? "truncatedNoEdit" : isBlobCell(shown) ? "blobNoEdit" : null;
1295
1217
  return React.createElement(
1296
1218
  "div",
1297
1219
  { className: "dbt-browse-root", style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0 } },
1298
- // 顶层只读提示:授权为 ro 时整格只读,单元格仍可点击查看完整值
1220
+ // 顶层只读提示:授权为 ro 时整格只读,单元格仍可点开底栏查看完整值
1299
1221
  !editable ? React.createElement("div", { className: "dbt-readhint" }, t("readOnlyRo")) : null,
1300
- // relative 容器承载单元格浮层(简化定位:水平居中贴底)
1222
+ // 预览表格区(flex:1 撑满剩余空间;底部详情栏在表格之后、面板流内)
1301
1223
  React.createElement(
1302
1224
  "div",
1303
- { style: { position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } },
1225
+ { style: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } },
1304
1226
  React.createElement(
1305
1227
  "div",
1306
1228
  { className: "dbt-browse-tablewrap" },
@@ -1321,81 +1243,63 @@ window.__ModuleLoader__.load({
1321
1243
  React.createElement("td", { key: j, title: row[j] === null ? "NULL" : String(row[j]) },
1322
1244
  React.createElement("button", {
1323
1245
  className: "dbt-cellbtn", type: "button",
1324
- onClick: () => openCell(i, colName, row[j]),
1325
- }, row[j] === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : String(row[j]))))),
1246
+ onClick: () => toggleCell(i, colName, row[j]),
1247
+ }, row[j] === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : String(row[j])))))),
1326
1248
  ),
1327
1249
  ),
1328
1250
  ),
1329
- pop ? React.createElement(
1251
+ ),
1252
+ // 底部详情栏(Navicat/检查器式):预览表格下方一栏,textarea 直接可编辑,点保存写回
1253
+ sel ? React.createElement(
1254
+ "div",
1255
+ { className: "dbt-celldetail" },
1256
+ // 标题行:完整值 · 列名;null 单元格带 NULL 芯片;只读原因随之展示
1257
+ React.createElement(
1330
1258
  "div",
1331
- { className: "dbt-cellpop", style: { position: "absolute", left: "50%", transform: "translateX(-50%)", bottom: 12 } },
1332
- React.createElement("strong", null, t("cellValue") + " · " + pop.colName),
1333
- React.createElement(
1334
- "div",
1335
- { className: "dbt-cellpop-value" },
1336
- pop.cell === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : String(pop.cell),
1337
- ),
1338
- popReason ? React.createElement("div", { className: "dbt-readhint" }, t(popReason)) : null,
1339
- cellErr ? React.createElement("div", { className: "dbt-err" }, cellErr) : null,
1340
- editing
1341
- ? React.createElement(
1342
- "div",
1343
- { className: "dbt-celledit" },
1344
- React.createElement("textarea", { value: text, onChange: (e) => setText(e.target.value) }),
1345
- React.createElement(
1346
- "label",
1347
- // 不用 .dbt-row:全局 input flex:1 会把 checkbox 拉伸占满、标签被推到远端(视觉审查命中)
1348
- { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "nowrap", width: "fit-content" } },
1349
- React.createElement("input", { type: "checkbox", style: { flex: "none", minWidth: 0, width: "auto" }, checked: editNull, onChange: (e) => setEditNull(e.target.checked) }),
1350
- t("setNull"),
1351
- ),
1352
- React.createElement(
1353
- "div",
1354
- { className: "dbt-row" },
1355
- React.createElement("button", { className: "dbt-btn primary", disabled: busy, onClick: saveCell }, busy ? t("cellSaving") : t("save")),
1356
- React.createElement("button", { className: "dbt-btn", disabled: busy, onClick: () => setEditing(false) }, t("cancel")),
1357
- ),
1358
- )
1359
- : React.createElement(
1360
- "div",
1361
- { className: "dbt-cellpop-actions" },
1362
- React.createElement("button", { className: "dbt-btn", onClick: copyCell }, copied ? t("copied") : t("copyBtn")),
1363
- popReason === null ? React.createElement("button", { className: "dbt-btn primary", onClick: () => setEditing(true) }, t("editCell")) : null,
1364
- React.createElement("button", { className: "dbt-btn", onClick: () => setPop(null) }, t("close")),
1365
- ),
1366
- ) : null,
1259
+ null,
1260
+ React.createElement("strong", null, t("cellValue") + " · " + sel.colName),
1261
+ sel.cell === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : null,
1262
+ selReason ? React.createElement("span", { className: "dbt-readhint" }, t(selReason)) : null,
1367
1263
  ),
1368
- ),
1264
+ // 完整值编辑区:textarea 恒为编辑形态,不可编辑时只读
1265
+ React.createElement("textarea", {
1266
+ className: "dbt-celldetail-input",
1267
+ value: text,
1268
+ readOnly: selReason !== null,
1269
+ onChange: (e) => setText(e.target.value),
1270
+ }),
1271
+ sel.cell === null ? React.createElement(
1272
+ "label",
1273
+ // 不用 .dbt-row:全局 input flex:1 会把 checkbox 拉伸占满、标签被推到远端(视觉审查命中)
1274
+ { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "nowrap", width: "fit-content" } },
1275
+ React.createElement("input", { type: "checkbox", style: { flex: "none", minWidth: 0, width: "auto" }, checked: editNull, onChange: (e) => setEditNull(e.target.checked) }),
1276
+ t("setNull"),
1277
+ ) : null,
1278
+ truncHint && truncHint !== selReason ? React.createElement("span", { className: "dbt-readhint" }, t(truncHint)) : null,
1279
+ cellErr ? React.createElement("span", { className: "dbt-err" }, cellErr) : null,
1280
+ React.createElement(
1281
+ "div",
1282
+ { className: "dbt-row" },
1283
+ React.createElement("button", { className: "dbt-btn", onClick: copyCell }, copied ? t("copied") : t("copyBtn")),
1284
+ selReason === null ? React.createElement("button", { className: "dbt-btn primary", disabled: busy, onClick: saveCell }, busy ? t("cellSaving") : t("save")) : null,
1285
+ React.createElement("button", { className: "dbt-btn", onClick: () => setSel(null) }, t("close")),
1286
+ ),
1287
+ ) : null,
1369
1288
  );
1370
1289
  }
1371
1290
 
1372
- function BrowseDialog(props) {
1373
- const { conns, projectPath, grants, askConfirm, onClose, ctx } = props;
1291
+ // Navicat 式浏览面板(内嵌侧边栏):上半对象树选库-表,下半结构/数据预览,点表即看,不再弹窗
1292
+ function BrowsePane(props) {
1293
+ const { conns, projectPath, grants, askConfirm } = props;
1374
1294
  // grants(editable 判定)与 askConfirm(单元格写回确认)下传 PreviewGrid
1375
- // portal 异步解析接入:portalReady 仅用于触发重渲染(mountDialog 内部读 portalImpl)。
1376
- // createPortal 不换组件实例——portalReady 切换前后是同一 DOM 节点,pos state 保持,居中定位不受 portal 重挂影响。
1377
- const [portalReady, setPortalReady] = React.useState(!!portalImpl);
1378
- React.useEffect(() => {
1379
- resolvePortalAsync(ctx);
1380
- resolveRootAsync(ctx);
1381
- onPortalReady(function () { setPortalReady(true); });
1382
- // 诊断:一次性输出(帮助真机排查 iframe 隔离与 portal/createRoot 解析结果、PiP 尝试结果)
1383
- if (!window.__dbtPortalDiag) {
1384
- window.__dbtPortalDiag = true;
1385
- console.info("[dbt] portal:", portalImpl ? "sync/async ok" : "unavailable",
1386
- "createRoot:", rootSource || "none", "pip:", pipResult,
1387
- "sameDoc:", window.top === window.self);
1388
- }
1389
- }, []);
1390
- const [sel, setSel] = React.useState(null); // 弹窗内当前选中(null=未选表,右栏显示引导空态)
1295
+ const [sel, setSel] = React.useState(null); // 面板内当前选中(null=未选表,内容区显示引导空态)
1391
1296
  const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
1392
1297
  const [schema, setSchema] = React.useState([]);
1393
1298
  const [preview, setPreview] = React.useState(null); // QueryResult
1394
1299
  const [page, setPage] = React.useState(1);
1395
1300
  const [busy, setBusy] = React.useState("");
1396
- const [loadErr, setLoadErr] = React.useState(""); // 右栏加载失败(就地显示 + 重试)
1301
+ const [loadErr, setLoadErr] = React.useState(""); // 内容区加载失败(就地显示 + 重试)
1397
1302
  const openSeq = React.useRef(0); // 请求序号守卫
1398
- const closeRef = React.useRef(null);
1399
1303
 
1400
1304
  // 请求序号守卫(沿用面板):快速切表/翻页时丢弃晚到旧响应,防止旧数据覆盖新选中项
1401
1305
  const openTable = React.useCallback((s, pg) => {
@@ -1412,59 +1316,16 @@ window.__ModuleLoader__.load({
1412
1316
  if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
1413
1317
  setSchema(sch || []); setPreview(prev); setPage(pg || 1);
1414
1318
  })
1415
- // 弹窗内错误就地显示(不走面板顶部错误区)
1319
+ // 面板内错误就地显示(不走面板顶部错误区)
1416
1320
  .catch((e) => { if (seq === openSeq.current) setLoadErr(String(e && e.message ? e.message : e)); })
1417
1321
  .finally(() => { if (seq === openSeq.current) setBusy(""); });
1418
1322
  }, [projectPath]);
1419
1323
  React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
1420
1324
 
1421
- // 非模态浮窗:无焦点陷阱(焦点可自由离开,主页交互不受阻);Esc 仅在焦点位于弹窗内时
1422
- // 经容器 onKeyDown 触发(stopPropagation 防止宿主级快捷键同时响应)。初始焦点=关闭按钮
1423
- React.useEffect(() => {
1424
- if (closeRef.current) closeRef.current.focus();
1425
- }, []);
1426
-
1427
- // 拖动定位:首帧给个保守值,mount 后按浮窗实测尺寸居中(坐标基准取浮窗所在 document 的视口——DOM 浮窗为主视口,PiP 独立窗口为 PiP 视口)
1428
- const dialogRef2 = React.useRef(null);
1429
- const [pos, setPos] = React.useState(() => ({ top: 72, left: 12 }));
1430
- React.useEffect(() => {
1431
- const el = dialogRef2.current;
1432
- if (!el) return;
1433
- const view = (el.ownerDocument && el.ownerDocument.defaultView) || window;
1434
- const w = view.innerWidth || 1200, h = view.innerHeight || 800;
1435
- setPos({
1436
- top: Math.max(0, Math.round((h - el.offsetHeight) / 2)),
1437
- left: Math.max(0, Math.round((w - el.offsetWidth) / 2)),
1438
- });
1439
- }, []);
1440
- const draggingRef = React.useRef(null); // {dx, dy}
1441
- const [dragging, setDragging] = React.useState(false);
1442
- function stopDrag() { draggingRef.current = null; setDragging(false); }
1443
- function onHeaderPointerDown(e) {
1444
- // header 内可点元素(关闭按钮)不启动拖动,否则 setPointerCapture 会把 click 重定向到 header 使按钮失效
1445
- if (e.target.closest && e.target.closest("button")) return;
1446
- draggingRef.current = { dx: e.clientX - pos.left, dy: e.clientY - pos.top };
1447
- e.currentTarget.setPointerCapture(e.pointerId);
1448
- setDragging(true);
1449
- }
1450
- function onHeaderPointerMove(e) {
1451
- if (!draggingRef.current) return;
1452
- const view = (e.currentTarget.ownerDocument && e.currentTarget.ownerDocument.defaultView) || window;
1453
- const h = view.innerHeight || 800, w = view.innerWidth || 1200;
1454
- // clamp 全部用浮窗实测尺寸:整体保持在视口内(窄视口下常量估宽会把窗拖丢)
1455
- const dlg = e.currentTarget.parentElement;
1456
- const dlgH = (dlg && dlg.offsetHeight) || 720;
1457
- const dlgW = (dlg && dlg.offsetWidth) || 1100;
1458
- setPos({
1459
- top: Math.min(Math.max(e.clientY - draggingRef.current.dy, 0), Math.max(0, h - dlgH)),
1460
- left: Math.min(Math.max(e.clientX - draggingRef.current.dx, 0), Math.max(0, w - dlgW)),
1461
- });
1462
- }
1463
-
1464
- // 右栏互斥状态:未选中引导 / 加载骨架 / 错误重试 / 空态 / 表格
1325
+ // 内容区互斥状态:未选中引导 / 加载骨架 / 错误重试 / 空态 / 表格
1465
1326
  function rightPane() {
1466
1327
  if (!sel && busy !== "open" && !loadErr) {
1467
- // 未选表:显示引导空态(浏览窗口可直接打开,不强制先选表)
1328
+ // 未选表:显示引导空态(内嵌面板可直接浏览,不强制先选表)
1468
1329
  return React.createElement("div", { className: "dbt-browse-empty" }, t("selectTableHint"));
1469
1330
  }
1470
1331
  if (busy === "open") {
@@ -1474,7 +1335,7 @@ window.__ModuleLoader__.load({
1474
1335
  [0, 1, 2, 3, 4, 5].map((c) => React.createElement("div", { className: "dbt-browse-skelcell", key: c })))));
1475
1336
  }
1476
1337
  if (loadErr) {
1477
- // 错误就地显示 + 重试(弹窗遮罩下面板顶部错误区不可见,不走 props.onError)
1338
+ // 错误就地显示 + 重试(内嵌面板保持就地呈现,不走 props.onError)
1478
1339
  return React.createElement("div", { className: "dbt-browse-errorbox" },
1479
1340
  React.createElement("div", { className: "dbt-err" }, t("error") + ": " + loadErr),
1480
1341
  React.createElement("button", { className: "dbt-btn", onClick: () => openTable(sel, 1) }, t("retry")));
@@ -1506,168 +1367,49 @@ window.__ModuleLoader__.load({
1506
1367
  });
1507
1368
  }
1508
1369
 
1509
- // 非模态浮窗:无遮罩、fixed 定位、可拖动;portal 到 body 不受侧栏视图切换影响
1510
- return mountDialog(React.createElement(
1370
+ // 内嵌面板纵向布局:上半对象树(限高滚动,容器类名留给样式层),下半预览主区 + 分页
1371
+ return React.createElement(
1511
1372
  "div",
1512
- {
1513
- className: "dbt-browse-root dbt-browse-dialog" + (dragging ? " dragging" : ""),
1514
- ref: dialogRef2,
1515
- style: { position: "fixed", top: pos.top, left: pos.left },
1516
- role: "dialog", "aria-labelledby": "dbt-browse-title",
1517
- onKeyDown: (e) => {
1518
- if (e.key === "Escape") { e.stopPropagation(); onClose(); }
1519
- },
1520
- },
1521
- // ---- header:面包屑 + 关闭(整条 header 可拖动)----
1373
+ { className: "dbt-view" },
1522
1374
  React.createElement(
1523
1375
  "div",
1524
- {
1525
- className: "dbt-browse-header",
1526
- style: { cursor: dragging ? "grabbing" : "grab" },
1527
- onPointerDown: onHeaderPointerDown,
1528
- onPointerMove: onHeaderPointerMove,
1529
- onPointerUp: stopDrag,
1530
- onPointerCancel: stopDrag,
1531
- },
1532
- React.createElement("div", { className: "dbt-browse-title", id: "dbt-browse-title" },
1533
- !sel ? t("viewBrowse") : t("browseDialogTitle", {
1534
- conn: (conns.find((c) => c.id === sel.connId) || {}).name || sel.connId,
1535
- db: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
1536
- table: sel.table.name,
1537
- })),
1538
- React.createElement(
1539
- "button",
1540
- { className: "dbt-browse-close", ref: closeRef, "aria-label": t("close"), onClick: onClose },
1541
- React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
1542
- React.createElement("path", { d: "M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })),
1543
- ),
1544
- ),
1545
- // ---- body:左树 + 右主区 ----
1376
+ { style: { maxHeight: 280, overflowY: "auto" } },
1377
+ React.createElement(BrowseTree, {
1378
+ conns, projectPath, sel, autoExpand: true, // 内嵌后此树是唯一树实例,挂载即展开第一层级
1379
+ onSelect: (s) => setSel(s), // 树内切表
1380
+ }),
1381
+ ),
1382
+ React.createElement(
1383
+ "div",
1384
+ { className: "dbt-browse-main" },
1385
+ // 结构/数据:mini segmented(复用面板同款与 i18n key)
1546
1386
  React.createElement(
1547
1387
  "div",
1548
- { className: "dbt-browse-body" },
1549
- React.createElement(
1550
- "div",
1551
- { className: "dbt-browse-tree" },
1552
- React.createElement(BrowseTree, {
1553
- conns, projectPath, sel, autoExpand: true, // 本任务后弹窗树是唯一树实例,挂载即展开第一层级
1554
- onSelect: (s) => setSel(s), // 弹窗内树切表
1555
- }),
1556
- ),
1388
+ { className: "dbt-browse-tabs" },
1557
1389
  React.createElement(
1558
1390
  "div",
1559
- { className: "dbt-browse-main" },
1560
- // 结构/数据:mini segmented(复用面板同款与 i18n key)
1561
- React.createElement(
1562
- "div",
1563
- { className: "dbt-browse-tabs" },
1564
- React.createElement(
1565
- "div",
1566
- { className: "dbt-seg" },
1567
- ["structure", "preview"].map((v) =>
1568
- React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
1569
- v === "structure" ? t("structure") : t("preview"))),
1570
- ),
1571
- ),
1572
- React.createElement("div", { className: "dbt-browse-content" }, rightPane()),
1391
+ { className: "dbt-seg" },
1392
+ ["structure", "preview"].map((v) =>
1393
+ React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
1394
+ v === "structure" ? t("structure") : t("preview"))),
1573
1395
  ),
1574
1396
  ),
1575
- // ---- footer:分页条(仅已选表的数据预览视图;空选中态不显示) ----
1576
- sel && view === "preview" && !loadErr ? React.createElement(
1577
- "div",
1578
- { className: "dbt-browse-footer" },
1579
- React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
1580
- React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
1581
- React.createElement("span", { style: { flex: 1 } }),
1582
- preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
1583
- React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
1584
- ) : null,
1397
+ // 限高兜底:无固定高度弹窗壳后给内容区可视上限,预览表在容器内滚动、单元格浮层贴底可见
1398
+ React.createElement("div", { className: "dbt-browse-content", style: { maxHeight: 420 } }, rightPane()),
1585
1399
  ),
1400
+ // 分页条(仅已选表的数据预览视图;空选中态不显示)
1401
+ sel && view === "preview" && !loadErr ? React.createElement(
1402
+ "div",
1403
+ { className: "dbt-browse-footer" },
1404
+ React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
1405
+ React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
1406
+ React.createElement("span", { style: { flex: 1 } }),
1407
+ preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
1408
+ React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
1409
+ ) : null,
1586
1410
  );
1587
1411
  }
1588
1412
 
1589
- /* ---------------- PiP 独立窗口(Document Picture-in-Picture) ---------------- */
1590
- // OS 级独立窗口:可拖出桌面端主窗口、系统级置顶。内容为完整数据浏览 UI(同 JS 环境复用
1591
- // BrowseDialog/DangerDialog 与 mock 之外的宿主 fetch 同源);DOM 浮窗作为不可用时的降级。
1592
- // 打开成功返回 pipWindow,失败返回 null(调用方降级为 DOM 浮窗)。
1593
- function PipBrowseApp(props) { // {ctx, conns, projectPath, grants, onClose}
1594
- // 危险操作确认渲染在 PiP 窗口内(Panel 的确认对话框在主窗口,PiP 用户看不见)
1595
- const [confirmReq, setConfirmReq] = React.useState(null);
1596
- const askConfirm = React.useCallback((info) => new Promise((resolve) => {
1597
- setConfirmReq(Object.assign({}, info, { resolve }));
1598
- }), []);
1599
- return React.createElement(
1600
- React.Fragment,
1601
- null,
1602
- React.createElement(BrowseDialog, {
1603
- ctx: props.ctx, conns: props.conns, projectPath: props.projectPath,
1604
- grants: props.grants, askConfirm: askConfirm, onClose: props.onClose,
1605
- }),
1606
- confirmReq ? React.createElement(DangerDialog, { challenge: confirmReq, onClose: () => setConfirmReq(null) }) : null,
1607
- );
1608
- }
1609
- function openBrowsePip(ctx, deps) { // deps: {conns, projectPath, grants, onClosed, onFallback} → 返回 pipWindow | null
1610
- try {
1611
- const dpip = window.documentPictureInPicture;
1612
- // 入口只硬性要求 dpip;createRoot/portal 未就绪不再放弃——手势内先开窗,root 异步就绪后渲染,超时由调用方降级
1613
- if (!dpip || typeof dpip.requestWindow !== "function") return null;
1614
- resolveRootAsync(ctx); // createRoot 同步未就绪时,趁 3 秒超时窗口发起宿主异步 import
1615
- // PiP 只能在用户手势内同步发起 requestWindow,Promise then 里再做 DOM 装配
1616
- const p = dpip.requestWindow({ width: 1100, height: 720 });
1617
- pipResult = "opened"; // requestWindow 已发起;拒绝走下方 catch
1618
- return p.then((w) => {
1619
- const doc = w.document;
1620
- // 相对地址基准:PiP 文档是 about:blank,fetch 的相对 API 路径须按主 origin 解析
1621
- const base = doc.createElement("base");
1622
- base.href = window.location.origin + "/";
1623
- doc.head.appendChild(base);
1624
- // 样式:整套 --dbt-* token 与规则注入 PiP 文档
1625
- if (styleEl) doc.head.appendChild(doc.importNode(styleEl, true));
1626
- doc.body.style.margin = "0";
1627
- // 加载占位:createRoot 异步解析期间给用户可见反馈(PiP 场景少用,不做 i18n key)
1628
- const ph = doc.createElement("div");
1629
- ph.style.cssText = "display:flex;align-items:center;justify-content:center;height:100%;font:14px sans-serif;color:#888;";
1630
- ph.textContent = "正在打开数据浏览…";
1631
- doc.body.appendChild(ph);
1632
- let root = null;
1633
- let settled = false; // root 已渲染或窗口已关闭:晚到的 onRootReady 回调与超时兜底均不再处理
1634
- const closePip = () => {
1635
- if (root) { try { root.unmount(); } catch (e) { /* 已卸载 */ } }
1636
- portalDisabled = false;
1637
- try { w.close(); } catch (e2) { /* 已关闭 */ }
1638
- };
1639
- onRootReady(() => {
1640
- if (settled) return;
1641
- settled = true;
1642
- portalDisabled = true; // PiP root 已在 pipDoc.body 上,禁 portal 防 DOM 被拉回主窗口
1643
- root = reactCreateRoot(doc.body);
1644
- root.render(React.createElement(PipBrowseApp, {
1645
- ctx, conns: deps.conns, projectPath: deps.projectPath, grants: deps.grants,
1646
- onClose: () => { closePip(); deps.onClosed(); },
1647
- }));
1648
- ph.remove();
1649
- });
1650
- w.addEventListener("pagehide", () => { // 用户点 PiP 窗口系统关闭钮
1651
- settled = true;
1652
- if (root) { try { root.unmount(); } catch (e) { /* 已卸载 */ } }
1653
- portalDisabled = false;
1654
- deps.onClosed();
1655
- });
1656
- // 超时兜底:3 秒内 createRoot 没就绪 → 关 PiP 清理并回调降级(调用方打开 DOM 浮窗)
1657
- setTimeout(() => {
1658
- if (settled) return;
1659
- settled = true;
1660
- closePip();
1661
- deps.onFallback();
1662
- }, 3000);
1663
- return w;
1664
- }).catch((err) => { pipResult = "rejected:" + (err && err.name || "Error"); return null; });
1665
- } catch (e) {
1666
- pipResult = "rejected:" + (e && e.name || "Error");
1667
- return Promise.resolve(null);
1668
- }
1669
- }
1670
-
1671
1413
  /* ---------------- SQL 控制台 ---------------- */
1672
1414
  // Apple 等宽字体栈(规范 §1):优先样式层 --dbt-mono token,fallback 内联栈
1673
1415
  const MONO_FONT = "var(--dbt-mono, ui-monospace, \"SF Mono\", Menlo, Consolas, monospace)";
@@ -1794,25 +1536,6 @@ window.__ModuleLoader__.load({
1794
1536
  const [error, setError] = React.useState("");
1795
1537
  const [message, setMessage] = React.useState("");
1796
1538
  const [confirmReq, setConfirmReq] = React.useState(null); // {statement,danger,reason,resolve}
1797
- // 数据浏览浮窗开关:由 Panel 层持有(不随 view 切换卸载),跨 tab 常驻
1798
- const [browseOpen, setBrowseOpen] = React.useState(false);
1799
- // PiP 独立窗口句柄:优先于 DOM 浮窗(OS 级窗口,可拖出桌面端主窗口);存在时 DOM 浮窗不渲染
1800
- const [pipWin, setPipWin] = React.useState(null);
1801
- function openBrowse() {
1802
- if (pipWin) { try { pipWin.focus(); } catch (e) { /* 已关闭 */ } return; }
1803
- // requestWindow 必须在用户手势内同步发起(本函数仅由 onClick 直调)
1804
- const p = openBrowsePip(props.ctx, {
1805
- conns, projectPath, grants,
1806
- onClosed: () => setPipWin(null),
1807
- // PiP 超时降级(createRoot 未就绪):清掉窗口句柄并回退 DOM 浮窗
1808
- onFallback: () => { setPipWin(null); setBrowseOpen(true); },
1809
- });
1810
- if (p && typeof p.then === "function") {
1811
- p.then((w) => { if (w) setPipWin(w); else setBrowseOpen(true); });
1812
- } else {
1813
- setBrowseOpen(true);
1814
- }
1815
- }
1816
1539
 
1817
1540
  const askConfirm = React.useCallback((info) => new Promise((resolve) => {
1818
1541
  setConfirmReq(Object.assign({}, info, { resolve }));
@@ -1867,10 +1590,9 @@ window.__ModuleLoader__.load({
1867
1590
  "div",
1868
1591
  { className: "dbt-tabs" },
1869
1592
  [["manage", t("viewManage")], ["grants", t("viewGrants")], ["browse", t("viewBrowse")], ["console", t("viewConsole")]].map(([v, label]) =>
1870
- // browse tab:切视图的同时直接打开浮窗(单击即用,不必再进树点表)
1871
1593
  React.createElement("button", {
1872
1594
  key: v, className: view === v ? "active" : "",
1873
- onClick: () => { setView(v); if (v === "browse") openBrowse(); },
1595
+ onClick: () => setView(v),
1874
1596
  }, label)),
1875
1597
  ),
1876
1598
  (projectEdited || !projectPath)
@@ -1906,25 +1628,14 @@ window.__ModuleLoader__.load({
1906
1628
  React.createElement("div", { className: "dbt-view", key: view },
1907
1629
  view === "manage" ? React.createElement(ManageView, shared) : null,
1908
1630
  view === "grants" ? React.createElement(GrantsView, shared) : null,
1909
- // browse:提示卡(浮窗由 Panel 层在下方常驻渲染,不随视图切换卸载)
1910
- view === "browse" ? React.createElement(
1911
- "div",
1912
- { className: "dbt-card" },
1913
- // 主提示用正常文字色(dbt-muted 11px 次要色压深底对比不足,视觉审查命中)
1914
- React.createElement("div", { style: { fontSize: 13 } }, t("browsePanelHint")),
1915
- React.createElement("div", { className: "dbt-row" },
1916
- React.createElement("button", { className: "dbt-btn primary", onClick: openBrowse }, t("reopenBrowse"))),
1917
- ) : null,
1631
+ // browse:内嵌浏览面板(对象树 + 结构/数据预览,点表即看)
1632
+ view === "browse" ? React.createElement(BrowsePane, {
1633
+ ctx: props.ctx, conns, projectPath, grants, askConfirm,
1634
+ }) : null,
1918
1635
  view === "console" ? React.createElement(ConsoleView, Object.assign({}, shared, { askConfirm })) : null,
1919
1636
  ),
1920
1637
  React.createElement("div", { className: "dbt-muted" }, t("footerHint")),
1921
1638
  confirmReq ? React.createElement(DangerDialog, { challenge: confirmReq, onClose: () => setConfirmReq(null) }) : null,
1922
- // 浮窗常驻:portal 到 body 且在 dbt-view 外,切 tab 时不消失
1923
- browseOpen ? React.createElement(BrowseDialog, {
1924
- conns, projectPath, grants, askConfirm,
1925
- ctx: props.ctx, // 宿主 ctx 透传给浮窗,供 portal 异步解析(ctx.modules.import)
1926
- onClose: () => setBrowseOpen(false),
1927
- }) : null,
1928
1639
  );
1929
1640
  }
1930
1641
 
@@ -1947,7 +1658,7 @@ window.__ModuleLoader__.load({
1947
1658
  key: "db-tool-" + String(localeKey || "zh"),
1948
1659
  visible: props.visible,
1949
1660
  scope: props.scope,
1950
- ctx: ctx, // 宿主 ctx 下传,Panel 透传给 BrowseDialog 供 portal 解析
1661
+ ctx: ctx, // 宿主 ctx 下传,Panel 透传给 BrowsePane
1951
1662
  });
1952
1663
  }
1953
1664
 
@@ -1,12 +1,13 @@
1
1
  import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from './common.js';
2
- function poolConfig(conn) {
2
+ function poolConfig(conn, overrideUrl) {
3
3
  const base = {
4
4
  max: 10,
5
5
  idleTimeoutMillis: 30000,
6
6
  connectionTimeoutMillis: 5000,
7
7
  };
8
8
  if (conn.url) {
9
- base.connectionString = conn.url;
9
+ // overrideUrl:多主机探测选定台 / 剥 jdbc: 后的最终 connectionString
10
+ base.connectionString = overrideUrl ?? conn.url;
10
11
  }
11
12
  else if (conn.fields) {
12
13
  const f = conn.fields;
@@ -29,8 +30,122 @@ function poolConfig(conn) {
29
30
  base.ssl = { rejectUnauthorized: false };
30
31
  return base;
31
32
  }
33
+ /**
34
+ * 规范化 JDBC / 多主机风格连接 URL。
35
+ *
36
+ * 背景:`jdbc:gaussdb://...` 的 `jdbc:` 是 WHATWG 非特殊 scheme,驱动内 new URL()
37
+ * 把 `gaussdb://...` 当不透明路径 → hostname/port 为空 → 兜底连本机;多主机
38
+ * authority(host:port,host:port)端口段含逗号非数字 → new URL 直接抛 Invalid URL。
39
+ * 故自行按文本解析 authority,不经 WHATWG 解析。
40
+ */
41
+ export function normalizeUrl(url) {
42
+ const stripped = url.replace(/^jdbc:/i, '');
43
+ const schemeEnd = stripped.indexOf('://');
44
+ // 无 authority(非 URL 形态):整串原样保留,交由驱动按既有逻辑处理
45
+ if (schemeEnd < 0)
46
+ return { prefix: stripped, suffix: '' };
47
+ const rest = stripped.slice(schemeEnd + 3);
48
+ const authEnd = /[/?#]/.exec(rest)?.index ?? rest.length;
49
+ const authority = rest.slice(0, authEnd);
50
+ const suffix = rest.slice(authEnd);
51
+ const at = authority.lastIndexOf('@');
52
+ const userinfo = at >= 0 ? authority.slice(0, at + 1) : '';
53
+ const hostPart = authority.slice(at + 1);
54
+ const hosts = hostPart.split(',').map((seg) => {
55
+ // ponytail: 按最后一个冒号拆 host/port,仅支持 IPv4/主机名(IPv6 需方括号感知,遇到再加)
56
+ const ci = seg.lastIndexOf(':');
57
+ if (ci < 0)
58
+ return { host: seg };
59
+ const port = Number.parseInt(seg.slice(ci + 1), 10);
60
+ return Number.isFinite(port) ? { host: seg.slice(0, ci), port } : { host: seg };
61
+ });
62
+ const prefix = `${stripped.slice(0, schemeEnd)}://${userinfo}`;
63
+ // 单台不设 hosts(调用方只在多台时探测)
64
+ if (hosts.length <= 1)
65
+ return { prefix: prefix + hostPart, suffix };
66
+ return { prefix, hosts, suffix };
67
+ }
68
+ /**
69
+ * 逐台探测可用节点(移植自用户项目 GaussDB-MCP 的 probeHosts):返回第一台可连
70
+ * (masterOnly 时须为主节点,pg_is_in_recovery() 为 true 的备节点跳过)的主机;
71
+ * 全部失败返回 undefined(调用方回退首台,让 testConnect 报真实连接错误)。
72
+ */
73
+ // ponytail: 仅适配器创建时探测一次;运行中主备切换不自动 failover,需要时在 pool error 事件里重建池再探测
74
+ export async function pickHost(driver, base, hosts, masterOnly) {
75
+ if (!driver.Client)
76
+ return undefined;
77
+ for (const h of hosts) {
78
+ // urlUser/urlPassword 是调用方传入的内部凭据约定键,转成驱动的 user/password 后剔除
79
+ const { urlUser, urlPassword, ...rest } = base;
80
+ const cfg = { ...rest, connectionString: undefined, host: h.host };
81
+ if (h.port != null)
82
+ cfg.port = h.port;
83
+ // 凭据自 base.urlUser/base.urlPassword 解出;无则不设
84
+ if (typeof urlUser === 'string' && urlUser !== '')
85
+ cfg.user = urlUser;
86
+ if (typeof urlPassword === 'string')
87
+ cfg.password = urlPassword;
88
+ const client = new driver.Client(cfg);
89
+ try {
90
+ await client.connect();
91
+ if (masterOnly) {
92
+ const res = await client.query('SELECT pg_is_in_recovery() AS in_recovery');
93
+ if (res.rows[0]?.in_recovery === true) {
94
+ await client.end().catch(() => { });
95
+ continue; // 备节点跳过
96
+ }
97
+ }
98
+ await client.end().catch(() => { });
99
+ return h.port != null ? { host: h.host, port: h.port } : { host: h.host };
100
+ }
101
+ catch {
102
+ await client.end().catch(() => { });
103
+ }
104
+ }
105
+ return undefined;
106
+ }
32
107
  export async function createPgLikeAdapter(kind, Driver, conn, opts) {
33
- const pool = new Driver.Pool(poolConfig(conn));
108
+ // GaussDB JDBC 风格 URL 驱动自带的 WHATWG 解析处理不了(jdbc: 非特殊 scheme 使
109
+ // hostname/port 为空 → 兜底连本机;多主机 authority 端口段含逗号 → 抛 Invalid URL),
110
+ // 故建池前先规范化,多主机时逐台探测选可用节点;postgresql 多主机列表剥 jdbc: 后
111
+ // 原样透传(pg 驱动原生支持 libpq 多主机 failover),不 probe。
112
+ const norm = conn.url ? normalizeUrl(conn.url) : null;
113
+ const joinSeg = (h) => `${h.host}${h.port != null ? ':' + h.port : ''}`;
114
+ const cleanUrl = norm ? norm.prefix + (norm.hosts ?? []).map(joinSeg).join(',') + norm.suffix : undefined;
115
+ let finalUrl = cleanUrl;
116
+ if (kind === 'gaussdb' && norm?.hosts && norm.hosts.length > 1) {
117
+ const { prefix, suffix } = norm;
118
+ const joinHost = (h) => prefix + joinSeg(h) + suffix;
119
+ const firstUrl = joinHost(norm.hosts[0]);
120
+ // targetServerType=master(键值大小写不敏感)→ 只接受主节点
121
+ const qi = suffix.indexOf('?');
122
+ let masterOnly = false;
123
+ for (const [k, v] of new URLSearchParams(qi >= 0 ? suffix.slice(qi + 1) : '')) {
124
+ if (k.toLowerCase() === 'targetservertype' && v.toLowerCase() === 'master')
125
+ masterOnly = true;
126
+ }
127
+ const base = poolConfig(conn, firstUrl);
128
+ // 探测凭据自 prefix userinfo 解出(按最后一个 @ / 最后一个 : 分界,decode 与
129
+ // percent-encode 注入互逆);仅密码无用户名时 user 不设
130
+ if (prefix.endsWith('@')) {
131
+ const body = prefix.slice(prefix.indexOf('://') + 3, -1);
132
+ const ci = body.lastIndexOf(':');
133
+ if (ci >= 0) {
134
+ const user = decodeURIComponent(body.slice(0, ci));
135
+ if (user !== '')
136
+ base.urlUser = user;
137
+ base.urlPassword = decodeURIComponent(body.slice(ci + 1));
138
+ }
139
+ else if (body !== '') {
140
+ base.urlUser = decodeURIComponent(body);
141
+ }
142
+ }
143
+ const picked = await pickHost(Driver, base, norm.hosts, masterOnly);
144
+ // 全败回退首台重组值:让 testConnect 报真实连接错误
145
+ finalUrl = picked ? joinHost(picked) : firstUrl;
146
+ }
147
+ const cfg = poolConfig(conn, finalUrl);
148
+ const pool = new Driver.Pool(cfg);
34
149
  const readOnly = opts?.mode === 'ro';
35
150
  // 所有池(主池 + 跨库池)统一走此初始化。
36
151
  // pg 约定:release(err) 若无等待者会 emit pool 'error',无监听器会崩进程——
@@ -58,10 +173,11 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
58
173
  const mainDb = () => {
59
174
  if (conn.fields)
60
175
  return conn.fields.database ? String(conn.fields.database) : null;
61
- if (!conn.url)
176
+ if (!finalUrl)
62
177
  return null;
63
178
  try {
64
- return decodeURIComponent(new URL(conn.url).pathname.replace(/^\//, '')) || null;
179
+ // 规范化后的最终 URL(单台 authority)才可被 new URL 正确解析
180
+ return decodeURIComponent(new URL(finalUrl).pathname.replace(/^\//, '')) || null;
65
181
  }
66
182
  catch {
67
183
  return null;
@@ -73,7 +189,8 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
73
189
  const cached = dbPools.get(db);
74
190
  if (cached)
75
191
  return cached;
76
- const created = new Driver.Pool({ ...poolConfig(conn), database: db });
192
+ // 与主池同一份最终 cfg(多主机探测选定台后的 connectionString),仅覆盖库名
193
+ const created = new Driver.Pool({ ...cfg, database: db });
77
194
  setupPool(created); // 与主池一致:error 兜底 + ro 会话只读
78
195
  dbPools.set(db, created);
79
196
  return created;
@@ -10,11 +10,16 @@ import * as path from 'node:path';
10
10
  import { readJson, writeJsonAtomic } from './io.js';
11
11
  /** fields 中被视为机密的键,落盘前拆出到 secrets.json */
12
12
  const PASSWORD_KEY = 'password';
13
+ /** authority 段定位:scheme:// 到首个 /?#(兼容可选 jdbc: 前缀,如 jdbc:gaussdb://),
14
+ * 捕获组 1 为 scheme:// 部分、组 2 为 authority(多主机逗号串整体落在该段内)。
15
+ * redactUrl 与 mergeUrlCredentials 的字符串回退注入共用。 */
16
+ const AUTHORITY_RE = /^((?:jdbc:)?[a-z][a-z0-9+.-]*:\/\/)([^/?#]*)/i;
13
17
  /** 将 URL 中的密码段替换为 ***:scheme://user:pass@host → scheme://user:***@host。
14
18
  * 密码可能包含 / ? # @ 等字符:先取 scheme:// 到首个 /?# 的 authority 段,
15
- * 在 authority 内以最后一个 @ 分界取 userinfo,再以最后一个 : 分界取密码。 */
19
+ * 在 authority 内以最后一个 @ 分界取 userinfo,再以最后一个 : 分界取密码。
20
+ * scheme 兼容可选 jdbc: 前缀(jdbc:gaussdb://…),无前缀行为不变。 */
16
21
  export function redactUrl(url) {
17
- const m = url.match(/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)/i);
22
+ const m = url.match(AUTHORITY_RE);
18
23
  if (!m)
19
24
  return url;
20
25
  const authority = m[2] ?? '';
@@ -34,7 +39,12 @@ export function redactUrl(url) {
34
39
  * - url 含脱敏标记 ':***@'(编辑回填值)→ 有 secretsUrl 以其为基底整体替换,否则报错;
35
40
  * - 注入用 WHATWG URL 的 username/password setter(自动 percent-encode '@:/?# ' 等特殊字符),
36
41
  * 与适配器侧 decodeURIComponent / 驱动 RFC 3986 解码严格互逆;只填一项只覆盖一项
37
- * (redis 'redis://:pass@host' 无用户名形态、oracle 必填用户名校验均兼容)。 */
42
+ * (redis 'redis://:pass@host' 无用户名形态、oracle 必填用户名校验均兼容)。
43
+ * - WHATWG 解析失败(如多主机逗号端口 gaussdb://h1:8000,h2:8000/…)或 setter 静默无效
44
+ * (jdbc:gaussdb://… 为 opaque path、host 为空)时回退字符串注入:正则定位 authority 段
45
+ * (兼容可选 jdbc: 前缀),段内以最后一个 @ 为界替换/插入 userinfo,其余部分逐字保留;
46
+ * 凭据按 encodeURIComponent 编码,只填一项只覆盖一项的语义与 setter 对齐;
47
+ * 无 scheme://authority 形态的残缺串仍报「URL 无效」。 */
38
48
  export function mergeUrlCredentials(opts) {
39
49
  const { url, urlUser, urlPassword, secretsUrl } = opts;
40
50
  if (urlUser === undefined && urlPassword === undefined)
@@ -59,7 +69,15 @@ export function mergeUrlCredentials(opts) {
59
69
  u = new URL(base);
60
70
  }
61
71
  catch {
62
- throw new Error('连接 URL 无效,无法注入用户名/密码');
72
+ u = undefined; // WHATWG 解析失败:多主机逗号端口、残缺串等
73
+ }
74
+ const m = base.match(AUTHORITY_RE);
75
+ // 回退条件:解析失败;或 opaque path(host 为空、setter no-op)且 authority 非空。
76
+ // authority 为空(sqlite:// 等无主机形态)不回退,维持 setter no-op 的现状语义。
77
+ if (u === undefined || (u.host === '' && (m?.[2] ?? '') !== '')) {
78
+ if (m === null)
79
+ throw new Error('连接 URL 无效,无法注入用户名/密码');
80
+ return injectUserInfo(base, m, urlUser, urlPassword);
63
81
  }
64
82
  if (urlUser !== undefined && urlUser !== '')
65
83
  u.username = urlUser;
@@ -67,6 +85,35 @@ export function mergeUrlCredentials(opts) {
67
85
  u.password = urlPassword;
68
86
  return u.toString();
69
87
  }
88
+ /** 字符串回退注入:在 authority(m[2])内以最后一个 @ 为界替换/插入 userinfo。
89
+ * 与 WHATWG setter 语义对齐:只填一项只覆盖一项(另一项保留基底值);
90
+ * 两者均无效时不改动原样返回。凭据按 encodeURIComponent 编码(与适配器侧
91
+ * decodeURIComponent 互逆),authority 之外(path/query/fragment)逐字保留。 */
92
+ function injectUserInfo(base, m, urlUser, urlPassword) {
93
+ const whole = m[0] ?? '';
94
+ const authority = m[2] ?? '';
95
+ const user = urlUser !== undefined && urlUser !== '' ? encodeURIComponent(urlUser) : undefined;
96
+ const pass = urlPassword !== undefined && urlPassword !== '' ? encodeURIComponent(urlPassword) : undefined;
97
+ if (user === undefined && pass === undefined)
98
+ return base;
99
+ const at = authority.lastIndexOf('@');
100
+ const oldUserinfo = at >= 0 ? authority.slice(0, at) : '';
101
+ const colon = oldUserinfo.lastIndexOf(':');
102
+ const oldUser = colon >= 0 ? oldUserinfo.slice(0, colon) : oldUserinfo;
103
+ const oldPass = colon >= 0 ? oldUserinfo.slice(colon + 1) : undefined;
104
+ let userinfo;
105
+ if (user !== undefined && pass !== undefined) {
106
+ userinfo = `${user}:${pass}`;
107
+ }
108
+ else if (user !== undefined) {
109
+ userinfo = oldPass !== undefined ? `${user}:${oldPass}` : user;
110
+ }
111
+ else {
112
+ userinfo = `${oldUser}:${pass}`;
113
+ }
114
+ const hostPart = at >= 0 ? authority.slice(at + 1) : authority;
115
+ return whole.slice(0, whole.length - authority.length) + userinfo + '@' + hostPart + base.slice(whole.length);
116
+ }
70
117
  function splitPassword(fields) {
71
118
  if (!fields || typeof fields[PASSWORD_KEY] !== 'string') {
72
119
  return { clean: fields, password: undefined };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "1.5.5",
3
+ "version": "1.5.7",
4
4
  "description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,9 +38,7 @@
38
38
  },
39
39
  "client": {
40
40
  "inject": [
41
- "@deepseek-ai/dsh-client-locale",
42
- "react-dom",
43
- "react-dom/client"
41
+ "@deepseek-ai/dsh-client-locale"
44
42
  ],
45
43
  "platform": "web"
46
44
  }