dsh-db-tool 1.4.0 → 1.5.0
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/README.md +2 -2
- package/client/client.js +277 -73
- package/dist/adapters/mongodb/index.js +22 -5
- package/dist/http/index.js +6 -0
- package/dist/manager.js +12 -3
- package/dist/store/connections.js +59 -6
- package/dist/store/index.js +1 -1
- package/package.json +1 -1
package/client/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
## 文件
|
|
10
10
|
|
|
11
|
-
- `client.js` —
|
|
12
|
-
- 4 个面板:管理(连接 CRUD + 测试 + 审计查看)、授权(项目路径 → 连接 ro/rw
|
|
11
|
+
- `client.js` — 全部前端逻辑(单文件,约 1400 行),内含:
|
|
12
|
+
- 4 个面板:管理(连接 CRUD + 测试 + 审计查看)、授权(项目路径 → 连接 ro/rw/撤销)、浏览(树选库/表 → 点表打开 Navicat 式弹窗:左树 + 结构/数据 Tabs + 分页;portal 挂 body,Esc/遮罩/✕ 关闭)、SQL 控制台(连接/库选择、query/execute/script、危险确认对话框 → challengeId 重发)
|
|
13
13
|
- zh/en 双语词典(zh 默认),经 `ctx.locale.register(NS, ...)` 注册,`useSyncExternalStore` 订阅语言切换
|
|
14
14
|
- 危险确认:`runGuarded()` 先发请求,收到 `code:"NEEDS_CONFIRMATION"` 且有 `challengeId` 时弹 `DangerDialog`,用户确认后携 challengeId 重发(challenge 一次性、绑语句、5 分钟有效)
|
|
15
15
|
- REST API 调用走同源前缀 **`/dsh-db-tool/api`**(须与 host 端 `webServer.register` 的 prefix 一致)
|
package/client/client.js
CHANGED
|
@@ -34,6 +34,8 @@ window.__ModuleLoader__.load({
|
|
|
34
34
|
urlMode: "URL 方式",
|
|
35
35
|
fieldsMode: "分字段方式",
|
|
36
36
|
url: "连接 URL(密码将自动拆入密钥存储)",
|
|
37
|
+
urlUser: "用户名(可选,自动注入 URL)",
|
|
38
|
+
urlPassword: "密码(可选,自动注入 URL;留空保持已存)",
|
|
37
39
|
fieldsHost: "主机地址",
|
|
38
40
|
fieldsPort: "端口",
|
|
39
41
|
fieldsUser: "用户名",
|
|
@@ -83,6 +85,11 @@ window.__ModuleLoader__.load({
|
|
|
83
85
|
prevPage: "上一页",
|
|
84
86
|
pageInfo: "第 {page} 页(每页 50 行)",
|
|
85
87
|
previewTruncated: "结果已截断",
|
|
88
|
+
browseDialogTitle: "{conn} / {db} / {table}",
|
|
89
|
+
close: "关闭",
|
|
90
|
+
emptyStructure: "暂无字段信息",
|
|
91
|
+
emptyData: "暂无数据",
|
|
92
|
+
retry: "重试",
|
|
86
93
|
// SQL 控制台
|
|
87
94
|
modeQuery: "只读查询",
|
|
88
95
|
modeExecute: "写入执行",
|
|
@@ -123,6 +130,8 @@ window.__ModuleLoader__.load({
|
|
|
123
130
|
urlMode: "URL",
|
|
124
131
|
fieldsMode: "Fields",
|
|
125
132
|
url: "Connection URL (password moved to secrets automatically)",
|
|
133
|
+
urlUser: "Username (optional, injected into URL)",
|
|
134
|
+
urlPassword: "Password (optional, injected into URL; blank keeps saved)",
|
|
126
135
|
fieldsHost: "Host",
|
|
127
136
|
fieldsPort: "Port",
|
|
128
137
|
fieldsUser: "User",
|
|
@@ -170,6 +179,11 @@ window.__ModuleLoader__.load({
|
|
|
170
179
|
prevPage: "Prev",
|
|
171
180
|
pageInfo: "Page {page} (50 rows/page)",
|
|
172
181
|
previewTruncated: "Result truncated",
|
|
182
|
+
browseDialogTitle: "{conn} / {db} / {table}",
|
|
183
|
+
close: "Close",
|
|
184
|
+
emptyStructure: "No columns",
|
|
185
|
+
emptyData: "No rows",
|
|
186
|
+
retry: "Retry",
|
|
173
187
|
modeQuery: "query (read-only)",
|
|
174
188
|
modeExecute: "execute (write)",
|
|
175
189
|
modeScript: "script",
|
|
@@ -276,9 +290,11 @@ window.__ModuleLoader__.load({
|
|
|
276
290
|
if (styleEl) return;
|
|
277
291
|
styleEl = document.createElement("style");
|
|
278
292
|
styleEl.textContent = [
|
|
279
|
-
"/* ===== Apple-style Design Tokens(dark 基线,light 经 media query
|
|
280
|
-
".dbt-panel{--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-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;}",
|
|
281
|
-
"@media (prefers-color-scheme: light){.dbt-panel{--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-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);}}",
|
|
293
|
+
"/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转;.dbt-browse-root 并入以承载 portal 弹窗) ===== */",
|
|
294
|
+
".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;}",
|
|
295
|
+
"@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);}}",
|
|
296
|
+
// 弹窗根自持文字色:portal 挂 document.body 后不再依赖侧栏祖先的 color 继承(ocr medium)
|
|
297
|
+
".dbt-browse-root{color:var(--dbt-text);}",
|
|
282
298
|
"/* ===== 根容器 ===== */",
|
|
283
299
|
".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;}",
|
|
284
300
|
"/* ===== iOS segmented control(顶部 tab) ===== */",
|
|
@@ -331,6 +347,25 @@ window.__ModuleLoader__.load({
|
|
|
331
347
|
".dbt-chev.open{transform:rotate(90deg);}",
|
|
332
348
|
".dbt-chev.leaf{visibility:hidden;}",
|
|
333
349
|
".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
350
|
+
/* ===== Navicat 式浏览弹窗 ===== */
|
|
351
|
+
".dbt-browse-dialog{width:92vw;height:88vh;max-width:1400px;max-height:940px;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:14px;box-shadow:var(--dbt-shadow);-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:13px;animation:dbt-in .22s var(--dbt-ease);}",
|
|
352
|
+
".dbt-browse-header{flex:none;display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--dbt-separator);}",
|
|
353
|
+
".dbt-browse-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:13px;}",
|
|
354
|
+
".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);}",
|
|
355
|
+
".dbt-browse-close:hover{background:var(--dbt-surface-strong);color:inherit;}",
|
|
356
|
+
".dbt-browse-close:active{transform:scale(.92);}",
|
|
357
|
+
".dbt-browse-body{flex:1;min-height:0;display:flex;}",
|
|
358
|
+
".dbt-browse-tree{flex:none;width:260px;overflow-y:auto;border-right:1px solid var(--dbt-separator);}",
|
|
359
|
+
".dbt-browse-main{flex:1;min-width:0;display:flex;flex-direction:column;}",
|
|
360
|
+
".dbt-browse-tabs{flex:none;padding:10px 14px 0;}",
|
|
361
|
+
".dbt-browse-content{flex:1;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px;overflow:hidden;}",
|
|
362
|
+
".dbt-browse-tablewrap{flex:1;min-height:0;overflow:auto;border:none;border-radius:var(--dbt-radius-card);background:var(--dbt-surface);}",
|
|
363
|
+
".dbt-browse-footer{flex:none;display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--dbt-separator);}",
|
|
364
|
+
".dbt-browse-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--dbt-text-secondary);font-size:12px;}",
|
|
365
|
+
".dbt-browse-errorbox{flex:1;display:flex;flex-direction:column;gap:10px;align-items:flex-start;justify-content:center;}",
|
|
366
|
+
".dbt-browse-skel{display:flex;flex-direction:column;gap:8px;}",
|
|
367
|
+
".dbt-browse-skelrow{display:flex;gap:8px;}",
|
|
368
|
+
".dbt-browse-skelcell{flex:1;height:24px;background:var(--dbt-surface-strong);border-radius:var(--dbt-radius-pill);}",
|
|
334
369
|
"/* ===== 视图切换入场(仅 transform/opacity;reduced-motion 全局已禁) ===== */",
|
|
335
370
|
"@keyframes dbt-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}",
|
|
336
371
|
".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
|
|
@@ -339,9 +374,9 @@ window.__ModuleLoader__.load({
|
|
|
339
374
|
".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);}",
|
|
340
375
|
".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
341
376
|
"/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
|
|
342
|
-
"@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-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}}",
|
|
343
|
-
"@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#f5f5f7;}.dbt-table th{background:#f2f2f7;}}",
|
|
344
|
-
"@media (prefers-reduced-motion: reduce){.dbt-panel *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
377
|
+
"@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{backdrop-filter:none;-webkit-backdrop-filter:none;background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}}",
|
|
378
|
+
"@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog,.dbt-browse-dialog{background:#f5f5f7;}.dbt-table th{background:#f2f2f7;}}",
|
|
379
|
+
"@media (prefers-reduced-motion: reduce){.dbt-panel *,.dbt-browse-root *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
345
380
|
].join("\n");
|
|
346
381
|
document.head.appendChild(styleEl);
|
|
347
382
|
}
|
|
@@ -374,14 +409,21 @@ window.__ModuleLoader__.load({
|
|
|
374
409
|
);
|
|
375
410
|
}
|
|
376
411
|
|
|
412
|
+
// --- 弹窗挂载:优先 createPortal 挂 document.body(避免侧栏容器裁剪);宿主未提供 react-dom 时回退 Panel 内 fixed overlay(DangerDialog 同模式) ---
|
|
413
|
+
let createPortal = null;
|
|
414
|
+
try { createPortal = require("react-dom").createPortal; } catch (e) { /* 留 null,走回退 */ }
|
|
415
|
+
function mountDialog(children) {
|
|
416
|
+
return createPortal ? createPortal(children, document.body) : children;
|
|
417
|
+
}
|
|
418
|
+
|
|
377
419
|
/* ---------------- 连接管理 ---------------- */
|
|
378
|
-
const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
|
|
420
|
+
const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
|
|
379
421
|
|
|
380
|
-
// 通用表格渲染:columns + rows
|
|
381
|
-
function resultTable(columns, rows, cellTitles) {
|
|
422
|
+
// 通用表格渲染:columns + rows(审计列表与查询结果共用);wrapClass 可选(浏览弹窗用自适应高度容器)
|
|
423
|
+
function resultTable(columns, rows, cellTitles, wrapClass) {
|
|
382
424
|
return React.createElement(
|
|
383
425
|
"div",
|
|
384
|
-
{ className: "dbt-tablewrap" },
|
|
426
|
+
{ className: wrapClass || "dbt-tablewrap" },
|
|
385
427
|
React.createElement(
|
|
386
428
|
"table",
|
|
387
429
|
{ className: "dbt-table" },
|
|
@@ -457,6 +499,7 @@ window.__ModuleLoader__.load({
|
|
|
457
499
|
...EMPTY_FORM,
|
|
458
500
|
id: c.id, kind: c.kind, name: c.name || "", mode,
|
|
459
501
|
url: c.safeUrl || "",
|
|
502
|
+
urlUser: "", urlPassword: "",
|
|
460
503
|
host: c.host || "", port: c.port != null ? String(c.port) : "",
|
|
461
504
|
user: c.user || "", database: c.database || "",
|
|
462
505
|
ssl: !!c.ssl,
|
|
@@ -496,6 +539,11 @@ window.__ModuleLoader__.load({
|
|
|
496
539
|
if (form.url && form.url !== origUrl) body.url = form.url;
|
|
497
540
|
// 新建 url 连接必须发
|
|
498
541
|
if (!props.initial && form.url) body.url = form.url;
|
|
542
|
+
// URL 模式独立凭据:trim 后非空才发(服务端注入 userinfo;url 未发时以已存 URL 为基底)
|
|
543
|
+
const uu = (form.urlUser || "").trim();
|
|
544
|
+
const up = (form.urlPassword || "").trim();
|
|
545
|
+
if (uu) body.urlUser = uu;
|
|
546
|
+
if (up) body.urlPassword = up;
|
|
499
547
|
} else {
|
|
500
548
|
body.fields = { host: form.host, user: form.user, database: form.database || undefined };
|
|
501
549
|
if (form.port) body.fields.port = Number(form.port);
|
|
@@ -541,7 +589,14 @@ window.__ModuleLoader__.load({
|
|
|
541
589
|
React.createElement("button", { className: form.mode === "fields" ? "active" : "", onClick: reenterFields }, t("fieldsMode")),
|
|
542
590
|
),
|
|
543
591
|
form.mode === "url"
|
|
544
|
-
? React.createElement(
|
|
592
|
+
? React.createElement(
|
|
593
|
+
"div",
|
|
594
|
+
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
595
|
+
React.createElement("input", { placeholder: t("url"), value: form.url, onChange: (e) => patch({ url: e.target.value }) }),
|
|
596
|
+
React.createElement("div", { className: "dbt-row" },
|
|
597
|
+
React.createElement("input", { placeholder: t("urlUser"), value: form.urlUser, onChange: (e) => patch({ urlUser: e.target.value }) }),
|
|
598
|
+
React.createElement("input", { type: "password", placeholder: t("urlPassword"), value: form.urlPassword, onChange: (e) => patch({ urlPassword: e.target.value }) })),
|
|
599
|
+
)
|
|
545
600
|
: React.createElement(
|
|
546
601
|
"div",
|
|
547
602
|
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
@@ -734,25 +789,20 @@ window.__ModuleLoader__.load({
|
|
|
734
789
|
}
|
|
735
790
|
|
|
736
791
|
/* ---------------- 数据浏览 ---------------- */
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
792
|
+
// Navicat 式对象树:连接 ▸ 库/[schema] ▸ 表,懒加载展开;面板与弹窗各持一个实例,展开状态互不影响
|
|
793
|
+
function BrowseTree(props) {
|
|
794
|
+
const { conns, projectPath, sel, onSelect } = props;
|
|
795
|
+
// sel 由父组件持有(弹窗内 active 高亮与初始选中都依赖);onSelect(选中记录, 触发元素) 上报
|
|
740
796
|
const [open, setOpen] = React.useState({}); // "c:<id>" | "d:<id>/<db>" -> bool
|
|
741
797
|
const [loading, setLoading] = React.useState({});
|
|
742
798
|
const [error, setError] = React.useState({}); // 树节点加载失败信息(就地显示,可点重试)
|
|
743
799
|
const [dbs, setDbs] = React.useState({}); // connId -> string[]
|
|
744
800
|
const [schemasMap, setSchemasMap] = React.useState({}); // "<connId>/<db>" -> string[](PG/GaussDB 库内 schema 层)
|
|
745
801
|
const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db|schema>" -> TableInfo[]
|
|
746
|
-
const [sel, setSel] = React.useState(null); // {connId, db, table: TableInfo}
|
|
747
|
-
const [schema, setSchema] = React.useState([]);
|
|
748
|
-
const [preview, setPreview] = React.useState(null); // QueryResult
|
|
749
|
-
const [page, setPage] = React.useState(1);
|
|
750
|
-
const [busy, setBusy] = React.useState("");
|
|
751
|
-
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
752
802
|
|
|
753
|
-
//
|
|
803
|
+
// 会话项目切换时清空树缓存,避免陈旧授权下的旧数据
|
|
754
804
|
React.useEffect(() => {
|
|
755
|
-
setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({});
|
|
805
|
+
setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({});
|
|
756
806
|
}, [projectPath]);
|
|
757
807
|
|
|
758
808
|
function toggle(key, load) {
|
|
@@ -798,39 +848,28 @@ window.__ModuleLoader__.load({
|
|
|
798
848
|
setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
|
|
799
849
|
}));
|
|
800
850
|
}
|
|
801
|
-
// 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
|
|
802
|
-
const openSeq = React.useRef(0);
|
|
803
|
-
const openTable = React.useCallback((s, pg) => {
|
|
804
|
-
if (!s || !projectPath) return;
|
|
805
|
-
// PG 系跨库:database 传 "库名.schema";其它库传库名
|
|
806
|
-
const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
|
|
807
|
-
const seq = ++openSeq.current;
|
|
808
|
-
setBusy("open");
|
|
809
|
-
Promise.all([
|
|
810
|
-
api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
|
|
811
|
-
api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
|
|
812
|
-
])
|
|
813
|
-
.then(([sch, prev]) => {
|
|
814
|
-
if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
|
|
815
|
-
setSchema(sch || []); setPreview(prev); setPage(pg || 1);
|
|
816
|
-
})
|
|
817
|
-
.catch((e) => props.onError(e))
|
|
818
|
-
.finally(() => { if (seq === openSeq.current) setBusy(""); });
|
|
819
|
-
}, [projectPath]);
|
|
820
|
-
React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
|
|
821
|
-
|
|
822
851
|
// 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注
|
|
852
|
+
// ponytail: 交互行全量 tabIndex=0 使 Tab 循环较长,升级路径为 APG roving tabindex + 方向键导航
|
|
823
853
|
function treerow(key, level, isOpen, leaf, label, onClick, active, extra) {
|
|
824
854
|
return React.createElement(
|
|
825
855
|
"div",
|
|
826
|
-
{
|
|
856
|
+
{
|
|
857
|
+
className: "dbt-treerow" + (active ? " active" : ""), key,
|
|
858
|
+
role: onClick ? "treeitem" : undefined,
|
|
859
|
+
tabIndex: onClick ? 0 : undefined,
|
|
860
|
+
onClick: onClick || undefined,
|
|
861
|
+
onKeyDown: onClick ? (e) => {
|
|
862
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick({ currentTarget: e.currentTarget }); }
|
|
863
|
+
} : undefined,
|
|
864
|
+
style: { paddingLeft: 12 + level * 16 },
|
|
865
|
+
},
|
|
827
866
|
React.createElement("span", { className: "dbt-chev" + (isOpen ? " open" : "") + (leaf ? " leaf" : "") }, "▶"),
|
|
828
867
|
React.createElement("span", { className: "dbt-treename" }, label),
|
|
829
868
|
extra || null,
|
|
830
869
|
);
|
|
831
870
|
}
|
|
832
871
|
|
|
833
|
-
// 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示
|
|
872
|
+
// 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示 …);表节点点击上报选中记录与触发元素
|
|
834
873
|
const treeRows = [];
|
|
835
874
|
for (const c of conns) {
|
|
836
875
|
const ck = "c:" + c.id;
|
|
@@ -870,7 +909,7 @@ window.__ModuleLoader__.load({
|
|
|
870
909
|
const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
|
|
871
910
|
treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
|
|
872
911
|
tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
|
|
873
|
-
() =>
|
|
912
|
+
(e) => onSelect({ connId: c.id, db: d, schemaName: s, table: tb }, e.currentTarget), active));
|
|
874
913
|
}
|
|
875
914
|
}
|
|
876
915
|
continue;
|
|
@@ -882,7 +921,7 @@ window.__ModuleLoader__.load({
|
|
|
882
921
|
const active = !!sel && sel.connId === c.id && sel.db === d && sel.table.name === tb.name;
|
|
883
922
|
treeRows.push(treerow("t:" + c.id + "/" + d + "/" + tb.name, 2, false, true,
|
|
884
923
|
tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
|
|
885
|
-
() =>
|
|
924
|
+
(e) => onSelect({ connId: c.id, db: d, table: tb }, e.currentTarget), active));
|
|
886
925
|
}
|
|
887
926
|
}
|
|
888
927
|
}
|
|
@@ -890,43 +929,208 @@ window.__ModuleLoader__.load({
|
|
|
890
929
|
return React.createElement(
|
|
891
930
|
"div",
|
|
892
931
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
893
|
-
// Navicat
|
|
932
|
+
// Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件决定打开弹窗)
|
|
894
933
|
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
895
934
|
React.createElement("div", { className: "dbt-group" },
|
|
896
935
|
React.createElement("div", { className: "dbt-tree" }, treeRows)),
|
|
897
|
-
|
|
898
|
-
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 面板视图:精简树 + 弹窗编排(sel / triggerRef / 打开与关闭)
|
|
940
|
+
function BrowseView(props) {
|
|
941
|
+
const { conns, projectPath } = props;
|
|
942
|
+
const [sel, setSel] = React.useState(null); // {connId, db, schemaName?, table: TableInfo}
|
|
943
|
+
// 打开弹窗的树行元素:关闭弹窗后焦点返回触发元素(APG Dialog Modal)
|
|
944
|
+
const triggerRef = React.useRef(null);
|
|
945
|
+
// 项目切换时关闭弹窗并清选中(树缓存由 BrowseTree 自清)
|
|
946
|
+
React.useEffect(() => { setSel(null); }, [projectPath]);
|
|
947
|
+
return React.createElement(
|
|
948
|
+
"div",
|
|
949
|
+
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
950
|
+
React.createElement(BrowseTree, {
|
|
951
|
+
conns, projectPath, sel,
|
|
952
|
+
onSelect: (s, el) => {
|
|
953
|
+
if (el) triggerRef.current = el; // 面板树触发的打开才记录焦点返回目标
|
|
954
|
+
setSel(s);
|
|
955
|
+
},
|
|
956
|
+
}),
|
|
957
|
+
sel ? React.createElement(BrowseDialog, {
|
|
958
|
+
initialSel: sel, conns, projectPath,
|
|
959
|
+
onClose: () => {
|
|
960
|
+
setSel(null);
|
|
961
|
+
// APG:对话框关闭后焦点返回触发元素;面板树行常驻 DOM,防御性检查归属
|
|
962
|
+
const el = triggerRef.current;
|
|
963
|
+
if (el && document.contains(el)) el.focus();
|
|
964
|
+
},
|
|
965
|
+
}) : null,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Navicat 式浏览弹窗:左树选库-表,右看字段+数据(近全屏模态)
|
|
970
|
+
function BrowseDialog(props) {
|
|
971
|
+
const { initialSel, conns, projectPath, onClose } = props;
|
|
972
|
+
const [sel, setSel] = React.useState(initialSel); // 弹窗内当前选中(初始为面板点开的表)
|
|
973
|
+
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
974
|
+
const [schema, setSchema] = React.useState([]);
|
|
975
|
+
const [preview, setPreview] = React.useState(null); // QueryResult
|
|
976
|
+
const [page, setPage] = React.useState(1);
|
|
977
|
+
const [busy, setBusy] = React.useState("");
|
|
978
|
+
const [loadErr, setLoadErr] = React.useState(""); // 右栏加载失败(就地显示 + 重试)
|
|
979
|
+
const openSeq = React.useRef(0); // 请求序号守卫
|
|
980
|
+
const dialogRef = React.useRef(null);
|
|
981
|
+
const closeRef = React.useRef(null);
|
|
982
|
+
|
|
983
|
+
// 请求序号守卫(沿用面板):快速切表/翻页时丢弃晚到旧响应,防止旧数据覆盖新选中项
|
|
984
|
+
const openTable = React.useCallback((s, pg) => {
|
|
985
|
+
if (!s || !projectPath) return;
|
|
986
|
+
// PG 系跨库:database 传 "库名.schema";其它库传库名
|
|
987
|
+
const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
|
|
988
|
+
const seq = ++openSeq.current;
|
|
989
|
+
setBusy("open"); setLoadErr("");
|
|
990
|
+
Promise.all([
|
|
991
|
+
api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
|
|
992
|
+
api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
|
|
993
|
+
])
|
|
994
|
+
.then(([sch, prev]) => {
|
|
995
|
+
if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
|
|
996
|
+
setSchema(sch || []); setPreview(prev); setPage(pg || 1);
|
|
997
|
+
})
|
|
998
|
+
// 弹窗内错误就地显示(不走面板顶部错误区)
|
|
999
|
+
.catch((e) => { if (seq === openSeq.current) setLoadErr(String(e && e.message ? e.message : e)); })
|
|
1000
|
+
.finally(() => { if (seq === openSeq.current) setBusy(""); });
|
|
1001
|
+
}, [projectPath]);
|
|
1002
|
+
React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
|
|
1003
|
+
|
|
1004
|
+
// APG/react.dev:键盘处理统一挂 window 订阅(Esc 关闭 + Tab 焦点陷阱同层,
|
|
1005
|
+
// 避免焦点落在 body 时 Tab 事件不经 dialog 冒泡而逃逸出模态);cleanup 移除
|
|
1006
|
+
React.useEffect(() => {
|
|
1007
|
+
const onKey = (e) => {
|
|
1008
|
+
if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
|
|
1009
|
+
if (e.key === "Tab") trapTab(e);
|
|
1010
|
+
};
|
|
1011
|
+
window.addEventListener("keydown", onKey);
|
|
1012
|
+
// 初始焦点=关闭按钮(APG:对话框内首个交互元素;禁止聚焦 role="dialog" 容器本身)
|
|
1013
|
+
if (closeRef.current) closeRef.current.focus();
|
|
1014
|
+
return () => { window.removeEventListener("keydown", onKey); };
|
|
1015
|
+
}, []); // 依赖 []:onClose 语义恒定(setSel(null)+焦点返回),无陈旧闭包危害
|
|
1016
|
+
|
|
1017
|
+
// 轻量焦点陷阱:Tab 到末元素回首元素,Shift+Tab 反向(APG);焦点在弹窗外时拉回弹窗内
|
|
1018
|
+
function trapTab(e) {
|
|
1019
|
+
const root = dialogRef.current;
|
|
1020
|
+
if (!root) return;
|
|
1021
|
+
const found = root.querySelectorAll('button,input,select,textarea,a[href],[tabindex]:not([tabindex="-1"])');
|
|
1022
|
+
const list = Array.prototype.filter.call(found, (el) => !el.disabled && el.offsetParent !== null);
|
|
1023
|
+
if (list.length === 0) return;
|
|
1024
|
+
const first = list[0], last = list[list.length - 1];
|
|
1025
|
+
const active = document.activeElement;
|
|
1026
|
+
if (e.shiftKey && (active === first || !root.contains(active))) { e.preventDefault(); last.focus(); }
|
|
1027
|
+
else if (!e.shiftKey && (active === last || !root.contains(active))) { e.preventDefault(); first.focus(); }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// 右栏互斥状态:加载骨架 / 错误重试 / 空态 / 表格
|
|
1031
|
+
function rightPane() {
|
|
1032
|
+
if (busy === "open") {
|
|
1033
|
+
// 表形状骨架:6 行 6 列静态色块(不引入新动画,reduced-motion 天然安全)
|
|
1034
|
+
return React.createElement("div", { className: "dbt-browse-skel", role: "status", "aria-busy": "true" },
|
|
1035
|
+
[0, 1, 2, 3, 4, 5].map((r) => React.createElement("div", { className: "dbt-browse-skelrow", key: r },
|
|
1036
|
+
[0, 1, 2, 3, 4, 5].map((c) => React.createElement("div", { className: "dbt-browse-skelcell", key: c })))));
|
|
1037
|
+
}
|
|
1038
|
+
if (loadErr) {
|
|
1039
|
+
// 错误就地显示 + 重试(弹窗遮罩下面板顶部错误区不可见,不走 props.onError)
|
|
1040
|
+
return React.createElement("div", { className: "dbt-browse-errorbox" },
|
|
1041
|
+
React.createElement("div", { className: "dbt-err" }, t("error") + ": " + loadErr),
|
|
1042
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => openTable(sel, 1) }, t("retry")));
|
|
1043
|
+
}
|
|
1044
|
+
if (view === "structure") {
|
|
1045
|
+
if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyStructure"));
|
|
1046
|
+
return resultTable(
|
|
1047
|
+
[t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
|
|
1048
|
+
schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
|
|
1049
|
+
undefined, "dbt-browse-tablewrap");
|
|
1050
|
+
}
|
|
1051
|
+
if (!preview || !preview.rows || preview.rows.length === 0) {
|
|
1052
|
+
return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData"));
|
|
1053
|
+
}
|
|
1054
|
+
return resultTable(preview.columns, preview.rows, undefined, "dbt-browse-tablewrap");
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// 遮罩点击关闭需 mousedown 归属:表格内拖选文本滑出弹窗释放时 click 落在遮罩(公共祖先),
|
|
1058
|
+
// 仅当按下与释放都在遮罩自身才视为「点击遮罩关闭」(双审查报告一致命中)
|
|
1059
|
+
const downOnOverlay = React.useRef(false);
|
|
1060
|
+
return mountDialog(React.createElement(
|
|
1061
|
+
"div",
|
|
1062
|
+
{
|
|
1063
|
+
className: "dbt-overlay dbt-browse-root",
|
|
1064
|
+
onMouseDown: (e) => { downOnOverlay.current = e.target === e.currentTarget; },
|
|
1065
|
+
onClick: (e) => { if (downOnOverlay.current && e.target === e.currentTarget) onClose(); },
|
|
1066
|
+
},
|
|
1067
|
+
React.createElement(
|
|
1068
|
+
"div",
|
|
1069
|
+
{
|
|
1070
|
+
className: "dbt-browse-dialog",
|
|
1071
|
+
ref: dialogRef,
|
|
1072
|
+
role: "dialog", "aria-modal": "true", "aria-labelledby": "dbt-browse-title",
|
|
1073
|
+
onClick: (e) => e.stopPropagation(),
|
|
1074
|
+
},
|
|
1075
|
+
// ---- header:面包屑 + 关闭 ----
|
|
1076
|
+
React.createElement(
|
|
899
1077
|
"div",
|
|
900
|
-
{
|
|
901
|
-
|
|
1078
|
+
{ className: "dbt-browse-header" },
|
|
1079
|
+
React.createElement("div", { className: "dbt-browse-title", id: "dbt-browse-title" },
|
|
1080
|
+
t("browseDialogTitle", {
|
|
1081
|
+
conn: (conns.find((c) => c.id === sel.connId) || {}).name || sel.connId,
|
|
1082
|
+
db: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
|
|
1083
|
+
table: sel.table.name,
|
|
1084
|
+
})),
|
|
1085
|
+
React.createElement(
|
|
1086
|
+
"button",
|
|
1087
|
+
{ className: "dbt-browse-close", ref: closeRef, "aria-label": t("close"), onClick: onClose },
|
|
1088
|
+
React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
|
|
1089
|
+
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" })),
|
|
1090
|
+
),
|
|
1091
|
+
),
|
|
1092
|
+
// ---- body:左树 + 右主区 ----
|
|
1093
|
+
React.createElement(
|
|
1094
|
+
"div",
|
|
1095
|
+
{ className: "dbt-browse-body" },
|
|
902
1096
|
React.createElement(
|
|
903
1097
|
"div",
|
|
904
|
-
{ className: "dbt-
|
|
905
|
-
|
|
906
|
-
|
|
1098
|
+
{ className: "dbt-browse-tree" },
|
|
1099
|
+
React.createElement(BrowseTree, {
|
|
1100
|
+
conns, projectPath, sel,
|
|
1101
|
+
onSelect: (s) => setSel(s), // 弹窗内树切表;触发元素仅面板打开时记录,这里不传 el
|
|
1102
|
+
}),
|
|
907
1103
|
),
|
|
908
|
-
React.createElement(
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
React.Fragment,
|
|
916
|
-
null,
|
|
917
|
-
preview ? resultTable(preview.columns, preview.rows) : null,
|
|
1104
|
+
React.createElement(
|
|
1105
|
+
"div",
|
|
1106
|
+
{ className: "dbt-browse-main" },
|
|
1107
|
+
// 结构/数据:mini segmented(复用面板同款与 i18n key)
|
|
1108
|
+
React.createElement(
|
|
1109
|
+
"div",
|
|
1110
|
+
{ className: "dbt-browse-tabs" },
|
|
918
1111
|
React.createElement(
|
|
919
1112
|
"div",
|
|
920
|
-
{ className: "dbt-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1113
|
+
{ className: "dbt-seg" },
|
|
1114
|
+
["structure", "preview"].map((v) =>
|
|
1115
|
+
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
|
|
1116
|
+
v === "structure" ? t("structure") : t("preview"))),
|
|
924
1117
|
),
|
|
925
|
-
preview && preview.truncated ? React.createElement("div", { className: "dbt-muted" }, t("previewTruncated")) : null,
|
|
926
1118
|
),
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1119
|
+
React.createElement("div", { className: "dbt-browse-content" }, rightPane()),
|
|
1120
|
+
),
|
|
1121
|
+
),
|
|
1122
|
+
// ---- footer:分页条(仅数据预览视图) ----
|
|
1123
|
+
view === "preview" && !loadErr ? React.createElement(
|
|
1124
|
+
"div",
|
|
1125
|
+
{ className: "dbt-browse-footer" },
|
|
1126
|
+
React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
|
|
1127
|
+
React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
|
|
1128
|
+
React.createElement("span", { style: { flex: 1 } }),
|
|
1129
|
+
preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
|
|
1130
|
+
React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
|
|
1131
|
+
) : null,
|
|
1132
|
+
),
|
|
1133
|
+
));
|
|
930
1134
|
}
|
|
931
1135
|
|
|
932
1136
|
/* ---------------- SQL 控制台 ---------------- */
|
|
@@ -166,7 +166,9 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
166
166
|
}
|
|
167
167
|
const dbName = typeof conn.meta.database === 'string' && conn.meta.database !== ''
|
|
168
168
|
? conn.meta.database
|
|
169
|
-
:
|
|
169
|
+
: conn.url
|
|
170
|
+
? urlDbName(conn.url)
|
|
171
|
+
: defaultDbName(conn.fields);
|
|
170
172
|
const db = client.db(dbName);
|
|
171
173
|
function wrap(e) {
|
|
172
174
|
const M = vendorOrNull();
|
|
@@ -524,15 +526,30 @@ function inferBsonType(v) {
|
|
|
524
526
|
return 'object';
|
|
525
527
|
return typeof v;
|
|
526
528
|
}
|
|
527
|
-
function buildUri(fields, ssl) {
|
|
529
|
+
export function buildUri(fields, ssl) {
|
|
528
530
|
const host = typeof fields?.host === 'string' && fields.host !== '' ? fields.host : '127.0.0.1';
|
|
529
531
|
const port = typeof fields?.port === 'number' ? fields.port : 27017;
|
|
530
532
|
const user = typeof fields?.user === 'string' ? encodeURIComponent(fields.user) : '';
|
|
531
533
|
const pass = typeof fields?.password === 'string' ? `:${encodeURIComponent(fields.password)}` : '';
|
|
532
534
|
const auth = user !== '' ? `${user}${pass}@` : '';
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
535
|
+
// tls 用查询参数表达:mongodb+srv 是 DNS seedlist 形态(禁止显式端口),与 TLS 无关,不可混用
|
|
536
|
+
const params = [];
|
|
537
|
+
if (typeof fields?.authDatabase === 'string' && fields.authDatabase !== '') {
|
|
538
|
+
params.push(`authSource=${fields.authDatabase}`);
|
|
539
|
+
}
|
|
540
|
+
if (ssl || fields?.ssl === true || fields?.tls === true)
|
|
541
|
+
params.push('tls=true');
|
|
542
|
+
const qs = params.length > 0 ? `?${params.join('&')}` : '';
|
|
543
|
+
return `mongodb://${auth}${host}:${port}${qs}`;
|
|
544
|
+
}
|
|
545
|
+
/** URL 模式库名:取 URL path 首段(percent 解码),无则 'test' 兜底(与 pg-like mainDb 同口径) */
|
|
546
|
+
function urlDbName(url) {
|
|
547
|
+
try {
|
|
548
|
+
return decodeURIComponent(new URL(url).pathname.replace(/^\//, '')) || 'test';
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
return 'test';
|
|
552
|
+
}
|
|
536
553
|
}
|
|
537
554
|
function defaultDbName(fields) {
|
|
538
555
|
return typeof fields?.database === 'string' && fields.database !== '' ? fields.database : 'test';
|
package/dist/http/index.js
CHANGED
|
@@ -141,6 +141,8 @@ async function route(req, res, service, path, q, opts) {
|
|
|
141
141
|
id, kind: kind,
|
|
142
142
|
...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
|
|
143
143
|
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
144
|
+
...(b['urlUser'] !== undefined ? { urlUser: str(b['urlUser']) } : {}),
|
|
145
|
+
...(b['urlPassword'] !== undefined ? { urlPassword: str(b['urlPassword']) } : {}),
|
|
144
146
|
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
145
147
|
...(ssl !== undefined ? { ssl } : {}),
|
|
146
148
|
}));
|
|
@@ -158,6 +160,8 @@ async function route(req, res, service, path, q, opts) {
|
|
|
158
160
|
return sendMaybeConfirm(res, service.testDraft({
|
|
159
161
|
kind: kind,
|
|
160
162
|
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
163
|
+
...(b['urlUser'] !== undefined ? { urlUser: str(b['urlUser']) } : {}),
|
|
164
|
+
...(b['urlPassword'] !== undefined ? { urlPassword: str(b['urlPassword']) } : {}),
|
|
161
165
|
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
162
166
|
...(ssl !== undefined ? { ssl } : {}),
|
|
163
167
|
// 编辑已有连接时透传:服务端拼回已存机密(留空密码/url 未改动的测试语义)
|
|
@@ -178,6 +182,8 @@ async function route(req, res, service, path, q, opts) {
|
|
|
178
182
|
return sendOk(res, service.updateConnection(id, {
|
|
179
183
|
...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
|
|
180
184
|
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
185
|
+
...(b['urlUser'] !== undefined ? { urlUser: str(b['urlUser']) } : {}),
|
|
186
|
+
...(b['urlPassword'] !== undefined ? { urlPassword: str(b['urlPassword']) } : {}),
|
|
181
187
|
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
182
188
|
...(b['clearUrl'] === true ? { clearUrl: true } : {}),
|
|
183
189
|
...(ssl !== undefined ? { ssl } : {}),
|
package/dist/manager.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getAdapter } from './adapters/index.js';
|
|
2
2
|
import { ChallengeStore, classifyStatement, } from './guard/index.js';
|
|
3
|
-
import { normalizeProjectKey, } from './store/index.js';
|
|
3
|
+
import { mergeUrlCredentials, normalizeProjectKey, } from './store/index.js';
|
|
4
4
|
import { runScriptInChild } from './script/runner.js';
|
|
5
5
|
/* ---------------- 错误与结果类型 ---------------- */
|
|
6
6
|
/** 携带机器可读 code 的业务错误(HTTP 层直接映射 {ok:false,error,code}) */
|
|
@@ -84,15 +84,24 @@ export class DbToolService {
|
|
|
84
84
|
};
|
|
85
85
|
let url = input.url;
|
|
86
86
|
let fields = input.fields !== undefined ? { ...input.fields } : undefined;
|
|
87
|
+
let secretsUrl;
|
|
87
88
|
if (input.connId) {
|
|
88
89
|
// 已存连接:编辑时客户端不发旧机密(留空语义),这里从 secrets 拼回
|
|
89
90
|
const sec = this.store.secrets.get(input.connId);
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
secretsUrl = sec?.url;
|
|
92
|
+
if (url === undefined && secretsUrl !== undefined)
|
|
93
|
+
url = secretsUrl;
|
|
92
94
|
if (fields !== undefined && sec?.password !== undefined && fields.password === undefined) {
|
|
93
95
|
fields.password = sec.password;
|
|
94
96
|
}
|
|
95
97
|
}
|
|
98
|
+
// URL 模式独立凭据注入(拼回已存 URL 之后;无凭据字段时 helper 原样返回,?? 保持不变)
|
|
99
|
+
url = mergeUrlCredentials({
|
|
100
|
+
url,
|
|
101
|
+
urlUser: input.urlUser,
|
|
102
|
+
urlPassword: input.urlPassword,
|
|
103
|
+
secretsUrl,
|
|
104
|
+
}) ?? url;
|
|
96
105
|
if (url !== undefined)
|
|
97
106
|
rc.url = url;
|
|
98
107
|
if (fields !== undefined)
|
|
@@ -28,6 +28,45 @@ export function redactUrl(url) {
|
|
|
28
28
|
const redacted = m[1] + userinfo.slice(0, colon + 1) + '***@' + authority.slice(at + 1);
|
|
29
29
|
return redacted + url.slice(m[0].length);
|
|
30
30
|
}
|
|
31
|
+
/** URL 模式独立凭据注入:把 urlUser/urlPassword 合并进 URL userinfo(8 库 scheme 均为标准 URL 形态)。
|
|
32
|
+
* - 两者均空 → 原样返回(url 可为 undefined,现状语义不变);
|
|
33
|
+
* - url 为 undefined → 有 secretsUrl(编辑场景已存真实 URL)以其为基底注入,否则凭据忽略返回 undefined;
|
|
34
|
+
* - url 含脱敏标记 ':***@'(编辑回填值)→ 有 secretsUrl 以其为基底整体替换,否则报错;
|
|
35
|
+
* - 注入用 WHATWG URL 的 username/password setter(自动 percent-encode '@:/?# ' 等特殊字符),
|
|
36
|
+
* 与适配器侧 decodeURIComponent / 驱动 RFC 3986 解码严格互逆;只填一项只覆盖一项
|
|
37
|
+
* (redis 'redis://:pass@host' 无用户名形态、oracle 必填用户名校验均兼容)。 */
|
|
38
|
+
export function mergeUrlCredentials(opts) {
|
|
39
|
+
const { url, urlUser, urlPassword, secretsUrl } = opts;
|
|
40
|
+
if (urlUser === undefined && urlPassword === undefined)
|
|
41
|
+
return url;
|
|
42
|
+
let base;
|
|
43
|
+
if (url === undefined) {
|
|
44
|
+
if (secretsUrl === undefined)
|
|
45
|
+
return undefined; // 无 URL 可注入,凭据忽略
|
|
46
|
+
base = secretsUrl;
|
|
47
|
+
}
|
|
48
|
+
else if (url.includes(':***@')) {
|
|
49
|
+
if (secretsUrl === undefined) {
|
|
50
|
+
throw new Error('URL 中含脱敏占位(***)且无已存真实 URL 可回退,请重新输入完整 URL 或清空用户名/密码框');
|
|
51
|
+
}
|
|
52
|
+
base = secretsUrl;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
base = url;
|
|
56
|
+
}
|
|
57
|
+
let u;
|
|
58
|
+
try {
|
|
59
|
+
u = new URL(base);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error('连接 URL 无效,无法注入用户名/密码');
|
|
63
|
+
}
|
|
64
|
+
if (urlUser !== undefined && urlUser !== '')
|
|
65
|
+
u.username = urlUser;
|
|
66
|
+
if (urlPassword !== undefined && urlPassword !== '')
|
|
67
|
+
u.password = urlPassword;
|
|
68
|
+
return u.toString();
|
|
69
|
+
}
|
|
31
70
|
function splitPassword(fields) {
|
|
32
71
|
if (!fields || typeof fields[PASSWORD_KEY] !== 'string') {
|
|
33
72
|
return { clean: fields, password: undefined };
|
|
@@ -107,9 +146,15 @@ export class ConnectionStore {
|
|
|
107
146
|
if (clean !== undefined)
|
|
108
147
|
rec.fields = clean;
|
|
109
148
|
const secretPatch = {};
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
// URL 模式独立凭据注入(无凭据字段时 mergeUrlCredentials 原样返回,现状语义不变)
|
|
150
|
+
const finalUrl = mergeUrlCredentials({
|
|
151
|
+
url: input.url,
|
|
152
|
+
urlUser: input.urlUser,
|
|
153
|
+
urlPassword: input.urlPassword,
|
|
154
|
+
});
|
|
155
|
+
if (finalUrl !== undefined) {
|
|
156
|
+
rec.urlSafe = redactUrl(finalUrl);
|
|
157
|
+
secretPatch.url = finalUrl;
|
|
113
158
|
}
|
|
114
159
|
if (password !== undefined)
|
|
115
160
|
secretPatch.password = password;
|
|
@@ -135,9 +180,17 @@ export class ConnectionStore {
|
|
|
135
180
|
if (password !== undefined)
|
|
136
181
|
this.secrets.set(id, { password });
|
|
137
182
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
183
|
+
// URL 模式独立凭据注入:url 未发/发脱敏回填值时以已存 secrets.url 为基底(update 场景);
|
|
184
|
+
// 无 url 且无凭据字段时 mergeUrlCredentials 返回 undefined,自然跳过(无需外层守卫)
|
|
185
|
+
const finalUrl = mergeUrlCredentials({
|
|
186
|
+
url: patch.url,
|
|
187
|
+
urlUser: patch.urlUser,
|
|
188
|
+
urlPassword: patch.urlPassword,
|
|
189
|
+
secretsUrl: this.secrets.get(id)?.url,
|
|
190
|
+
});
|
|
191
|
+
if (finalUrl !== undefined) {
|
|
192
|
+
rec.urlSafe = redactUrl(finalUrl);
|
|
193
|
+
this.secrets.set(id, { url: finalUrl });
|
|
141
194
|
}
|
|
142
195
|
if (patch.clearUrl && rec.urlSafe !== undefined) {
|
|
143
196
|
delete rec.urlSafe;
|
package/dist/store/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { GrantStore } from './grants.js';
|
|
|
18
18
|
import { chmodBestEffort } from './io.js';
|
|
19
19
|
import { SecretsBox } from './secrets.js';
|
|
20
20
|
export { normalizeProjectKey } from './normalize.js';
|
|
21
|
-
export { redactUrl } from './connections.js';
|
|
21
|
+
export { mergeUrlCredentials, redactUrl } from './connections.js';
|
|
22
22
|
export { AuditLog, ConnectionStore, GrantStore, SecretsBox };
|
|
23
23
|
export class DbToolStore {
|
|
24
24
|
/** 数据目录:$DSH_HOME/db-tool */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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",
|