dsh-db-tool 1.4.1 → 1.5.1
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 +645 -73
- package/dist/adapters/mongodb/index.js +36 -7
- 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
|
@@ -14,7 +14,9 @@ window.__ModuleLoader__.load({
|
|
|
14
14
|
* ============================================================ */
|
|
15
15
|
|
|
16
16
|
// --- i18n(dsh-better-sidebar 同款模式)---
|
|
17
|
-
|
|
17
|
+
// i18n 字典/HTTP 封装/CSS/对话框均无单元断言,仅变异下方纯函数层(Stryker 区段指令需精确匹配,不得带后缀文本)
|
|
18
|
+
// Stryker disable all
|
|
19
|
+
const LOCALE_NS = "dbTool";
|
|
18
20
|
const zh = {
|
|
19
21
|
tabTitle: "数据库",
|
|
20
22
|
projectLabel: "项目: {path}",
|
|
@@ -85,6 +87,27 @@ window.__ModuleLoader__.load({
|
|
|
85
87
|
prevPage: "上一页",
|
|
86
88
|
pageInfo: "第 {page} 页(每页 50 行)",
|
|
87
89
|
previewTruncated: "结果已截断",
|
|
90
|
+
browseDialogTitle: "{conn} / {db} / {table}",
|
|
91
|
+
close: "关闭",
|
|
92
|
+
emptyStructure: "暂无字段信息",
|
|
93
|
+
emptyData: "暂无数据",
|
|
94
|
+
retry: "重试",
|
|
95
|
+
browsePanelHint: "数据浏览已在浮动窗口打开,可继续操作其他功能。",
|
|
96
|
+
reopenBrowse: "打开数据浏览",
|
|
97
|
+
selectTableHint: "在左侧选择表后查看数据",
|
|
98
|
+
// 单元格查看/编辑(Navicat 式就地写回)
|
|
99
|
+
editCell: "编辑",
|
|
100
|
+
cellValue: "完整值",
|
|
101
|
+
copyBtn: "复制",
|
|
102
|
+
copied: "已复制",
|
|
103
|
+
setNull: "设为 NULL",
|
|
104
|
+
cellSaving: "保存中…",
|
|
105
|
+
readOnlyRo: "只读授权,不可编辑",
|
|
106
|
+
noPkHint: "该表无主键,不支持就地编辑",
|
|
107
|
+
truncatedNoEdit: "值已截断显示,就地编辑可能覆盖数据,请改用 SQL 控制台",
|
|
108
|
+
blobNoEdit: "二进制值不支持就地编辑",
|
|
109
|
+
redisTypeRo: "Redis 该类型不支持就地编辑",
|
|
110
|
+
cellSaved: "已保存",
|
|
88
111
|
// SQL 控制台
|
|
89
112
|
modeQuery: "只读查询",
|
|
90
113
|
modeExecute: "写入执行",
|
|
@@ -174,6 +197,27 @@ window.__ModuleLoader__.load({
|
|
|
174
197
|
prevPage: "Prev",
|
|
175
198
|
pageInfo: "Page {page} (50 rows/page)",
|
|
176
199
|
previewTruncated: "Result truncated",
|
|
200
|
+
browseDialogTitle: "{conn} / {db} / {table}",
|
|
201
|
+
close: "Close",
|
|
202
|
+
emptyStructure: "No columns",
|
|
203
|
+
emptyData: "No rows",
|
|
204
|
+
retry: "Retry",
|
|
205
|
+
browsePanelHint: "Browse is open in a floating window; you can keep working here.",
|
|
206
|
+
reopenBrowse: "Open Data Browser",
|
|
207
|
+
selectTableHint: "Select a table on the left to view data",
|
|
208
|
+
// Cell view/edit (Navicat-style in-place write-back)
|
|
209
|
+
editCell: "Edit",
|
|
210
|
+
cellValue: "Full value",
|
|
211
|
+
copyBtn: "Copy",
|
|
212
|
+
copied: "Copied",
|
|
213
|
+
setNull: "Set NULL",
|
|
214
|
+
cellSaving: "Saving…",
|
|
215
|
+
readOnlyRo: "Read-only grant; editing disabled",
|
|
216
|
+
noPkHint: "No primary key; in-place editing unavailable",
|
|
217
|
+
truncatedNoEdit: "Value shown truncated; edit via SQL Console instead",
|
|
218
|
+
blobNoEdit: "Binary values cannot be edited here",
|
|
219
|
+
redisTypeRo: "This Redis type cannot be edited here",
|
|
220
|
+
cellSaved: "Saved",
|
|
177
221
|
modeQuery: "query (read-only)",
|
|
178
222
|
modeExecute: "execute (write)",
|
|
179
223
|
modeScript: "script",
|
|
@@ -280,9 +324,11 @@ window.__ModuleLoader__.load({
|
|
|
280
324
|
if (styleEl) return;
|
|
281
325
|
styleEl = document.createElement("style");
|
|
282
326
|
styleEl.textContent = [
|
|
283
|
-
"/* ===== Apple-style Design Tokens(dark 基线,light 经 media query
|
|
284
|
-
".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;}",
|
|
285
|
-
"@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);}}",
|
|
327
|
+
"/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转;.dbt-browse-root 并入以承载 portal 弹窗) ===== */",
|
|
328
|
+
".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;}",
|
|
329
|
+
"@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);}}",
|
|
330
|
+
// 弹窗根自持文字色:portal 挂 document.body 后不再依赖侧栏祖先的 color 继承(ocr medium)
|
|
331
|
+
".dbt-browse-root{color:var(--dbt-text);}",
|
|
286
332
|
"/* ===== 根容器 ===== */",
|
|
287
333
|
".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;}",
|
|
288
334
|
"/* ===== iOS segmented control(顶部 tab) ===== */",
|
|
@@ -315,6 +361,12 @@ window.__ModuleLoader__.load({
|
|
|
315
361
|
".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;}",
|
|
316
362
|
".dbt-table td{font-family:var(--dbt-mono);}",
|
|
317
363
|
".dbt-tablewrap{border:none;border-radius:var(--dbt-radius-card);overflow:auto;max-height:320px;background:var(--dbt-surface);}",
|
|
364
|
+
"/* ===== 浏览弹窗表格:列分明(列间 hairline + 表头加深一档 + 行 hover) ===== */",
|
|
365
|
+
".dbt-browse-root .dbt-table th + th,.dbt-browse-root .dbt-table td + td{border-left:1px solid var(--dbt-separator);}",
|
|
366
|
+
".dbt-browse-root .dbt-table th{background:rgba(30,30,32,.85);font-size:12px;}",
|
|
367
|
+
".dbt-browse-root .dbt-table td{font-size:13px;padding:7px 10px;}",
|
|
368
|
+
".dbt-browse-root .dbt-table tbody tr:hover td{background:var(--dbt-surface-strong);}",
|
|
369
|
+
"@media (prefers-color-scheme: light){.dbt-browse-root .dbt-table th{background:rgba(242,242,247,.9);}}",
|
|
318
370
|
"/* ===== 对话框(macOS alert 材质) ===== */",
|
|
319
371
|
".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;}",
|
|
320
372
|
".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);}",
|
|
@@ -327,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
327
379
|
".dbt-listrow:hover{background:var(--dbt-surface-strong);}",
|
|
328
380
|
".dbt-tree{display:flex;flex-direction:column;}",
|
|
329
381
|
"/* ===== Navicat 式对象树(数据浏览) ===== */",
|
|
330
|
-
".dbt-treerow{display:flex;gap:6px;align-items:center;padding:
|
|
382
|
+
".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);}",
|
|
331
383
|
".dbt-treerow:last-child{border-bottom:none;}",
|
|
332
384
|
".dbt-treerow:hover{background:var(--dbt-surface-strong);}",
|
|
333
385
|
".dbt-treerow.active{color:var(--dbt-accent);font-weight:600;}",
|
|
@@ -335,6 +387,37 @@ window.__ModuleLoader__.load({
|
|
|
335
387
|
".dbt-chev.open{transform:rotate(90deg);}",
|
|
336
388
|
".dbt-chev.leaf{visibility:hidden;}",
|
|
337
389
|
".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
390
|
+
/* ===== Navicat 式浏览弹窗 ===== */
|
|
391
|
+
".dbt-browse-dialog{position:fixed;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);}",
|
|
392
|
+
".dbt-browse-dialog.dragging{user-select:none;}",
|
|
393
|
+
".dbt-browse-header{flex:none;display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--dbt-separator);}",
|
|
394
|
+
".dbt-browse-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:17px;}",
|
|
395
|
+
".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);}",
|
|
396
|
+
".dbt-browse-close:hover{background:var(--dbt-surface-strong);color:inherit;}",
|
|
397
|
+
".dbt-browse-close:active{transform:scale(.92);}",
|
|
398
|
+
".dbt-browse-body{flex:1;min-height:0;display:flex;}",
|
|
399
|
+
".dbt-browse-tree{flex:none;width:260px;overflow-y:auto;border-right:1px solid var(--dbt-separator);}",
|
|
400
|
+
".dbt-browse-main{flex:1;min-width:0;display:flex;flex-direction:column;}",
|
|
401
|
+
".dbt-browse-tabs{flex:none;padding:10px 14px 0;}",
|
|
402
|
+
".dbt-browse-content{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;gap:10px;padding:12px 14px;overflow:hidden;}",
|
|
403
|
+
".dbt-browse-tablewrap{flex:1;min-height:0;overflow:auto;border:none;border-radius:var(--dbt-radius-card);background:var(--dbt-surface);}",
|
|
404
|
+
".dbt-browse-footer{flex:none;display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--dbt-separator);}",
|
|
405
|
+
".dbt-browse-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--dbt-text-secondary);font-size:13px;}",
|
|
406
|
+
".dbt-browse-errorbox{flex:1;display:flex;flex-direction:column;gap:10px;align-items:flex-start;justify-content:center;}",
|
|
407
|
+
".dbt-browse-skel{display:flex;flex-direction:column;gap:8px;}",
|
|
408
|
+
".dbt-browse-skelrow{display:flex;gap:8px;}",
|
|
409
|
+
".dbt-browse-skelcell{flex:1;height:24px;background:var(--dbt-surface-strong);border-radius:var(--dbt-radius-pill);}",
|
|
410
|
+
"/* ===== 浏览弹窗:单元格查看/编辑浮层(定位锚点为 .dbt-browse-content) ===== */",
|
|
411
|
+
".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;}",
|
|
412
|
+
".dbt-cellbtn:focus-visible{outline:2px solid var(--dbt-accent);outline-offset:-1px;border-radius:2px;}",
|
|
413
|
+
".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;}",
|
|
414
|
+
".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;}",
|
|
415
|
+
".dbt-cellpop-actions{display:flex;gap:8px;align-items:center;}",
|
|
416
|
+
".dbt-celledit{display:flex;flex-direction:column;gap:8px;}",
|
|
417
|
+
".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;}",
|
|
418
|
+
".dbt-celledit textarea:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
|
|
419
|
+
".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;}",
|
|
420
|
+
".dbt-readhint{color:var(--dbt-text-secondary);font-size:11px;}",
|
|
338
421
|
"/* ===== 视图切换入场(仅 transform/opacity;reduced-motion 全局已禁) ===== */",
|
|
339
422
|
"@keyframes dbt-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}",
|
|
340
423
|
".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
|
|
@@ -342,10 +425,11 @@ window.__ModuleLoader__.load({
|
|
|
342
425
|
".dbt-seg{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
|
|
343
426
|
".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);}",
|
|
344
427
|
".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
428
|
+
".dbt-browse-root .dbt-seg button{font-size:12px;padding:4px 12px;}",
|
|
345
429
|
"/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
|
|
346
|
-
"@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;}}",
|
|
347
|
-
"@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#f5f5f7;}.dbt-table th{background:#f2f2f7;}}",
|
|
348
|
-
"@media (prefers-reduced-motion: reduce){.dbt-panel *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
430
|
+
"@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;}.dbt-dialog,.dbt-browse-dialog{background:#2c2c2e;}.dbt-cellpop{background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}.dbt-browse-root .dbt-table th{background:#1e1e20;}}",
|
|
431
|
+
"@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;}}",
|
|
432
|
+
"@media (prefers-reduced-motion: reduce){.dbt-panel *,.dbt-browse-root *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
349
433
|
].join("\n");
|
|
350
434
|
document.head.appendChild(styleEl);
|
|
351
435
|
}
|
|
@@ -378,14 +462,21 @@ window.__ModuleLoader__.load({
|
|
|
378
462
|
);
|
|
379
463
|
}
|
|
380
464
|
|
|
465
|
+
// --- 弹窗挂载:优先 createPortal 挂 document.body(避免侧栏容器裁剪);宿主未提供 react-dom 时回退 Panel 内 fixed overlay(DangerDialog 同模式) ---
|
|
466
|
+
let createPortal = null;
|
|
467
|
+
try { createPortal = require("react-dom").createPortal; } catch (e) { /* 留 null,走回退 */ }
|
|
468
|
+
function mountDialog(children) {
|
|
469
|
+
return createPortal ? createPortal(children, document.body) : children;
|
|
470
|
+
}
|
|
471
|
+
|
|
381
472
|
/* ---------------- 连接管理 ---------------- */
|
|
382
473
|
const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
|
|
383
474
|
|
|
384
|
-
// 通用表格渲染:columns + rows
|
|
385
|
-
function resultTable(columns, rows, cellTitles) {
|
|
475
|
+
// 通用表格渲染:columns + rows(审计列表与查询结果共用);wrapClass 可选(浏览弹窗用自适应高度容器)
|
|
476
|
+
function resultTable(columns, rows, cellTitles, wrapClass) {
|
|
386
477
|
return React.createElement(
|
|
387
478
|
"div",
|
|
388
|
-
{ className: "dbt-tablewrap" },
|
|
479
|
+
{ className: wrapClass || "dbt-tablewrap" },
|
|
389
480
|
React.createElement(
|
|
390
481
|
"table",
|
|
391
482
|
{ className: "dbt-table" },
|
|
@@ -409,6 +500,136 @@ window.__ModuleLoader__.load({
|
|
|
409
500
|
);
|
|
410
501
|
}
|
|
411
502
|
|
|
503
|
+
/* ---------------- 单元格写回:方言与命令构造(纯函数层,PreviewGrid 与测试共用) ---------------- */
|
|
504
|
+
// Stryker restore all(以下至 buildMongoOp 为变异目标区)
|
|
505
|
+
// 统一返回形状:{ ok:true, ops:[{statement, params?, database?}] }(多条顺序执行,如 redis 两步)
|
|
506
|
+
// 或 { ok:false, error:"<i18n key>" }(error 为 key 名,组件侧 t() 本地化;null 表示通用错误)
|
|
507
|
+
|
|
508
|
+
// 方言判定:q=问号占位符(mysql/sqlite)、dollar=$n(postgresql/gaussdb)、colon=:n(oracle/dmdb)
|
|
509
|
+
function dialectOf(kind) {
|
|
510
|
+
if (kind === "mysql" || kind === "sqlite") return "q";
|
|
511
|
+
if (kind === "postgresql" || kind === "gaussdb") return "dollar";
|
|
512
|
+
if (kind === "oracle" || kind === "dmdb") return "colon";
|
|
513
|
+
if (kind === "redis") return "redis";
|
|
514
|
+
if (kind === "mongodb") return "mongo";
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 标识符引用:mysql 用反引号(内部双写转义),其余统一双引号(内部双写转义)
|
|
519
|
+
function quoteIdent(kind, name) {
|
|
520
|
+
const q = kind === "mysql" ? "`" : "\"";
|
|
521
|
+
return q + String(name).split(q).join(q + q) + q;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 单元格文本 → 写回值:JSON 可解析且结果非字符串才用解析值(类型不漂移:显示 "123" 保持字符串,
|
|
525
|
+
// 数字列显示 123 → parse 得 number 123)
|
|
526
|
+
function parseCellText(text) {
|
|
527
|
+
try {
|
|
528
|
+
const v = JSON.parse(text);
|
|
529
|
+
if (typeof v !== "string") return v;
|
|
530
|
+
} catch (e) { /* 非 JSON 按原字符串 */ }
|
|
531
|
+
return text;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 截断/BLOB 标记(后端 normalize 产物):命中即禁止就地编辑,防旧值覆盖真实数据
|
|
535
|
+
function isTruncatedCell(text) {
|
|
536
|
+
const s = String(text);
|
|
537
|
+
// sql-shared 嵌套 JSON 以 "…[已截断]" 结尾;redis/mongo/oracle/dmdb 的 "…[截断,共N字符]"
|
|
538
|
+
// 结尾是"字符]",endsWith 永不命中,故该形态用 includes 检测
|
|
539
|
+
return s.endsWith("…[已截断]") || s.includes("…[截断,共");
|
|
540
|
+
}
|
|
541
|
+
function isBlobCell(text) {
|
|
542
|
+
const s = String(text);
|
|
543
|
+
return s.startsWith("[BLOB ") && s.endsWith("bytes]");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// SQL 6 库 UPDATE 构造:全限定表名 + 方言占位符 + 主键 WHERE;params 顺序 = [新值(非 NULL 时), ...pkValues]
|
|
547
|
+
function buildUpdate(kind, tableName, dbRef, schemaName, colName, newRawText, isNull, pkCols, pkValues) {
|
|
548
|
+
if (!Array.isArray(pkCols) || pkCols.length === 0 || !colName || !tableName) {
|
|
549
|
+
return { ok: false, error: "noPkHint" };
|
|
550
|
+
}
|
|
551
|
+
// 表全限定:pg/gauss 用 schema 前缀(库由 execute 的 database 参数路由,语句内不写库名);
|
|
552
|
+
// mysql 带库名(adapter 无 database 路由);oracle/dmdb 的 dbRef 即 OWNER;sqlite 仅表名
|
|
553
|
+
let table;
|
|
554
|
+
if (kind === "postgresql" || kind === "gaussdb") {
|
|
555
|
+
table = schemaName
|
|
556
|
+
? quoteIdent(kind, schemaName) + "." + quoteIdent(kind, tableName)
|
|
557
|
+
: quoteIdent(kind, tableName);
|
|
558
|
+
} else if (kind === "mysql" || kind === "oracle" || kind === "dmdb") {
|
|
559
|
+
table = quoteIdent(kind, dbRef) + "." + quoteIdent(kind, tableName);
|
|
560
|
+
} else {
|
|
561
|
+
table = quoteIdent(kind, tableName);
|
|
562
|
+
}
|
|
563
|
+
const dialect = dialectOf(kind);
|
|
564
|
+
// 占位符编号按 params 顺序:新值(非 NULL 时占 1 号)→ 主键逐列
|
|
565
|
+
const params = [];
|
|
566
|
+
const ph = (i) => (dialect === "dollar" ? "$" + i : dialect === "colon" ? ":" + i : "?");
|
|
567
|
+
let setSql;
|
|
568
|
+
if (isNull) {
|
|
569
|
+
setSql = quoteIdent(kind, colName) + " = NULL";
|
|
570
|
+
} else {
|
|
571
|
+
params.push(parseCellText(newRawText));
|
|
572
|
+
setSql = quoteIdent(kind, colName) + " = " + ph(params.length);
|
|
573
|
+
}
|
|
574
|
+
const wheres = pkCols.map((c, i) => quoteIdent(kind, c) + " = " + ph((isNull ? 0 : 1) + i + 1));
|
|
575
|
+
for (const v of (pkValues || [])) params.push(v);
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
ops: [{
|
|
579
|
+
statement: "UPDATE " + table + " SET " + setSql + " WHERE " + wheres.join(" AND "),
|
|
580
|
+
params,
|
|
581
|
+
database: kind === "sqlite" ? undefined : dbRef,
|
|
582
|
+
}],
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Redis 写命令构造(命令数组 JSON 化后经 execute 透传):仅数据位(value/score/member)可改,
|
|
587
|
+
// 键名/字段名只读;list/stream 与 NULL 写入不支持
|
|
588
|
+
function buildRedisOp(tableName, tableType, columns, rowValues, colName, newRawText, isNull) {
|
|
589
|
+
if (tableType === "list" || tableType === "stream" || isNull) {
|
|
590
|
+
return { ok: false, error: "redisTypeRo" };
|
|
591
|
+
}
|
|
592
|
+
const valueOf = (col) => rowValues[columns.indexOf(col)];
|
|
593
|
+
const one = (args) => ({ ok: true, ops: [{ statement: JSON.stringify(args) }] });
|
|
594
|
+
const two = (a, b) => ({ ok: true, ops: [{ statement: JSON.stringify(a) }, { statement: JSON.stringify(b) }] });
|
|
595
|
+
if (tableType === "string") {
|
|
596
|
+
if (colName === "value") return one(["SET", tableName, newRawText]);
|
|
597
|
+
if (colName === "key") return { ok: false, error: "readOnlyRo" };
|
|
598
|
+
} else if (tableType === "hash") {
|
|
599
|
+
if (colName === "value") return one(["HSET", tableName, valueOf("field"), newRawText]);
|
|
600
|
+
if (colName === "field") return { ok: false, error: "readOnlyRo" };
|
|
601
|
+
} else if (tableType === "zset") {
|
|
602
|
+
if (colName === "score") {
|
|
603
|
+
const score = Number(newRawText);
|
|
604
|
+
if (!Number.isFinite(score)) return { ok: false, error: null };
|
|
605
|
+
return one(["ZADD", tableName, score, valueOf("member")]);
|
|
606
|
+
}
|
|
607
|
+
if (colName === "member") {
|
|
608
|
+
// member 是有序集身份值:两步「删旧加新」,score 保持原值
|
|
609
|
+
return two(["ZREM", tableName, valueOf("member")], ["ZADD", tableName, valueOf("score"), newRawText]);
|
|
610
|
+
}
|
|
611
|
+
} else if (tableType === "set") {
|
|
612
|
+
if (colName === "member") return two(["SREM", tableName, valueOf("member")], ["SADD", tableName, newRawText]);
|
|
613
|
+
}
|
|
614
|
+
return { ok: false, error: "redisTypeRo" };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// MongoDB updateOne 命令文档构造:$set 单字段;filter 的 _id 按主键类型用 $oid 表达,
|
|
618
|
+
// date 列字符串值包 $date(Extended JSON 由服务端 reviveEjson 复活为 BSON 类型)
|
|
619
|
+
function buildMongoOp(tableName, idValue, idIsObjectId, colName, newRawText, isNull, colDataType) {
|
|
620
|
+
if (colName === "_id") return { ok: false, error: "readOnlyRo" };
|
|
621
|
+
let value = isNull ? null : parseCellText(newRawText);
|
|
622
|
+
if (!isNull && typeof value === "string" && typeof colDataType === "string" && colDataType.startsWith("date")) {
|
|
623
|
+
value = { "$date": value };
|
|
624
|
+
}
|
|
625
|
+
const filter = idIsObjectId ? { _id: { "$oid": idValue } } : { _id: idValue };
|
|
626
|
+
return {
|
|
627
|
+
ok: true,
|
|
628
|
+
ops: [{ statement: JSON.stringify({ updateOne: tableName, filter, update: { "$set": { [colName]: value } } }) }],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Stryker disable all(buildMongoOp 之后:以下 auditTable/KIND_DEFAULTS/buildEditForm 为连接表单工具,无单元断言)
|
|
412
633
|
function auditTable(audit) {
|
|
413
634
|
return resultTable(
|
|
414
635
|
[t("auditTime"), t("auditAction"), t("auditConn"), t("auditDetail")],
|
|
@@ -468,7 +689,9 @@ window.__ModuleLoader__.load({
|
|
|
468
689
|
};
|
|
469
690
|
}
|
|
470
691
|
|
|
471
|
-
|
|
692
|
+
// UI 组件渲染层无单元断言(纯函数层止于此),禁用至文件尾
|
|
693
|
+
// Stryker disable all
|
|
694
|
+
function ConnForm(props) {
|
|
472
695
|
// 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
|
|
473
696
|
const [form, setForm] = React.useState(() => props.initial || freshForm());
|
|
474
697
|
const [dirty, setDirty] = React.useState(() => new Set()); // 用户手改过的字段(切 kind 时保留)
|
|
@@ -751,25 +974,20 @@ window.__ModuleLoader__.load({
|
|
|
751
974
|
}
|
|
752
975
|
|
|
753
976
|
/* ---------------- 数据浏览 ---------------- */
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
977
|
+
// Navicat 式对象树:连接 ▸ 库/[schema] ▸ 表,懒加载展开(弹窗唯一实例,展开状态随弹窗生命周期)
|
|
978
|
+
function BrowseTree(props) {
|
|
979
|
+
const { conns, projectPath, sel, onSelect, autoExpand } = props;
|
|
980
|
+
// sel 由父组件持有(弹窗内 active 高亮与初始选中都依赖);onSelect(选中记录, 触发元素) 上报
|
|
757
981
|
const [open, setOpen] = React.useState({}); // "c:<id>" | "d:<id>/<db>" -> bool
|
|
758
982
|
const [loading, setLoading] = React.useState({});
|
|
759
983
|
const [error, setError] = React.useState({}); // 树节点加载失败信息(就地显示,可点重试)
|
|
760
984
|
const [dbs, setDbs] = React.useState({}); // connId -> string[]
|
|
761
985
|
const [schemasMap, setSchemasMap] = React.useState({}); // "<connId>/<db>" -> string[](PG/GaussDB 库内 schema 层)
|
|
762
986
|
const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db|schema>" -> TableInfo[]
|
|
763
|
-
const [sel, setSel] = React.useState(null); // {connId, db, table: TableInfo}
|
|
764
|
-
const [schema, setSchema] = React.useState([]);
|
|
765
|
-
const [preview, setPreview] = React.useState(null); // QueryResult
|
|
766
|
-
const [page, setPage] = React.useState(1);
|
|
767
|
-
const [busy, setBusy] = React.useState("");
|
|
768
|
-
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
769
987
|
|
|
770
|
-
//
|
|
988
|
+
// 会话项目切换时清空树缓存,避免陈旧授权下的旧数据
|
|
771
989
|
React.useEffect(() => {
|
|
772
|
-
setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({});
|
|
990
|
+
setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({});
|
|
773
991
|
}, [projectPath]);
|
|
774
992
|
|
|
775
993
|
function toggle(key, load) {
|
|
@@ -795,6 +1013,14 @@ window.__ModuleLoader__.load({
|
|
|
795
1013
|
setDbs((m) => Object.assign({}, m, { [c.id]: list || [] }));
|
|
796
1014
|
}));
|
|
797
1015
|
}
|
|
1016
|
+
// autoExpand:挂载后自动展开第一层级(连接节点),复用 toggleConn 的展开+懒加载;
|
|
1017
|
+
// 加载失败仍走既有就地错误行机制。声明在清缓存 effect 之后:项目切换时先清 open 再重新展开
|
|
1018
|
+
React.useEffect(() => {
|
|
1019
|
+
if (!autoExpand) return;
|
|
1020
|
+
for (const c of conns) {
|
|
1021
|
+
if (!open["c:" + c.id]) toggleConn(c);
|
|
1022
|
+
}
|
|
1023
|
+
}, [conns, projectPath]); // eslint-disable-line
|
|
798
1024
|
// PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
|
|
799
1025
|
const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
|
|
800
1026
|
function toggleDb(c, d) {
|
|
@@ -815,39 +1041,28 @@ window.__ModuleLoader__.load({
|
|
|
815
1041
|
setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
|
|
816
1042
|
}));
|
|
817
1043
|
}
|
|
818
|
-
// 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
|
|
819
|
-
const openSeq = React.useRef(0);
|
|
820
|
-
const openTable = React.useCallback((s, pg) => {
|
|
821
|
-
if (!s || !projectPath) return;
|
|
822
|
-
// PG 系跨库:database 传 "库名.schema";其它库传库名
|
|
823
|
-
const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
|
|
824
|
-
const seq = ++openSeq.current;
|
|
825
|
-
setBusy("open");
|
|
826
|
-
Promise.all([
|
|
827
|
-
api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
|
|
828
|
-
api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
|
|
829
|
-
])
|
|
830
|
-
.then(([sch, prev]) => {
|
|
831
|
-
if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
|
|
832
|
-
setSchema(sch || []); setPreview(prev); setPage(pg || 1);
|
|
833
|
-
})
|
|
834
|
-
.catch((e) => props.onError(e))
|
|
835
|
-
.finally(() => { if (seq === openSeq.current) setBusy(""); });
|
|
836
|
-
}, [projectPath]);
|
|
837
|
-
React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
|
|
838
|
-
|
|
839
1044
|
// 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注
|
|
1045
|
+
// ponytail: 交互行全量 tabIndex=0 使 Tab 循环较长,升级路径为 APG roving tabindex + 方向键导航
|
|
840
1046
|
function treerow(key, level, isOpen, leaf, label, onClick, active, extra) {
|
|
841
1047
|
return React.createElement(
|
|
842
1048
|
"div",
|
|
843
|
-
{
|
|
1049
|
+
{
|
|
1050
|
+
className: "dbt-treerow" + (active ? " active" : ""), key,
|
|
1051
|
+
role: onClick ? "treeitem" : undefined,
|
|
1052
|
+
tabIndex: onClick ? 0 : undefined,
|
|
1053
|
+
onClick: onClick || undefined,
|
|
1054
|
+
onKeyDown: onClick ? (e) => {
|
|
1055
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick({ currentTarget: e.currentTarget }); }
|
|
1056
|
+
} : undefined,
|
|
1057
|
+
style: { paddingLeft: 12 + level * 16 },
|
|
1058
|
+
},
|
|
844
1059
|
React.createElement("span", { className: "dbt-chev" + (isOpen ? " open" : "") + (leaf ? " leaf" : "") }, "▶"),
|
|
845
1060
|
React.createElement("span", { className: "dbt-treename" }, label),
|
|
846
1061
|
extra || null,
|
|
847
1062
|
);
|
|
848
1063
|
}
|
|
849
1064
|
|
|
850
|
-
// 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示
|
|
1065
|
+
// 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示 …);表节点点击上报选中记录与触发元素
|
|
851
1066
|
const treeRows = [];
|
|
852
1067
|
for (const c of conns) {
|
|
853
1068
|
const ck = "c:" + c.id;
|
|
@@ -887,7 +1102,7 @@ window.__ModuleLoader__.load({
|
|
|
887
1102
|
const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
|
|
888
1103
|
treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
|
|
889
1104
|
tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
|
|
890
|
-
() =>
|
|
1105
|
+
(e) => onSelect({ connId: c.id, db: d, schemaName: s, table: tb }, e.currentTarget), active));
|
|
891
1106
|
}
|
|
892
1107
|
}
|
|
893
1108
|
continue;
|
|
@@ -899,7 +1114,7 @@ window.__ModuleLoader__.load({
|
|
|
899
1114
|
const active = !!sel && sel.connId === c.id && sel.db === d && sel.table.name === tb.name;
|
|
900
1115
|
treeRows.push(treerow("t:" + c.id + "/" + d + "/" + tb.name, 2, false, true,
|
|
901
1116
|
tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
|
|
902
|
-
() =>
|
|
1117
|
+
(e) => onSelect({ connId: c.id, db: d, table: tb }, e.currentTarget), active));
|
|
903
1118
|
}
|
|
904
1119
|
}
|
|
905
1120
|
}
|
|
@@ -907,42 +1122,374 @@ window.__ModuleLoader__.load({
|
|
|
907
1122
|
return React.createElement(
|
|
908
1123
|
"div",
|
|
909
1124
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
910
|
-
// Navicat
|
|
1125
|
+
// Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件决定打开弹窗)
|
|
911
1126
|
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
912
1127
|
React.createElement("div", { className: "dbt-group" },
|
|
913
1128
|
React.createElement("div", { className: "dbt-tree" }, treeRows)),
|
|
914
|
-
|
|
915
|
-
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Navicat 式浏览弹窗:左树选库-表,右看字段+数据(非模态可拖动浮动窗,由 Panel 层持有跨 tab 常驻)
|
|
1133
|
+
|
|
1134
|
+
/* ---------------- PreviewGrid:数据预览网格(Navicat 式单元格查看/编辑) ---------------- */
|
|
1135
|
+
// props 契约:{ preview:{columns,rows,rowCount,truncated?}, schema:ColumnInfo[](SQL/mongo;redis 为
|
|
1136
|
+
// type/encoding/ttl/length,无 PRI), kind, editable:rw 授权布尔, tableType:sel.table.type(redis 键类型),
|
|
1137
|
+
// tableName, dbRef, schemaName, connId, projectPath, onSaved:保存成功回调, askConfirm:runGuarded 确认回调 }
|
|
1138
|
+
// redis 各键类型可就地编辑的数据列(与 buildRedisOp 分支保持一致;键名/字段名等定位列只读)
|
|
1139
|
+
const REDIS_EDITABLE_COLS = {
|
|
1140
|
+
string: ["value"],
|
|
1141
|
+
hash: ["value"],
|
|
1142
|
+
zset: ["score", "member"],
|
|
1143
|
+
set: ["member"],
|
|
1144
|
+
};
|
|
1145
|
+
function PreviewGrid(props) {
|
|
1146
|
+
const { preview, schema, kind, editable, tableType, tableName, dbRef, schemaName, connId, projectPath, onSaved, askConfirm } = props;
|
|
1147
|
+
const [pop, setPop] = React.useState(null); // {rowIdx, colName, cell} 单元格浮层
|
|
1148
|
+
const [editing, setEditing] = React.useState(false);
|
|
1149
|
+
const [text, setText] = React.useState(""); // 编辑态 textarea 值(进入浮层时就位)
|
|
1150
|
+
const [editNull, setEditNull] = React.useState(false); // 「设为 NULL」勾选(null 单元格默认勾选)
|
|
1151
|
+
const [cellErr, setCellErr] = React.useState("");
|
|
1152
|
+
const [busy, setBusy] = React.useState(false);
|
|
1153
|
+
const [copied, setCopied] = React.useState(false);
|
|
1154
|
+
const isMongo = kind === "mongodb";
|
|
1155
|
+
// SQL 系主键列名(schema 中 key==="PRI");mongo 由 _id 单列承担,redis 无主键概念
|
|
1156
|
+
const pkCols = (!isMongo && kind !== "redis" && Array.isArray(schema))
|
|
1157
|
+
? schema.filter((c) => c && c.key === "PRI").map((c) => c.name)
|
|
1158
|
+
: [];
|
|
1159
|
+
|
|
1160
|
+
// 打开浮层(查看态):编辑态初值一并就位——textarea 空 ↔ 勾选设为 NULL
|
|
1161
|
+
function openCell(rowIdx, colName, cell) {
|
|
1162
|
+
setPop({ rowIdx, colName, cell });
|
|
1163
|
+
setEditing(false);
|
|
1164
|
+
setCellErr("");
|
|
1165
|
+
setEditNull(cell === null);
|
|
1166
|
+
setText(cell === null ? "" : String(cell));
|
|
1167
|
+
}
|
|
1168
|
+
function copyCell() {
|
|
1169
|
+
if (!pop) return;
|
|
1170
|
+
navigator.clipboard.writeText(String(pop.cell)).then(() => {
|
|
1171
|
+
setCopied(true);
|
|
1172
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
1173
|
+
}, () => { /* 剪贴板不可用(无权限等):静默,按钮文案不变 */ });
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// 单元格编辑资格:返回 null=可编辑,否则为原因 i18n key(浮层 .dbt-readhint 展示)
|
|
1177
|
+
function cellReason(colName, shown) {
|
|
1178
|
+
if (!editable || !dialectOf(kind)) return "readOnlyRo";
|
|
1179
|
+
if (isMongo) return colName === "_id" ? "readOnlyRo" : null;
|
|
1180
|
+
if (kind === "redis") {
|
|
1181
|
+
if ((tableType === "string" && colName === "key") || (tableType === "hash" && colName === "field")) return "readOnlyRo";
|
|
1182
|
+
return (REDIS_EDITABLE_COLS[tableType] || []).includes(colName) ? null : "redisTypeRo";
|
|
1183
|
+
}
|
|
1184
|
+
// SQL 系:整表无主键 → 只读;截断/BLOB 单元格 → 防旧值覆盖
|
|
1185
|
+
if (pkCols.length === 0) return "noPkHint";
|
|
1186
|
+
if (isTruncatedCell(shown)) return "truncatedNoEdit";
|
|
1187
|
+
if (isBlobCell(shown)) return "blobNoEdit";
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 保存:构造写回命令(按 kind 分派)→ 顺序执行(redis 两步)→ 全部成功关浮层并回调 onSaved
|
|
1192
|
+
async function saveCell() {
|
|
1193
|
+
if (!pop) return;
|
|
1194
|
+
setCellErr("");
|
|
1195
|
+
const { rowIdx, colName } = pop;
|
|
1196
|
+
const row = (preview.rows || [])[rowIdx] || [];
|
|
1197
|
+
let r;
|
|
1198
|
+
if (kind === "redis") {
|
|
1199
|
+
r = buildRedisOp(tableName, tableType, preview.columns, row, colName, text, editNull);
|
|
1200
|
+
} else if (isMongo) {
|
|
1201
|
+
const idIdx = preview.columns.indexOf("_id");
|
|
1202
|
+
const idCol = (schema || []).find((c) => c && c.name === "_id");
|
|
1203
|
+
const colInfo = (schema || []).find((c) => c && c.name === colName);
|
|
1204
|
+
r = buildMongoOp(tableName, idIdx >= 0 ? row[idIdx] : undefined,
|
|
1205
|
+
!!(idCol && typeof idCol.dataType === "string" && idCol.dataType.startsWith("ObjectId")),
|
|
1206
|
+
colName, text, editNull, colInfo ? colInfo.dataType : undefined);
|
|
1207
|
+
} else {
|
|
1208
|
+
r = buildUpdate(kind, tableName, dbRef, schemaName, colName, text, editNull, pkCols,
|
|
1209
|
+
pkCols.map((c) => row[preview.columns.indexOf(c)]));
|
|
1210
|
+
}
|
|
1211
|
+
if (!r.ok) { setCellErr(r.error ? t(r.error) : t("error")); return; }
|
|
1212
|
+
setBusy(true);
|
|
1213
|
+
try {
|
|
1214
|
+
for (const op of r.ops) {
|
|
1215
|
+
await runGuarded(
|
|
1216
|
+
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: op.statement, params: op.params, database: op.database, challengeId } }),
|
|
1217
|
+
askConfirm,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
setPop(null);
|
|
1221
|
+
if (onSaved) onSaved();
|
|
1222
|
+
} catch (e) {
|
|
1223
|
+
// 取消确认(e.cancelled)不算失败:浮层保持打开供重试
|
|
1224
|
+
if (!e.cancelled) setCellErr(t("error") + ": " + String(e && e.message ? e.message : e));
|
|
1225
|
+
} finally {
|
|
1226
|
+
setBusy(false);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const popReason = pop ? cellReason(pop.colName, pop.cell === null ? "NULL" : String(pop.cell)) : null;
|
|
1231
|
+
return React.createElement(
|
|
1232
|
+
"div",
|
|
1233
|
+
{ className: "dbt-browse-root", style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0 } },
|
|
1234
|
+
// 顶层只读提示:授权为 ro 时整格只读,单元格仍可点击查看完整值
|
|
1235
|
+
!editable ? React.createElement("div", { className: "dbt-readhint" }, t("readOnlyRo")) : null,
|
|
1236
|
+
// relative 容器承载单元格浮层(简化定位:水平居中贴底)
|
|
1237
|
+
React.createElement(
|
|
1238
|
+
"div",
|
|
1239
|
+
{ style: { position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" } },
|
|
1240
|
+
React.createElement(
|
|
916
1241
|
"div",
|
|
917
|
-
{
|
|
918
|
-
|
|
1242
|
+
{ className: "dbt-browse-tablewrap" },
|
|
1243
|
+
React.createElement(
|
|
1244
|
+
"table",
|
|
1245
|
+
{ className: "dbt-table" },
|
|
1246
|
+
React.createElement(
|
|
1247
|
+
"thead",
|
|
1248
|
+
null,
|
|
1249
|
+
React.createElement("tr", null, (preview.columns || []).map((c, i) => React.createElement("th", { key: i }, c))),
|
|
1250
|
+
),
|
|
1251
|
+
React.createElement(
|
|
1252
|
+
"tbody",
|
|
1253
|
+
null,
|
|
1254
|
+
(preview.rows || []).map((row, i) =>
|
|
1255
|
+
React.createElement("tr", { key: i },
|
|
1256
|
+
(preview.columns || []).map((colName, j) =>
|
|
1257
|
+
React.createElement("td", { key: j, title: row[j] === null ? "NULL" : String(row[j]) },
|
|
1258
|
+
React.createElement("button", {
|
|
1259
|
+
className: "dbt-cellbtn", type: "button",
|
|
1260
|
+
onClick: () => openCell(i, colName, row[j]),
|
|
1261
|
+
}, row[j] === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : String(row[j]))))),
|
|
1262
|
+
),
|
|
1263
|
+
),
|
|
1264
|
+
),
|
|
1265
|
+
pop ? React.createElement(
|
|
1266
|
+
"div",
|
|
1267
|
+
{ className: "dbt-cellpop", style: { position: "absolute", left: "50%", transform: "translateX(-50%)", bottom: 12 } },
|
|
1268
|
+
React.createElement("strong", null, t("cellValue") + " · " + pop.colName),
|
|
919
1269
|
React.createElement(
|
|
920
1270
|
"div",
|
|
921
|
-
{ className: "dbt-
|
|
922
|
-
|
|
923
|
-
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
|
|
1271
|
+
{ className: "dbt-cellpop-value" },
|
|
1272
|
+
pop.cell === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : String(pop.cell),
|
|
924
1273
|
),
|
|
925
|
-
React.createElement("
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1274
|
+
popReason ? React.createElement("div", { className: "dbt-readhint" }, t(popReason)) : null,
|
|
1275
|
+
cellErr ? React.createElement("div", { className: "dbt-err" }, cellErr) : null,
|
|
1276
|
+
editing
|
|
1277
|
+
? React.createElement(
|
|
1278
|
+
"div",
|
|
1279
|
+
{ className: "dbt-celledit" },
|
|
1280
|
+
React.createElement("textarea", { value: text, onChange: (e) => setText(e.target.value) }),
|
|
1281
|
+
React.createElement(
|
|
1282
|
+
"label",
|
|
1283
|
+
// 不用 .dbt-row:全局 input flex:1 会把 checkbox 拉伸占满、标签被推到远端(视觉审查命中)
|
|
1284
|
+
{ style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "nowrap", width: "fit-content" } },
|
|
1285
|
+
React.createElement("input", { type: "checkbox", style: { flex: "none", minWidth: 0, width: "auto" }, checked: editNull, onChange: (e) => setEditNull(e.target.checked) }),
|
|
1286
|
+
t("setNull"),
|
|
1287
|
+
),
|
|
1288
|
+
React.createElement(
|
|
1289
|
+
"div",
|
|
1290
|
+
{ className: "dbt-row" },
|
|
1291
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: busy, onClick: saveCell }, busy ? t("cellSaving") : t("save")),
|
|
1292
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy, onClick: () => setEditing(false) }, t("cancel")),
|
|
1293
|
+
),
|
|
930
1294
|
)
|
|
931
1295
|
: React.createElement(
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1296
|
+
"div",
|
|
1297
|
+
{ className: "dbt-cellpop-actions" },
|
|
1298
|
+
React.createElement("button", { className: "dbt-btn", onClick: copyCell }, copied ? t("copied") : t("copyBtn")),
|
|
1299
|
+
popReason === null ? React.createElement("button", { className: "dbt-btn primary", onClick: () => setEditing(true) }, t("editCell")) : null,
|
|
1300
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => setPop(null) }, t("close")),
|
|
1301
|
+
),
|
|
1302
|
+
) : null,
|
|
1303
|
+
),
|
|
1304
|
+
),
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function BrowseDialog(props) {
|
|
1309
|
+
const { conns, projectPath, grants, askConfirm, onClose } = props;
|
|
1310
|
+
// grants / askConfirm:预留给后续单元格编辑的危险操作确认通道,本任务先接住不使用
|
|
1311
|
+
const [sel, setSel] = React.useState(null); // 弹窗内当前选中(null=未选表,右栏显示引导空态)
|
|
1312
|
+
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
1313
|
+
const [schema, setSchema] = React.useState([]);
|
|
1314
|
+
const [preview, setPreview] = React.useState(null); // QueryResult
|
|
1315
|
+
const [page, setPage] = React.useState(1);
|
|
1316
|
+
const [busy, setBusy] = React.useState("");
|
|
1317
|
+
const [loadErr, setLoadErr] = React.useState(""); // 右栏加载失败(就地显示 + 重试)
|
|
1318
|
+
const openSeq = React.useRef(0); // 请求序号守卫
|
|
1319
|
+
const closeRef = React.useRef(null);
|
|
1320
|
+
|
|
1321
|
+
// 请求序号守卫(沿用面板):快速切表/翻页时丢弃晚到旧响应,防止旧数据覆盖新选中项
|
|
1322
|
+
const openTable = React.useCallback((s, pg) => {
|
|
1323
|
+
if (!s || !projectPath) return;
|
|
1324
|
+
// PG 系跨库:database 传 "库名.schema";其它库传库名
|
|
1325
|
+
const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
|
|
1326
|
+
const seq = ++openSeq.current;
|
|
1327
|
+
setBusy("open"); setLoadErr("");
|
|
1328
|
+
Promise.all([
|
|
1329
|
+
api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
|
|
1330
|
+
api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
|
|
1331
|
+
])
|
|
1332
|
+
.then(([sch, prev]) => {
|
|
1333
|
+
if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
|
|
1334
|
+
setSchema(sch || []); setPreview(prev); setPage(pg || 1);
|
|
1335
|
+
})
|
|
1336
|
+
// 弹窗内错误就地显示(不走面板顶部错误区)
|
|
1337
|
+
.catch((e) => { if (seq === openSeq.current) setLoadErr(String(e && e.message ? e.message : e)); })
|
|
1338
|
+
.finally(() => { if (seq === openSeq.current) setBusy(""); });
|
|
1339
|
+
}, [projectPath]);
|
|
1340
|
+
React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
|
|
1341
|
+
|
|
1342
|
+
// 非模态浮窗:无焦点陷阱(焦点可自由离开,主页交互不受阻);Esc 仅在焦点位于弹窗内时
|
|
1343
|
+
// 经容器 onKeyDown 触发(stopPropagation 防止宿主级快捷键同时响应)。初始焦点=关闭按钮
|
|
1344
|
+
React.useEffect(() => {
|
|
1345
|
+
if (closeRef.current) closeRef.current.focus();
|
|
1346
|
+
}, []);
|
|
1347
|
+
|
|
1348
|
+
// 拖动定位:初始停靠视口右上(宽度按 1100 常量估算,.dbt-browse-dialog 为 max-width:1400 的自适应宽)
|
|
1349
|
+
const [pos, setPos] = React.useState(() => ({ top: 72, left: Math.max(12, (window.innerWidth || 1200) - 1100 - 24) }));
|
|
1350
|
+
const draggingRef = React.useRef(null); // {dx, dy}
|
|
1351
|
+
const [dragging, setDragging] = React.useState(false);
|
|
1352
|
+
function stopDrag() { draggingRef.current = null; setDragging(false); }
|
|
1353
|
+
function onHeaderPointerDown(e) {
|
|
1354
|
+
// header 内可点元素(关闭按钮)不启动拖动,否则 setPointerCapture 会把 click 重定向到 header 使按钮失效
|
|
1355
|
+
if (e.target.closest && e.target.closest("button")) return;
|
|
1356
|
+
draggingRef.current = { dx: e.clientX - pos.left, dy: e.clientY - pos.top };
|
|
1357
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
1358
|
+
setDragging(true);
|
|
1359
|
+
}
|
|
1360
|
+
function onHeaderPointerMove(e) {
|
|
1361
|
+
if (!draggingRef.current) return;
|
|
1362
|
+
const h = window.innerHeight || 800, w = window.innerWidth || 1200;
|
|
1363
|
+
// 垂直 clamp 用浮窗实际高度:整体保持在视口内(视觉审查:底部分页栏不得溢出被裁);
|
|
1364
|
+
// 水平放宽到至少留 120px 可抓回(允许左右拖出一半)
|
|
1365
|
+
const dlgH = (e.currentTarget.parentElement && e.currentTarget.parentElement.offsetHeight) || 720;
|
|
1366
|
+
setPos({
|
|
1367
|
+
top: Math.min(Math.max(e.clientY - draggingRef.current.dy, 0), Math.max(0, h - dlgH)),
|
|
1368
|
+
left: Math.min(Math.max(e.clientX - draggingRef.current.dx, -(1100 - 120)), w - 120),
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// 右栏互斥状态:未选中引导 / 加载骨架 / 错误重试 / 空态 / 表格
|
|
1373
|
+
function rightPane() {
|
|
1374
|
+
if (!sel && busy !== "open" && !loadErr) {
|
|
1375
|
+
// 未选表:显示引导空态(浏览窗口可直接打开,不强制先选表)
|
|
1376
|
+
return React.createElement("div", { className: "dbt-browse-empty" }, t("selectTableHint"));
|
|
1377
|
+
}
|
|
1378
|
+
if (busy === "open") {
|
|
1379
|
+
// 表形状骨架:6 行 6 列静态色块(不引入新动画,reduced-motion 天然安全)
|
|
1380
|
+
return React.createElement("div", { className: "dbt-browse-skel", role: "status", "aria-busy": "true" },
|
|
1381
|
+
[0, 1, 2, 3, 4, 5].map((r) => React.createElement("div", { className: "dbt-browse-skelrow", key: r },
|
|
1382
|
+
[0, 1, 2, 3, 4, 5].map((c) => React.createElement("div", { className: "dbt-browse-skelcell", key: c })))));
|
|
1383
|
+
}
|
|
1384
|
+
if (loadErr) {
|
|
1385
|
+
// 错误就地显示 + 重试(弹窗遮罩下面板顶部错误区不可见,不走 props.onError)
|
|
1386
|
+
return React.createElement("div", { className: "dbt-browse-errorbox" },
|
|
1387
|
+
React.createElement("div", { className: "dbt-err" }, t("error") + ": " + loadErr),
|
|
1388
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => openTable(sel, 1) }, t("retry")));
|
|
1389
|
+
}
|
|
1390
|
+
if (view === "structure") {
|
|
1391
|
+
if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyStructure"));
|
|
1392
|
+
return resultTable(
|
|
1393
|
+
[t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
|
|
1394
|
+
schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
|
|
1395
|
+
undefined, "dbt-browse-tablewrap");
|
|
1396
|
+
}
|
|
1397
|
+
if (!preview || !preview.rows || preview.rows.length === 0) {
|
|
1398
|
+
return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData"));
|
|
1399
|
+
}
|
|
1400
|
+
// Navicat 式单元格网格:kind/可编辑性由当前连接与授权决定(rw=可编辑),保存后重拉当前页
|
|
1401
|
+
const conn = conns.find((c) => c.id === sel.connId);
|
|
1402
|
+
const grant = (grants || []).find((g) => g.connId === sel.connId);
|
|
1403
|
+
return React.createElement(PreviewGrid, {
|
|
1404
|
+
preview, schema,
|
|
1405
|
+
kind: conn ? conn.kind : undefined,
|
|
1406
|
+
editable: !!grant && grant.mode === "rw",
|
|
1407
|
+
tableType: sel.table.type,
|
|
1408
|
+
tableName: sel.table.name,
|
|
1409
|
+
dbRef: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
|
|
1410
|
+
schemaName: sel.schemaName || null,
|
|
1411
|
+
connId: sel.connId, projectPath,
|
|
1412
|
+
onSaved: () => openTable(sel, page),
|
|
1413
|
+
askConfirm,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// 非模态浮窗:无遮罩、fixed 定位、可拖动;portal 到 body 不受侧栏视图切换影响
|
|
1418
|
+
return mountDialog(React.createElement(
|
|
1419
|
+
"div",
|
|
1420
|
+
{
|
|
1421
|
+
className: "dbt-browse-root dbt-browse-dialog" + (dragging ? " dragging" : ""),
|
|
1422
|
+
style: { position: "fixed", top: pos.top, left: pos.left },
|
|
1423
|
+
role: "dialog", "aria-labelledby": "dbt-browse-title",
|
|
1424
|
+
onKeyDown: (e) => {
|
|
1425
|
+
if (e.key === "Escape") { e.stopPropagation(); onClose(); }
|
|
1426
|
+
},
|
|
1427
|
+
},
|
|
1428
|
+
// ---- header:面包屑 + 关闭(整条 header 可拖动)----
|
|
1429
|
+
React.createElement(
|
|
1430
|
+
"div",
|
|
1431
|
+
{
|
|
1432
|
+
className: "dbt-browse-header",
|
|
1433
|
+
style: { cursor: dragging ? "grabbing" : "grab" },
|
|
1434
|
+
onPointerDown: onHeaderPointerDown,
|
|
1435
|
+
onPointerMove: onHeaderPointerMove,
|
|
1436
|
+
onPointerUp: stopDrag,
|
|
1437
|
+
onPointerCancel: stopDrag,
|
|
1438
|
+
},
|
|
1439
|
+
React.createElement("div", { className: "dbt-browse-title", id: "dbt-browse-title" },
|
|
1440
|
+
!sel ? t("viewBrowse") : t("browseDialogTitle", {
|
|
1441
|
+
conn: (conns.find((c) => c.id === sel.connId) || {}).name || sel.connId,
|
|
1442
|
+
db: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
|
|
1443
|
+
table: sel.table.name,
|
|
1444
|
+
})),
|
|
1445
|
+
React.createElement(
|
|
1446
|
+
"button",
|
|
1447
|
+
{ className: "dbt-browse-close", ref: closeRef, "aria-label": t("close"), onClick: onClose },
|
|
1448
|
+
React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
|
|
1449
|
+
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" })),
|
|
1450
|
+
),
|
|
1451
|
+
),
|
|
1452
|
+
// ---- body:左树 + 右主区 ----
|
|
1453
|
+
React.createElement(
|
|
1454
|
+
"div",
|
|
1455
|
+
{ className: "dbt-browse-body" },
|
|
1456
|
+
React.createElement(
|
|
1457
|
+
"div",
|
|
1458
|
+
{ className: "dbt-browse-tree" },
|
|
1459
|
+
React.createElement(BrowseTree, {
|
|
1460
|
+
conns, projectPath, sel, autoExpand: true, // 本任务后弹窗树是唯一树实例,挂载即展开第一层级
|
|
1461
|
+
onSelect: (s) => setSel(s), // 弹窗内树切表
|
|
1462
|
+
}),
|
|
1463
|
+
),
|
|
1464
|
+
React.createElement(
|
|
1465
|
+
"div",
|
|
1466
|
+
{ className: "dbt-browse-main" },
|
|
1467
|
+
// 结构/数据:mini segmented(复用面板同款与 i18n key)
|
|
1468
|
+
React.createElement(
|
|
1469
|
+
"div",
|
|
1470
|
+
{ className: "dbt-browse-tabs" },
|
|
935
1471
|
React.createElement(
|
|
936
1472
|
"div",
|
|
937
|
-
{ className: "dbt-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1473
|
+
{ className: "dbt-seg" },
|
|
1474
|
+
["structure", "preview"].map((v) =>
|
|
1475
|
+
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
|
|
1476
|
+
v === "structure" ? t("structure") : t("preview"))),
|
|
941
1477
|
),
|
|
942
|
-
preview && preview.truncated ? React.createElement("div", { className: "dbt-muted" }, t("previewTruncated")) : null,
|
|
943
1478
|
),
|
|
944
|
-
|
|
945
|
-
|
|
1479
|
+
React.createElement("div", { className: "dbt-browse-content" }, rightPane()),
|
|
1480
|
+
),
|
|
1481
|
+
),
|
|
1482
|
+
// ---- footer:分页条(仅已选表的数据预览视图;空选中态不显示) ----
|
|
1483
|
+
sel && view === "preview" && !loadErr ? React.createElement(
|
|
1484
|
+
"div",
|
|
1485
|
+
{ className: "dbt-browse-footer" },
|
|
1486
|
+
React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
|
|
1487
|
+
React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
|
|
1488
|
+
React.createElement("span", { style: { flex: 1 } }),
|
|
1489
|
+
preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
|
|
1490
|
+
React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
|
|
1491
|
+
) : null,
|
|
1492
|
+
),
|
|
946
1493
|
);
|
|
947
1494
|
}
|
|
948
1495
|
|
|
@@ -1072,6 +1619,8 @@ window.__ModuleLoader__.load({
|
|
|
1072
1619
|
const [error, setError] = React.useState("");
|
|
1073
1620
|
const [message, setMessage] = React.useState("");
|
|
1074
1621
|
const [confirmReq, setConfirmReq] = React.useState(null); // {statement,danger,reason,resolve}
|
|
1622
|
+
// 数据浏览浮窗开关:由 Panel 层持有(不随 view 切换卸载),跨 tab 常驻
|
|
1623
|
+
const [browseOpen, setBrowseOpen] = React.useState(false);
|
|
1075
1624
|
|
|
1076
1625
|
const askConfirm = React.useCallback((info) => new Promise((resolve) => {
|
|
1077
1626
|
setConfirmReq(Object.assign({}, info, { resolve }));
|
|
@@ -1126,7 +1675,11 @@ window.__ModuleLoader__.load({
|
|
|
1126
1675
|
"div",
|
|
1127
1676
|
{ className: "dbt-tabs" },
|
|
1128
1677
|
[["manage", t("viewManage")], ["grants", t("viewGrants")], ["browse", t("viewBrowse")], ["console", t("viewConsole")]].map(([v, label]) =>
|
|
1129
|
-
|
|
1678
|
+
// browse tab:切视图的同时直接打开浮窗(单击即用,不必再进树点表)
|
|
1679
|
+
React.createElement("button", {
|
|
1680
|
+
key: v, className: view === v ? "active" : "",
|
|
1681
|
+
onClick: () => { setView(v); if (v === "browse") setBrowseOpen(true); },
|
|
1682
|
+
}, label)),
|
|
1130
1683
|
),
|
|
1131
1684
|
(projectEdited || !projectPath)
|
|
1132
1685
|
? React.createElement(
|
|
@@ -1161,11 +1714,24 @@ window.__ModuleLoader__.load({
|
|
|
1161
1714
|
React.createElement("div", { className: "dbt-view", key: view },
|
|
1162
1715
|
view === "manage" ? React.createElement(ManageView, shared) : null,
|
|
1163
1716
|
view === "grants" ? React.createElement(GrantsView, shared) : null,
|
|
1164
|
-
|
|
1717
|
+
// browse:提示卡(浮窗由 Panel 层在下方常驻渲染,不随视图切换卸载)
|
|
1718
|
+
view === "browse" ? React.createElement(
|
|
1719
|
+
"div",
|
|
1720
|
+
{ className: "dbt-card" },
|
|
1721
|
+
// 主提示用正常文字色(dbt-muted 11px 次要色压深底对比不足,视觉审查命中)
|
|
1722
|
+
React.createElement("div", { style: { fontSize: 13 } }, t("browsePanelHint")),
|
|
1723
|
+
React.createElement("div", { className: "dbt-row" },
|
|
1724
|
+
React.createElement("button", { className: "dbt-btn primary", onClick: () => setBrowseOpen(true) }, t("reopenBrowse"))),
|
|
1725
|
+
) : null,
|
|
1165
1726
|
view === "console" ? React.createElement(ConsoleView, Object.assign({}, shared, { askConfirm })) : null,
|
|
1166
1727
|
),
|
|
1167
1728
|
React.createElement("div", { className: "dbt-muted" }, t("footerHint")),
|
|
1168
1729
|
confirmReq ? React.createElement(DangerDialog, { challenge: confirmReq, onClose: () => setConfirmReq(null) }) : null,
|
|
1730
|
+
// 浮窗常驻:portal 到 body 且在 dbt-view 外,切 tab 时不消失
|
|
1731
|
+
browseOpen ? React.createElement(BrowseDialog, {
|
|
1732
|
+
conns, projectPath, grants, askConfirm,
|
|
1733
|
+
onClose: () => setBrowseOpen(false),
|
|
1734
|
+
}) : null,
|
|
1169
1735
|
);
|
|
1170
1736
|
}
|
|
1171
1737
|
|
|
@@ -1225,6 +1791,12 @@ window.__ModuleLoader__.load({
|
|
|
1225
1791
|
}
|
|
1226
1792
|
exports.apply = apply;
|
|
1227
1793
|
exports.inject = inject;
|
|
1794
|
+
// 测试面:单元格写回纯函数(单测直接断言命令构造,无需起 React/HTTP)
|
|
1795
|
+
exports.__testables = {
|
|
1796
|
+
dialectOf, quoteIdent, parseCellText, isTruncatedCell, isBlobCell,
|
|
1797
|
+
buildUpdate, buildRedisOp, buildMongoOp,
|
|
1798
|
+
};
|
|
1228
1799
|
return module.exports;
|
|
1229
1800
|
},
|
|
1230
1801
|
});
|
|
1802
|
+
// Stryker restore all
|
|
@@ -355,7 +355,7 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
355
355
|
switch (op) {
|
|
356
356
|
case 'insertOne': {
|
|
357
357
|
// 不设 cmd.insert 别名:该键语义是集合名(insertMany 命令形态),混用会误导报错方向
|
|
358
|
-
const doc = requireObject(cmd.document, 'insertOne.document');
|
|
358
|
+
const doc = reviveEjson(requireObject(cmd.document, 'insertOne.document'));
|
|
359
359
|
const r = await c.insertOne(doc);
|
|
360
360
|
return { affectedRows: 1, message: `已插入 1 条文档到 ${name}(_id=${String(r.insertedId)})` };
|
|
361
361
|
}
|
|
@@ -363,7 +363,7 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
363
363
|
if (!Array.isArray(cmd.documents ?? cmd.docs) || (cmd.documents ?? cmd.docs).length === 0) {
|
|
364
364
|
throw new Error('MongoDB insertMany 需要非空 documents 数组:{"insertMany":"coll","documents":[{...}]}');
|
|
365
365
|
}
|
|
366
|
-
const docs = (cmd.documents ?? cmd.docs).map((d) => requireObject(d, 'insertMany.documents[]'));
|
|
366
|
+
const docs = (cmd.documents ?? cmd.docs).map((d) => reviveEjson(requireObject(d, 'insertMany.documents[]')));
|
|
367
367
|
const r = await c.insertMany(docs);
|
|
368
368
|
return { affectedRows: r.insertedCount, message: `已插入 ${r.insertedCount} 条文档到 ${name}` };
|
|
369
369
|
}
|
|
@@ -371,8 +371,8 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
371
371
|
case 'updateMany': {
|
|
372
372
|
if (op === 'updateMany')
|
|
373
373
|
assertExplicitFilter(op, cmd.filter);
|
|
374
|
-
const filter = requireObject(cmd.filter, `${op}.filter`);
|
|
375
|
-
const update = requireObject(cmd.update, `${op}.update`);
|
|
374
|
+
const filter = reviveEjson(requireObject(cmd.filter, `${op}.filter`));
|
|
375
|
+
const update = reviveEjson(requireObject(cmd.update, `${op}.update`));
|
|
376
376
|
const r = op === 'updateOne'
|
|
377
377
|
? await c.updateOne(filter, update)
|
|
378
378
|
: await c.updateMany(filter, update);
|
|
@@ -382,8 +382,8 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
382
382
|
};
|
|
383
383
|
}
|
|
384
384
|
case 'replaceOne': {
|
|
385
|
-
const filter = requireObject(cmd.filter, 'replaceOne.filter');
|
|
386
|
-
const replacement = requireObject(cmd.replacement ?? cmd.update, 'replaceOne.replacement');
|
|
385
|
+
const filter = reviveEjson(requireObject(cmd.filter, 'replaceOne.filter'));
|
|
386
|
+
const replacement = reviveEjson(requireObject(cmd.replacement ?? cmd.update, 'replaceOne.replacement'));
|
|
387
387
|
const r = await c.replaceOne(filter, replacement);
|
|
388
388
|
return {
|
|
389
389
|
affectedRows: r.modifiedCount,
|
|
@@ -394,7 +394,7 @@ export async function createMongoAdapter(conn, opts) {
|
|
|
394
394
|
case 'deleteMany': {
|
|
395
395
|
if (op === 'deleteMany')
|
|
396
396
|
assertExplicitFilter(op, cmd.filter);
|
|
397
|
-
const filter = requireObject(cmd.filter, `${op}.filter`);
|
|
397
|
+
const filter = reviveEjson(requireObject(cmd.filter, `${op}.filter`));
|
|
398
398
|
const r = op === 'deleteOne'
|
|
399
399
|
? await c.deleteOne(filter)
|
|
400
400
|
: await c.deleteMany(filter);
|
|
@@ -526,6 +526,35 @@ function inferBsonType(v) {
|
|
|
526
526
|
return 'object';
|
|
527
527
|
return typeof v;
|
|
528
528
|
}
|
|
529
|
+
/** Mongo Extended JSON($oid/$date)→ BSON 原生类型:前端单元格写回以 JSON 表达 ObjectId/Date,
|
|
530
|
+
* driver 不识别 EJSON 形态,须经此复活。最小集:仅 $oid/$date;其余键原样递归保留。
|
|
531
|
+
* $oid 需 24 位十六进制,非法抛中文可读错误;vendor 缺失时 $oid 原样返回不抛(降级可用)。 */
|
|
532
|
+
const OID_HEX_RE = /^[0-9a-f]{24}$/i;
|
|
533
|
+
export function reviveEjson(v) {
|
|
534
|
+
if (Array.isArray(v))
|
|
535
|
+
return v.map(reviveEjson);
|
|
536
|
+
if (!isPlainObject(v))
|
|
537
|
+
return v;
|
|
538
|
+
const keys = Object.keys(v);
|
|
539
|
+
if (keys.length === 1 && keys[0] === '$oid') {
|
|
540
|
+
const s = v.$oid;
|
|
541
|
+
if (typeof s !== 'string' || !OID_HEX_RE.test(s)) {
|
|
542
|
+
throw new Error(`MongoDB $oid 值非法(${String(s)}):需 24 位十六进制字符串,如 {"$oid":"507f1f77bcf86cd799439011"}`);
|
|
543
|
+
}
|
|
544
|
+
const M = vendorOrNull();
|
|
545
|
+
return M ? new M.ObjectId(s) : v;
|
|
546
|
+
}
|
|
547
|
+
if (keys.length === 1 && keys[0] === '$date') {
|
|
548
|
+
const d = v.$date;
|
|
549
|
+
if (typeof d !== 'string' && typeof d !== 'number')
|
|
550
|
+
return v;
|
|
551
|
+
return new Date(d);
|
|
552
|
+
}
|
|
553
|
+
const out = {};
|
|
554
|
+
for (const [k, val] of Object.entries(v))
|
|
555
|
+
out[k] = reviveEjson(val);
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
529
558
|
export function buildUri(fields, ssl) {
|
|
530
559
|
const host = typeof fields?.host === 'string' && fields.host !== '' ? fields.host : '127.0.0.1';
|
|
531
560
|
const port = typeof fields?.port === 'number' ? fields.port : 27017;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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",
|