dsh-db-tool 1.5.9 → 1.6.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/README.md +14 -2
- package/README_EN.md +14 -2
- package/client/client.js +1284 -183
- package/dist/adapters/dmdb/index.js +8 -2
- package/dist/adapters/oracle/index.js +17 -10
- package/dist/guard/index.js +4 -2
- package/dist/http/index.js +40 -0
- package/dist/manager.js +362 -5
- package/dist/store/connections.js +85 -2
- package/dist/store/export-crypto.js +78 -0
- package/dist/store/index.js +2 -1
- package/docs/connection-export-import.md +172 -0
- package/package.json +12 -2
- package/scripts/build-codemirror.mjs +263 -0
- package/vendor/codemirror-sql.cjs +27643 -0
package/client/client.js
CHANGED
|
@@ -52,6 +52,14 @@ window.__ModuleLoader__.load({
|
|
|
52
52
|
edit: "编辑",
|
|
53
53
|
delete: "删除",
|
|
54
54
|
deleteConnConfirm: "确定删除连接「{name}」?将级联删除其密钥与所有项目的授权。",
|
|
55
|
+
fieldKind: "数据库类型",
|
|
56
|
+
requiredMark: "(必填)",
|
|
57
|
+
deleteConnTitle: "删除连接",
|
|
58
|
+
auditShort: "审计日志",
|
|
59
|
+
noConnsGuide: "还没有连接,先添加一个,再授权给项目使用。",
|
|
60
|
+
addConn: "添加连接",
|
|
61
|
+
confirmParams: "参数",
|
|
62
|
+
grantModeGroup: "{name} 授权模式",
|
|
55
63
|
save: "保存",
|
|
56
64
|
cancel: "取消",
|
|
57
65
|
saving: "保存中…",
|
|
@@ -64,8 +72,22 @@ window.__ModuleLoader__.load({
|
|
|
64
72
|
auditDetail: "详情",
|
|
65
73
|
auditDenied: "拒绝",
|
|
66
74
|
auditConfirmed: "已确认",
|
|
75
|
+
// 连接导出/导入(口令加密备份)
|
|
76
|
+
exportConns: "导出",
|
|
77
|
+
importConns: "导入",
|
|
78
|
+
passphrase: "口令",
|
|
79
|
+
passphraseHint: "8 位以上,仅本次导出/导入使用",
|
|
80
|
+
exportDone: "已导出 {n} 条连接",
|
|
81
|
+
importResult: "已导入 {i} 条,跳过 {s} 条",
|
|
82
|
+
importPartialFail: "部分失败:成功 {i} 条,跳过 {s} 条,失败 {f} 条",
|
|
83
|
+
importDecryptFail: "口令错误或文件已损坏",
|
|
84
|
+
pickFile: "选择文件",
|
|
67
85
|
// 授权
|
|
68
86
|
grantsHint: "为当前项目授权连接。只读=仅查询/浏览,读写=允许执行语句与脚本。未授权的连接在业务操作中一律拒绝。",
|
|
87
|
+
grantsEmptyHint: "当前项目尚未授权任何连接,在下方每行选择「只读」或「读写」即可完成授权。",
|
|
88
|
+
grantsSummary: "已授权 {granted}/{total} 个连接",
|
|
89
|
+
modeBadgeRo: "只读",
|
|
90
|
+
modeBadgeRw: "读写",
|
|
69
91
|
modeNone: "未授权",
|
|
70
92
|
modeRo: "只读",
|
|
71
93
|
modeRw: "读写",
|
|
@@ -105,14 +127,35 @@ window.__ModuleLoader__.load({
|
|
|
105
127
|
blobNoEdit: "二进制值不支持就地编辑",
|
|
106
128
|
redisTypeRo: "Redis 该类型不支持就地编辑",
|
|
107
129
|
// SQL 控制台
|
|
108
|
-
modeQuery: "只读查询",
|
|
109
|
-
modeExecute: "写入执行",
|
|
110
130
|
modeScript: "脚本",
|
|
111
131
|
run: "执行",
|
|
112
132
|
running: "执行中…",
|
|
113
133
|
sqlPlaceholder: "输入 SQL / 命令…(Redis: [\"GET\",\"key\"];Mongo: {\"find\":\"users\",\"filter\":{}})",
|
|
134
|
+
// SQL 控制台(多标签重构):标签 / 工具 / 预分类 / 事务 / 历史
|
|
135
|
+
modeSql: "SQL",
|
|
136
|
+
newTab: "新建标签",
|
|
137
|
+
closeTab: "关闭标签页",
|
|
138
|
+
queryTab: "查询 {n}",
|
|
139
|
+
runSelection: "执行选中",
|
|
140
|
+
formatSql: "格式化",
|
|
141
|
+
skippedRo: "已跳过(只读授权)",
|
|
142
|
+
locate: "定位",
|
|
143
|
+
historyTitle: "历史",
|
|
144
|
+
historyEmpty: "暂无历史",
|
|
145
|
+
txBegin: "开始事务",
|
|
146
|
+
txCommit: "提交事务",
|
|
147
|
+
txRollback: "回滚事务",
|
|
148
|
+
txActive: "事务进行中",
|
|
149
|
+
txInline: "事务内",
|
|
150
|
+
txDefaultDbOnly: "事务绑定连接默认库:请先清空库选择",
|
|
151
|
+
routeQuery: "查询",
|
|
152
|
+
routeExec: "写入",
|
|
153
|
+
routeScript: "脚本",
|
|
154
|
+
// 视图状态与控制台(T2)
|
|
155
|
+
selectConn: "选择连接…",
|
|
156
|
+
copyFailed: "复制失败",
|
|
114
157
|
scriptPlaceholder: "输入 JS 脚本(vm 沙箱,含 db 助手对象)…",
|
|
115
|
-
paramsJson: "查询参数(JSON
|
|
158
|
+
paramsJson: "查询参数(JSON 数组,可选;多语句时每条共用同一组参数)",
|
|
116
159
|
rowsResult: "{n} 行",
|
|
117
160
|
execResult: "受影响 {n} 行",
|
|
118
161
|
needConfirmTitle: "危险操作确认",
|
|
@@ -160,6 +203,14 @@ window.__ModuleLoader__.load({
|
|
|
160
203
|
edit: "Edit",
|
|
161
204
|
delete: "Delete",
|
|
162
205
|
deleteConnConfirm: "Delete connection \"{name}\"? Its secrets and all project grants will be removed.",
|
|
206
|
+
fieldKind: "Database type",
|
|
207
|
+
requiredMark: " (required)",
|
|
208
|
+
deleteConnTitle: "Delete Connection",
|
|
209
|
+
auditShort: "Audit log",
|
|
210
|
+
noConnsGuide: "No connections yet — add one first, then grant it to your project.",
|
|
211
|
+
addConn: "Add Connection",
|
|
212
|
+
confirmParams: "Params",
|
|
213
|
+
grantModeGroup: "Access mode for {name}",
|
|
163
214
|
save: "Save",
|
|
164
215
|
cancel: "Cancel",
|
|
165
216
|
saving: "Saving…",
|
|
@@ -172,7 +223,20 @@ window.__ModuleLoader__.load({
|
|
|
172
223
|
auditDetail: "Detail",
|
|
173
224
|
auditDenied: "denied",
|
|
174
225
|
auditConfirmed: "confirmed",
|
|
226
|
+
exportConns: "Export",
|
|
227
|
+
importConns: "Import",
|
|
228
|
+
passphrase: "Passphrase",
|
|
229
|
+
passphraseHint: "8+ characters, used only for this export/import",
|
|
230
|
+
exportDone: "Exported {n} connection(s)",
|
|
231
|
+
importResult: "Imported {i}, skipped {s}",
|
|
232
|
+
importPartialFail: "Partial failure: {i} imported, {s} skipped, {f} failed",
|
|
233
|
+
importDecryptFail: "Wrong passphrase or corrupted file",
|
|
234
|
+
pickFile: "Pick file",
|
|
175
235
|
grantsHint: "Grant connections to the current project. ro = read-only (query/browse), rw = read-write (execute/script allowed). Unauthorized connections are always rejected.",
|
|
236
|
+
grantsEmptyHint: "No connections granted to this project yet. Pick read-only or read-write on a row below to grant.",
|
|
237
|
+
grantsSummary: "{granted}/{total} connections granted",
|
|
238
|
+
modeBadgeRo: "RO",
|
|
239
|
+
modeBadgeRw: "RW",
|
|
176
240
|
modeNone: "none",
|
|
177
241
|
modeRo: "read-only ro",
|
|
178
242
|
modeRw: "read-write rw",
|
|
@@ -210,14 +274,35 @@ window.__ModuleLoader__.load({
|
|
|
210
274
|
truncatedNoEdit: "Value shown truncated; edit via SQL Console instead",
|
|
211
275
|
blobNoEdit: "Binary values cannot be edited here",
|
|
212
276
|
redisTypeRo: "This Redis type cannot be edited here",
|
|
213
|
-
modeQuery: "query (read-only)",
|
|
214
|
-
modeExecute: "execute (write)",
|
|
215
277
|
modeScript: "script",
|
|
216
278
|
run: "Run",
|
|
217
279
|
running: "Running…",
|
|
218
280
|
sqlPlaceholder: "SQL / command… (Redis: [\"GET\",\"key\"]; Mongo: {\"find\":\"users\",\"filter\":{}})",
|
|
281
|
+
// SQL console (multi-tab rework): tabs / toolbar / pre-classification / transactions / history
|
|
282
|
+
modeSql: "SQL",
|
|
283
|
+
newTab: "New tab",
|
|
284
|
+
closeTab: "Close tab",
|
|
285
|
+
queryTab: "Query {n}",
|
|
286
|
+
runSelection: "Run selection",
|
|
287
|
+
formatSql: "Format",
|
|
288
|
+
skippedRo: "Skipped (read-only grant)",
|
|
289
|
+
locate: "Locate",
|
|
290
|
+
historyTitle: "History",
|
|
291
|
+
historyEmpty: "No history yet",
|
|
292
|
+
txBegin: "Begin",
|
|
293
|
+
txCommit: "Commit",
|
|
294
|
+
txRollback: "Roll back",
|
|
295
|
+
txActive: "In transaction",
|
|
296
|
+
txInline: "In tx",
|
|
297
|
+
txDefaultDbOnly: "Transactions bind the connection's default database: clear the database selection first",
|
|
298
|
+
routeQuery: "read",
|
|
299
|
+
routeExec: "write",
|
|
300
|
+
routeScript: "script",
|
|
301
|
+
// View state & console (T2)
|
|
302
|
+
selectConn: "Select a connection…",
|
|
303
|
+
copyFailed: "Copy failed",
|
|
219
304
|
scriptPlaceholder: "JS script (vm sandbox, with db helper)…",
|
|
220
|
-
paramsJson: "params (JSON array, optional)",
|
|
305
|
+
paramsJson: "params (JSON array, optional; shared by every statement in a batch)",
|
|
221
306
|
rowsResult: "{n} rows",
|
|
222
307
|
execResult: "{n} rows affected",
|
|
223
308
|
needConfirmTitle: "Dangerous operation",
|
|
@@ -274,6 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
274
359
|
err.statement = data.statement;
|
|
275
360
|
err.danger = data.danger;
|
|
276
361
|
err.reason = data.reason;
|
|
362
|
+
err.params = data.params; // 服务端如回传参数则透传(对话框代入展示用;缺失时 undefined 无害)
|
|
277
363
|
}
|
|
278
364
|
throw err;
|
|
279
365
|
}
|
|
@@ -292,7 +378,7 @@ window.__ModuleLoader__.load({
|
|
|
292
378
|
return await send(undefined);
|
|
293
379
|
} catch (e) {
|
|
294
380
|
if (e && e.code === "NEEDS_CONFIRMATION" && e.challengeId) {
|
|
295
|
-
const yes = await askConfirm({ statement: e.statement, danger: e.danger, reason: e.reason });
|
|
381
|
+
const yes = await askConfirm({ statement: e.statement, danger: e.danger, reason: e.reason, params: e.params });
|
|
296
382
|
if (!yes) { const err = new Error(t("cancelled")); err.cancelled = true; throw err; }
|
|
297
383
|
return await send(e.challengeId);
|
|
298
384
|
}
|
|
@@ -317,7 +403,7 @@ window.__ModuleLoader__.load({
|
|
|
317
403
|
styleEl = document.createElement("style");
|
|
318
404
|
styleEl.textContent = [
|
|
319
405
|
"/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转;.dbt-browse-root 并入以承载内嵌浏览面板与单元格浮层) ===== */",
|
|
320
|
-
".dbt-panel,.dbt-browse-root{--dbt-accent:var(--dsh-accent,#0a84ff);--dbt-bg:transparent;--dbt-surface:rgba(120,120,128,.12);--dbt-surface-strong:rgba(120,120,128,.18);--dbt-separator:rgba(120,120,128,.24);--dbt-text:rgba(235,235,245,.92);--dbt-text-secondary:rgba(235,235,245,.6);--dbt-danger:#ff453a;--dbt-success:#30d158;--dbt-warning:#ffd60a;--dbt-dialog-bg:rgba(40,40,44,.85);--dbt-th-bg:rgba(30,30,32,.72);--dbt-seg-active:rgba(255,255,255,.14);--dbt-shadow:0 8px 32px rgba(0,0,0,.28);--dbt-radius-card:12px;--dbt-radius-ctrl:8px;--dbt-radius-pill:6px;--dbt-ease:cubic-bezier(.25,.1,.25,1);--dbt-mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif;}",
|
|
406
|
+
".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-caption:11px;--dbt-caption-xs:10px;--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;}",
|
|
321
407
|
"@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);}}",
|
|
322
408
|
// 浏览面板根自持文字色:PiP 独立文档挂 body 后无侧栏祖先 color 可继承(内嵌/浮窗两态共用)
|
|
323
409
|
".dbt-browse-root{color:var(--dbt-text);}",
|
|
@@ -325,7 +411,7 @@ window.__ModuleLoader__.load({
|
|
|
325
411
|
".dbt-panel{font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:10px;padding:16px;height:100%;box-sizing:border-box;overflow-y:auto;}",
|
|
326
412
|
"/* ===== iOS segmented control(顶部 tab) ===== */",
|
|
327
413
|
".dbt-tabs{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
|
|
328
|
-
".dbt-tabs button{flex:1;border:none;background:transparent;color:inherit;border-radius:var(--dbt-radius-pill);padding:5px 8px;font-size:12px;font-weight:500;cursor:pointer;transition:
|
|
414
|
+
".dbt-tabs button{flex:1;border:none;background:transparent;color:inherit;border-radius:var(--dbt-radius-pill);padding:5px 8px;font-size:12px;font-weight:500;cursor:pointer;transition:background .18s var(--dbt-ease),box-shadow .18s var(--dbt-ease);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
|
|
329
415
|
".dbt-tabs button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
330
416
|
"/* ===== inset grouped 卡片 ===== */",
|
|
331
417
|
".dbt-card{background:var(--dbt-surface);border:none;border-radius:var(--dbt-radius-card);padding:12px;display:flex;flex-direction:column;gap:8px;}",
|
|
@@ -334,17 +420,14 @@ window.__ModuleLoader__.load({
|
|
|
334
420
|
".dbt-col{display:flex;flex-direction:column;gap:8px;}",
|
|
335
421
|
".dbt-col.dbt-col-tight{gap:2px;}",
|
|
336
422
|
"/* ===== 填充式无边框输入 ===== */",
|
|
337
|
-
".dbt-row input,.dbt-row select,.dbt-card input,.dbt-card select,.dbt-card textarea,.dbt-group input,.dbt-group select{flex:1;min-width:60px;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:5px 12px;font-size:13px;font-family:inherit;transition:
|
|
423
|
+
".dbt-row input,.dbt-row select,.dbt-card input,.dbt-card select,.dbt-card textarea,.dbt-group input,.dbt-group select{flex:1;min-width:60px;background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:5px 12px;font-size:13px;font-family:inherit;transition:outline-color .18s var(--dbt-ease),outline-width .18s var(--dbt-ease);}",
|
|
338
424
|
".dbt-row input:focus,.dbt-row select:focus,.dbt-card input:focus,.dbt-card select:focus,.dbt-card textarea:focus,.dbt-group input:focus,.dbt-group select:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
|
|
339
425
|
".dbt-card textarea{font-family:var(--dbt-mono);min-height:96px;resize:vertical;}",
|
|
340
426
|
"/* ===== 按钮 ===== */",
|
|
341
|
-
".dbt-btn{border:none;background:var(--dbt-surface);color:inherit;border-radius:var(--dbt-radius-ctrl);padding:5px 12px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;transition:
|
|
342
|
-
".dbt-btn:hover{background:var(--dbt-surface-strong);}",
|
|
427
|
+
".dbt-btn{border:none;background:var(--dbt-surface);color:inherit;border-radius:var(--dbt-radius-ctrl);padding:5px 12px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .18s var(--dbt-ease),filter .18s var(--dbt-ease),transform .18s var(--dbt-ease),opacity .18s var(--dbt-ease);}",
|
|
343
428
|
".dbt-btn:active{transform:scale(.97);}",
|
|
344
429
|
".dbt-btn.primary{background:var(--dbt-accent);color:#fff;}",
|
|
345
|
-
".dbt-btn.primary:hover{filter:brightness(1.1);}",
|
|
346
430
|
".dbt-btn.danger{color:var(--dbt-danger);background:transparent;border:1px solid var(--dbt-danger);}",
|
|
347
|
-
".dbt-btn.danger:hover{background:rgba(255,69,58,.12);}",
|
|
348
431
|
".dbt-btn:disabled{opacity:.4;cursor:default;transform:none;}",
|
|
349
432
|
"/* ===== 文本反馈 ===== */",
|
|
350
433
|
".dbt-panel strong,.dbt-browse-root strong{font-size:13px;font-weight:600;}",
|
|
@@ -357,11 +440,23 @@ window.__ModuleLoader__.load({
|
|
|
357
440
|
".dbt-table th{position:sticky;top:0;z-index:1;background:var(--dbt-th-bg);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);font-weight:600;font-size:12px;}",
|
|
358
441
|
".dbt-table td{font-family:var(--dbt-mono);}",
|
|
359
442
|
".dbt-tablewrap{border:none;border-radius:var(--dbt-radius-card);overflow:auto;max-height:320px;background:var(--dbt-surface);}",
|
|
443
|
+
"/* ===== 结果网格(SQL 控制台):可排序表头按钮(毛玻璃 sticky 表头复用 .dbt-table th,不另设) ===== */",
|
|
444
|
+
".dbt-grid-sort{border:none;background:transparent;color:inherit;font:inherit;font-weight:600;font-size:12px;padding:0;margin:0;cursor:pointer;display:block;width:100%;text-align:left;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
445
|
+
".dbt-grid-sort:focus-visible{outline:2px solid var(--dbt-accent);outline-offset:-1px;border-radius:2px;}",
|
|
446
|
+
"/* ===== SQL 控制台(多标签):标签条 / 编辑器宿主 / 结果行路由徽标(全 token,跟随明暗) ===== */",
|
|
447
|
+
".dbt-cm-tabbar{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;overflow-x:auto;}",
|
|
448
|
+
".dbt-cm-tab{display:flex;align-items:center;gap:4px;border:none;background:transparent;color:inherit;font-size:12px;padding:5px 8px;border-radius:var(--dbt-radius-pill);cursor:pointer;white-space:nowrap;transition:background .18s var(--dbt-ease);}",
|
|
449
|
+
".dbt-cm-tab.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
450
|
+
".dbt-cm-tabclose{border:none;background:transparent;color:inherit;opacity:.5;padding:0 1px;cursor:pointer;font-size:11px;line-height:1;}",
|
|
451
|
+
".dbt-cm-tabclose:hover{opacity:1;}",
|
|
452
|
+
".dbt-editor-host{height:280px;display:flex;flex-direction:column;}",
|
|
453
|
+
".dbt-editor-host textarea{flex:1;font-family:var(--dbt-mono);background:var(--dbt-surface-strong);border:none;color:inherit;border-radius:var(--dbt-radius-ctrl);padding:8px 10px;resize:none;}",
|
|
454
|
+
".dbt-editor-host textarea:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
|
|
455
|
+
".dbt-routechip{flex:none;background:var(--dbt-surface-strong);color:var(--dbt-text-secondary);border-radius:6px;padding:1px 6px;font-size:var(--dbt-caption-xs,10px);font-weight:600;line-height:1.45;}",
|
|
360
456
|
"/* ===== 浏览面板表格:列分明(列间 hairline + 表头加深一档 + 行 hover) ===== */",
|
|
361
457
|
".dbt-browse-root .dbt-table th + th,.dbt-browse-root .dbt-table td + td{border-left:1px solid var(--dbt-separator);}",
|
|
362
458
|
".dbt-browse-root .dbt-table th{background:rgba(30,30,32,.85);}",
|
|
363
459
|
".dbt-browse-root .dbt-table td{font-size:12px;}",
|
|
364
|
-
".dbt-browse-root .dbt-table tbody tr:hover td{background:var(--dbt-surface-strong);}",
|
|
365
460
|
"@media (prefers-color-scheme: light){.dbt-browse-root .dbt-table th{background:rgba(242,242,247,.9);}}",
|
|
366
461
|
"/* ===== 对话框(macOS alert 材质) ===== */",
|
|
367
462
|
".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;}",
|
|
@@ -372,14 +467,12 @@ window.__ModuleLoader__.load({
|
|
|
372
467
|
".dbt-group{background:var(--dbt-surface);border-radius:var(--dbt-radius-card);overflow:hidden;display:flex;flex-direction:column;}",
|
|
373
468
|
".dbt-listrow{display:flex;gap:8px;align-items:center;padding:10px 12px;border-bottom:1px solid var(--dbt-separator);transition:background .18s var(--dbt-ease);}",
|
|
374
469
|
".dbt-listrow:last-child{border-bottom:none;}",
|
|
375
|
-
".dbt-listrow:hover{background:var(--dbt-surface-strong);}",
|
|
376
470
|
".dbt-tree{display:flex;flex-direction:column;}",
|
|
377
471
|
"/* ===== Navicat 式对象树(数据浏览) ===== */",
|
|
378
472
|
".dbt-treerow{display:flex;gap:6px;align-items:center;padding:10px 12px;font-size:13px;cursor:pointer;user-select:none;border-bottom:1px solid var(--dbt-separator);transition:background .15s var(--dbt-ease);}",
|
|
379
473
|
".dbt-treerow:last-child{border-bottom:none;}",
|
|
380
|
-
".dbt-treerow:hover{background:var(--dbt-surface-strong);}",
|
|
381
474
|
".dbt-treerow.active{color:var(--dbt-accent);font-weight:600;}",
|
|
382
|
-
".dbt-chev{flex:none;width:12px;text-align:center;color:var(--dbt-
|
|
475
|
+
".dbt-chev{flex:none;width:12px;text-align:center;color:var(--dbt-text-secondary);font-size:var(--dbt-caption-xs);line-height:1;transition:transform .18s var(--dbt-ease);}",
|
|
383
476
|
".dbt-chev.open{transform:rotate(90deg);}",
|
|
384
477
|
".dbt-chev.leaf{visibility:hidden;}",
|
|
385
478
|
".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
@@ -412,40 +505,108 @@ window.__ModuleLoader__.load({
|
|
|
412
505
|
".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
|
|
413
506
|
"/* ===== mini segmented(ro/rw 等切换) ===== */",
|
|
414
507
|
".dbt-seg{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
|
|
415
|
-
".dbt-seg button{flex:none;border:none;background:transparent;color:inherit;font-size:12px;padding:5px 12px;border-radius:var(--dbt-radius-pill);cursor:pointer;transition:
|
|
508
|
+
".dbt-seg button{flex:none;border:none;background:transparent;color:inherit;font-size:12px;padding:5px 12px;border-radius:var(--dbt-radius-pill);cursor:pointer;transition:background .18s var(--dbt-ease),box-shadow .18s var(--dbt-ease);}",
|
|
416
509
|
".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
510
|
+
"/* ===== hover 仅精确指针(触屏 tap 不残留 hover 底色) ===== */",
|
|
511
|
+
"@media (hover:hover) and (pointer:fine){.dbt-btn:hover{background:var(--dbt-surface-strong);}.dbt-btn.primary:hover{filter:brightness(1.1);}.dbt-btn.danger:hover{background:rgba(255,69,58,.12);}.dbt-listrow:hover{background:var(--dbt-surface-strong);}.dbt-treerow:hover{background:var(--dbt-surface-strong);}.dbt-browse-root .dbt-table tbody tr:hover td{background:var(--dbt-surface-strong);}}",
|
|
417
512
|
"/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
|
|
418
513
|
"@media (prefers-reduced-transparency: reduce){.dbt-overlay{backdrop-filter:none;-webkit-backdrop-filter:none;background:rgba(0,0,0,.55);}.dbt-dialog{backdrop-filter:none;-webkit-backdrop-filter:none;background:#2c2c2e;}.dbt-celldetail{background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}.dbt-browse-root .dbt-table th{background:#1e1e20;}}",
|
|
419
|
-
"@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#
|
|
514
|
+
"@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#f2f2f7;}.dbt-celldetail{background:#f2f2f7;}.dbt-table th,.dbt-browse-root .dbt-table th{background:#f2f2f7;}}",
|
|
420
515
|
"@media (prefers-reduced-motion: reduce){.dbt-panel *,.dbt-browse-root *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
421
516
|
].join("\n");
|
|
422
517
|
document.head.appendChild(styleEl);
|
|
423
518
|
}
|
|
424
519
|
|
|
425
520
|
function DangerDialog(props) {
|
|
426
|
-
const ch = props.challenge; // {statement, danger, reason, resolve}
|
|
521
|
+
const ch = props.challenge; // {statement, danger, reason, resolve, target?, params?, title?, confirmLabel?, hint?}
|
|
522
|
+
const dialogRef = React.useRef(null);
|
|
523
|
+
const cancelRef = React.useRef(null);
|
|
524
|
+
const titleId = "dbt-confirm-title";
|
|
427
525
|
function done(v) { props.onClose(); ch.resolve(v); }
|
|
526
|
+
// 键盘契约:Escape=取消;Tab/Shift+Tab 在「确认执行」「取消」两枚按钮间循环锁定,焦点不逃出对话框
|
|
527
|
+
function onKeyDown(e) {
|
|
528
|
+
if (e.key === "Escape") { e.preventDefault(); done(false); return; }
|
|
529
|
+
if (e.key !== "Tab") return;
|
|
530
|
+
const dialog = dialogRef.current;
|
|
531
|
+
if (!dialog) return;
|
|
532
|
+
const btns = dialog.querySelectorAll("button");
|
|
533
|
+
if (btns.length === 0) return;
|
|
534
|
+
const first = btns[0];
|
|
535
|
+
const last = btns[btns.length - 1];
|
|
536
|
+
const doc = dialog.ownerDocument || document;
|
|
537
|
+
if (!dialog.contains(doc.activeElement)) { e.preventDefault(); (e.shiftKey ? last : first).focus(); return; }
|
|
538
|
+
if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
539
|
+
else if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
540
|
+
}
|
|
541
|
+
// 挂载后焦点落「取消」:次动作为键盘默认,防回车/空格误触危险确认
|
|
542
|
+
React.useEffect(() => {
|
|
543
|
+
if (cancelRef.current && typeof cancelRef.current.focus === "function") cancelRef.current.focus();
|
|
544
|
+
}, []);
|
|
545
|
+
// 有 params 时 pre 展示代入后的完整语句(? → 字面量);params 为空/非法 JSON 视为无参数
|
|
546
|
+
let hasParams = false;
|
|
547
|
+
try {
|
|
548
|
+
const parsed = typeof ch.params === "string" ? JSON.parse(ch.params) : ch.params;
|
|
549
|
+
if (Array.isArray(parsed) && parsed.length > 0) hasParams = true;
|
|
550
|
+
} catch (e) { /* 非法 JSON:按无参数展示 */ }
|
|
551
|
+
const shownStatement = hasParams ? renderStatementWithParams(ch.statement, ch.params) : (ch.statement || "(?)");
|
|
552
|
+
// 目标条徽标:ro=muted、rw=danger,形状对齐 kind 徽标(纯内联样式,样式块不承载对话框专属规则)
|
|
553
|
+
const target = ch.target;
|
|
554
|
+
const modeBadge = target && target.mode
|
|
555
|
+
? React.createElement("span", {
|
|
556
|
+
style: {
|
|
557
|
+
background: "var(--dbt-surface-strong, rgba(120,120,128,.18))",
|
|
558
|
+
borderRadius: "6px",
|
|
559
|
+
padding: "2px 6px",
|
|
560
|
+
fontSize: "11px",
|
|
561
|
+
lineHeight: 1.45,
|
|
562
|
+
fontWeight: 600,
|
|
563
|
+
color: target.mode === "rw" ? "var(--dbt-danger, #ff453a)" : "var(--dbt-text-secondary, rgba(235,235,245,.6))",
|
|
564
|
+
},
|
|
565
|
+
}, target.mode === "rw" ? t("modeRw") : t("modeRo"))
|
|
566
|
+
: null;
|
|
428
567
|
return React.createElement(
|
|
429
568
|
"div",
|
|
430
|
-
{ className: "dbt-overlay", onClick: () => done(false) },
|
|
569
|
+
{ className: "dbt-overlay", onClick: () => done(false), onKeyDown },
|
|
431
570
|
React.createElement(
|
|
432
571
|
"div",
|
|
433
|
-
{
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
572
|
+
{
|
|
573
|
+
className: "dbt-dialog",
|
|
574
|
+
onClick: (e) => e.stopPropagation(),
|
|
575
|
+
role: "dialog",
|
|
576
|
+
"aria-modal": "true",
|
|
577
|
+
"aria-labelledby": titleId,
|
|
578
|
+
ref: dialogRef,
|
|
579
|
+
},
|
|
580
|
+
// macOS alert 风格:纯文字标题 + danger 色,不用 emoji;title 可被 challenge 覆盖(如删除连接)
|
|
581
|
+
React.createElement("strong", { id: titleId, style: { color: "var(--dbt-danger, #ff453a)" } }, ch.title || t("needConfirmTitle")),
|
|
582
|
+
// 目标条:连接名(semibold 主行)+ kind/db(muted)+ ro/rw 徽标;target 缺失不渲染
|
|
583
|
+
target && target.conn ? React.createElement(
|
|
584
|
+
"div",
|
|
585
|
+
{ className: "dbt-row" },
|
|
586
|
+
React.createElement("strong", { style: { fontWeight: 600, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, target.conn),
|
|
587
|
+
target.kind ? React.createElement("span", { className: "dbt-muted" }, target.kind) : null,
|
|
588
|
+
target.db ? React.createElement("span", { className: "dbt-muted" }, target.db) : null,
|
|
589
|
+
modeBadge,
|
|
590
|
+
) : null,
|
|
591
|
+
React.createElement("pre", null, shownStatement),
|
|
592
|
+
hasParams ? React.createElement(
|
|
593
|
+
"div",
|
|
594
|
+
{ className: "dbt-muted", style: { fontFamily: MONO_FONT, wordBreak: "break-all" } },
|
|
595
|
+
t("confirmParams") + " " + (typeof ch.params === "string" ? ch.params : JSON.stringify(ch.params)),
|
|
596
|
+
) : null,
|
|
597
|
+
// 风险等级/原因行仅服务端危险判定场景渲染(删除连接等本地确认无此二字段)
|
|
598
|
+
(ch.danger || ch.reason) ? React.createElement(
|
|
438
599
|
"div",
|
|
439
600
|
{ className: "dbt-muted" },
|
|
440
601
|
t("dangerLevel") + ": " + (ch.danger || "danger") + " · " + t("reason") + ": " + (ch.reason || "-"),
|
|
441
|
-
),
|
|
442
|
-
React.createElement("div", { className: "dbt-muted" }, t("confirmHint")),
|
|
602
|
+
) : null,
|
|
603
|
+
React.createElement("div", { className: "dbt-muted" }, ch.hint || t("confirmHint")),
|
|
443
604
|
React.createElement(
|
|
444
605
|
"div",
|
|
445
606
|
// 末对齐由样式层 .dbt-dialog .dbt-row 规则承担,无需内联 style
|
|
446
607
|
{ className: "dbt-row" },
|
|
447
|
-
React.createElement("button", { className: "dbt-btn danger", onClick: () => done(true) }, t("confirmRun")),
|
|
448
|
-
React.createElement("button", { className: "dbt-btn", onClick: () => done(false) }, t("confirmCancel")),
|
|
608
|
+
React.createElement("button", { className: "dbt-btn danger", onClick: () => done(true) }, ch.confirmLabel || t("confirmRun")),
|
|
609
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => done(false), ref: cancelRef }, t("confirmCancel")),
|
|
449
610
|
),
|
|
450
611
|
),
|
|
451
612
|
);
|
|
@@ -454,6 +615,74 @@ window.__ModuleLoader__.load({
|
|
|
454
615
|
/* ---------------- 连接管理 ---------------- */
|
|
455
616
|
const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
|
|
456
617
|
|
|
618
|
+
// 口令弹窗(ManageView 局部复用,导出/导入共用):键盘契约对齐 DangerDialog
|
|
619
|
+
// (Escape=取消、Tab 在对话框内循环锁定);差异:初始焦点在口令输入框,确认按钮主色。
|
|
620
|
+
// ponytail: 仅两处使用,故为 ManageView 内部局部组件,不抽通用模块。
|
|
621
|
+
function PassphraseDialog(props) {
|
|
622
|
+
const [value, setValue] = React.useState("");
|
|
623
|
+
const [err, setErr] = React.useState(""); // 就地错误(如口令过短);读屏经 role=alert 播报
|
|
624
|
+
const inputRef = React.useRef(null);
|
|
625
|
+
const dialogRef = React.useRef(null);
|
|
626
|
+
const titleId = "dbt-pass-title";
|
|
627
|
+
function done(v) { props.onClose(v); }
|
|
628
|
+
function submit() {
|
|
629
|
+
const v = value.trim();
|
|
630
|
+
if (v.length < 8) { setErr(t("passphraseHint")); return; } // 与服务端 PASSPHRASE_MIN=8 对齐(服务端为权威边界)
|
|
631
|
+
done(v);
|
|
632
|
+
}
|
|
633
|
+
function onKeyDown(e) {
|
|
634
|
+
if (e.key === "Escape") { e.preventDefault(); done(null); return; }
|
|
635
|
+
if (e.key === "Enter" && e.target === inputRef.current) { e.preventDefault(); submit(); return; }
|
|
636
|
+
if (e.key !== "Tab") return;
|
|
637
|
+
const dialog = dialogRef.current;
|
|
638
|
+
if (!dialog) return;
|
|
639
|
+
const els = dialog.querySelectorAll("input,button");
|
|
640
|
+
if (els.length === 0) return;
|
|
641
|
+
const first = els[0];
|
|
642
|
+
const last = els[els.length - 1];
|
|
643
|
+
const doc = dialog.ownerDocument || document;
|
|
644
|
+
if (!dialog.contains(doc.activeElement)) { e.preventDefault(); (e.shiftKey ? last : first).focus(); return; }
|
|
645
|
+
if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
646
|
+
else if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
647
|
+
}
|
|
648
|
+
// 挂载后焦点落口令输入框(与 DangerDialog 落「取消」不同:本弹窗无危险语义,输入即主路径)
|
|
649
|
+
React.useEffect(() => {
|
|
650
|
+
if (inputRef.current && typeof inputRef.current.focus === "function") inputRef.current.focus();
|
|
651
|
+
}, []);
|
|
652
|
+
return React.createElement(
|
|
653
|
+
"div",
|
|
654
|
+
{ className: "dbt-overlay", onClick: () => done(null), onKeyDown },
|
|
655
|
+
React.createElement(
|
|
656
|
+
"div",
|
|
657
|
+
{
|
|
658
|
+
className: "dbt-dialog",
|
|
659
|
+
onClick: (e) => e.stopPropagation(),
|
|
660
|
+
role: "dialog",
|
|
661
|
+
"aria-modal": "true",
|
|
662
|
+
"aria-labelledby": titleId,
|
|
663
|
+
ref: dialogRef,
|
|
664
|
+
},
|
|
665
|
+
React.createElement("strong", { id: titleId }, props.title),
|
|
666
|
+
React.createElement("input", {
|
|
667
|
+
type: "password",
|
|
668
|
+
"aria-label": t("passphrase"),
|
|
669
|
+
placeholder: t("passphrase"),
|
|
670
|
+
autoComplete: "off",
|
|
671
|
+
value,
|
|
672
|
+
ref: inputRef,
|
|
673
|
+
onChange: (e) => { setValue(e.target.value); if (err) setErr(""); },
|
|
674
|
+
}),
|
|
675
|
+
React.createElement("div", { className: "dbt-muted" }, t("passphraseHint")),
|
|
676
|
+
err ? React.createElement("div", { className: "dbt-err", role: "alert" }, err) : null,
|
|
677
|
+
React.createElement(
|
|
678
|
+
"div",
|
|
679
|
+
{ className: "dbt-row" },
|
|
680
|
+
React.createElement("button", { className: "dbt-btn primary", onClick: submit }, props.confirmLabel),
|
|
681
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => done(null) }, t("cancel")),
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
457
686
|
// 通用表格渲染:columns + rows(审计列表与查询结果共用);wrapClass 可选(浏览弹窗用自适应高度容器)
|
|
458
687
|
function resultTable(columns, rows, cellTitles, wrapClass) {
|
|
459
688
|
return React.createElement(
|
|
@@ -612,6 +841,151 @@ window.__ModuleLoader__.load({
|
|
|
612
841
|
};
|
|
613
842
|
}
|
|
614
843
|
|
|
844
|
+
// 危险确认对话框展示用:把语句里的 ? 占位符逐个替换为参数的字面渲染(仅展示,不参与真实执行)
|
|
845
|
+
// params 支持 JSON 字符串/数组/undefined;字符串加单引号并内部双写转义,数字/布尔原样,
|
|
846
|
+
// null/undefined 渲染为 NULL;字面量超 120 字符截断加省略号;占位符多于参数时剩余 ? 原样保留
|
|
847
|
+
function renderStatementWithParams(statement, params) {
|
|
848
|
+
let arr = params;
|
|
849
|
+
if (typeof arr === "string") {
|
|
850
|
+
try { arr = JSON.parse(arr); } catch (e) { arr = null; }
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(arr)) arr = [];
|
|
853
|
+
const lit = (v) => {
|
|
854
|
+
let s;
|
|
855
|
+
if (v === null || v === undefined) s = "NULL";
|
|
856
|
+
else if (typeof v === "number" || typeof v === "boolean") s = String(v);
|
|
857
|
+
else s = "'" + String(v).split("'").join("''") + "'";
|
|
858
|
+
return s.length > 120 ? s.slice(0, 120) + "…" : s;
|
|
859
|
+
};
|
|
860
|
+
const parts = String(statement == null ? "" : statement).split("?");
|
|
861
|
+
return parts.map((seg, i) => (i === parts.length - 1 ? seg : seg + (i < arr.length ? lit(arr[i]) : "?"))).join("");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/* ---------------- 控制台工具纯函数(语句切分 / 格式化方言 / 导出 / 耗时,ConsoleView 与测试共用) ---------------- */
|
|
865
|
+
|
|
866
|
+
// sql-formatter 方言映射(vendor bundle 的 format() 用);mongodb/redis 非 SQL 方言返回 null
|
|
867
|
+
function fmtDialectOf(kind) {
|
|
868
|
+
if (kind === "mysql") return "mysql";
|
|
869
|
+
if (kind === "postgresql" || kind === "gaussdb") return "postgresql";
|
|
870
|
+
if (kind === "sqlite") return "sqlite";
|
|
871
|
+
if (kind === "oracle" || kind === "dmdb") return "plsql";
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// SQL 文本 → 语句列表 [{ text, start }]:text 为原文切片(保留内部注释/空白),start 为语句
|
|
876
|
+
// 首字符偏移(跳过前导空白/注释,错误就地显示后定位光标用)。词法跳过与 lang-sql 方言 spec
|
|
877
|
+
// 一一对应:单/双引号字符串(反斜杠与双写转义,mysql 另含反引号标识符)、-- 行注释
|
|
878
|
+
// (slashComments)、/* */ 块注释、$$...$$ dollar-quoting(doubleDollarQuotedStrings,仅
|
|
879
|
+
// postgresql/gaussdb)、MySQL DELIMITER 自定义分隔符(客户端指令:仅语句缓冲为空时切换后续
|
|
880
|
+
// 分隔符,指令行不产生语句)、Oracle/达梦 BEGIN..END 块(块内分号不切分;CREATE
|
|
881
|
+
// PROCEDURE/FUNCTION 体的分号由该块覆盖)。空/纯注释语句被过滤;无尾分隔符的最后一条也返回。
|
|
882
|
+
// ponytail: depth 计数不识别 CASE..END/LOOP/IF 等(块内混用可能提前切分);需要时换完整 PL/SQL 词法
|
|
883
|
+
function splitSqlStatements(sql, kind) {
|
|
884
|
+
const src = String(sql == null ? "" : sql);
|
|
885
|
+
const out = [];
|
|
886
|
+
const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
887
|
+
let sep = ";";
|
|
888
|
+
let i = 0, stmtStart = 0, contentStart = 0, hasContent = false, depth = 0; // depth: BEGIN..END 嵌套计数
|
|
889
|
+
while (i < src.length) {
|
|
890
|
+
const c = src[i];
|
|
891
|
+
if (c === "-" && src[i + 1] === "-") { // -- 行注释至行尾
|
|
892
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (c === "/" && src[i + 1] === "*") { // /* 块注释至 */(未闭合则吞到结尾)
|
|
896
|
+
i += 2;
|
|
897
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
898
|
+
i = Math.min(src.length, i + 2);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (c === "'" || c === '"' || (kind === "mysql" && c === "`")) { // 引号串:反斜杠与双写转义
|
|
902
|
+
if (!hasContent) contentStart = i;
|
|
903
|
+
hasContent = true;
|
|
904
|
+
i++;
|
|
905
|
+
while (i < src.length) {
|
|
906
|
+
if (src[i] === "\\") { i += 2; continue; }
|
|
907
|
+
if (src[i] === c) {
|
|
908
|
+
if (src[i + 1] === c) { i += 2; continue; } // 双写转义
|
|
909
|
+
i++;
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
i++;
|
|
913
|
+
}
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if ((kind === "postgresql" || kind === "gaussdb") && c === "$" && src[i + 1] === "$") {
|
|
917
|
+
if (!hasContent) contentStart = i; // $$dollar 串至下一 $$(未闭合则吞到结尾)
|
|
918
|
+
hasContent = true;
|
|
919
|
+
i += 2;
|
|
920
|
+
while (i < src.length && !(src[i] === "$" && src[i + 1] === "$")) i++;
|
|
921
|
+
i = Math.min(src.length, i + 2);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
// DELIMITER <sep> 客户端指令:仅当前语句缓冲无内容时生效(mysql cli 行首语义)
|
|
925
|
+
if (kind === "mysql" && depth === 0 && !hasContent
|
|
926
|
+
&& (i === 0 || !isWord(src[i - 1]))
|
|
927
|
+
&& src.slice(i, i + 9).toUpperCase() === "DELIMITER"
|
|
928
|
+
&& (src[i + 9] === " " || src[i + 9] === "\t")) {
|
|
929
|
+
let j = i + 9, e = j;
|
|
930
|
+
while (e < src.length && src[e] !== "\n" && src[e] !== "\r") e++;
|
|
931
|
+
while (j < e && (src[j] === " " || src[j] === "\t")) j++;
|
|
932
|
+
if (j < e) { sep = src.slice(j, e); stmtStart = e; i = e; continue; }
|
|
933
|
+
}
|
|
934
|
+
// BEGIN/END 词计数(oracle/dmdb):块内分号不切分;END 不把 depth 减到负(顶层 CASE..END 不受影响)
|
|
935
|
+
if ((kind === "oracle" || kind === "dmdb") && (i === 0 || !isWord(src[i - 1]))) {
|
|
936
|
+
if (src.slice(i, i + 5).toUpperCase() === "BEGIN" && !isWord(src[i + 5] || " ")) {
|
|
937
|
+
if (!hasContent) contentStart = i;
|
|
938
|
+
depth++; hasContent = true; i += 5; continue;
|
|
939
|
+
}
|
|
940
|
+
if (src.slice(i, i + 3).toUpperCase() === "END" && !isWord(src[i + 3] || " ")) {
|
|
941
|
+
if (depth > 0) depth--;
|
|
942
|
+
if (!hasContent) contentStart = i;
|
|
943
|
+
hasContent = true; i += 3; continue;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (depth === 0 && src.startsWith(sep, i)) { // 语句终止:收割有内容的语句
|
|
947
|
+
if (hasContent) out.push({ text: src.slice(stmtStart, i), start: contentStart });
|
|
948
|
+
i += sep.length;
|
|
949
|
+
stmtStart = i;
|
|
950
|
+
contentStart = i;
|
|
951
|
+
hasContent = false;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (!/\s/.test(c)) { if (!hasContent) contentStart = i; hasContent = true; }
|
|
955
|
+
i++;
|
|
956
|
+
}
|
|
957
|
+
if (hasContent) out.push({ text: src.slice(stmtStart), start: contentStart }); // 无尾分隔符的最后一条
|
|
958
|
+
return out;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// 结果导出:CSV 侧 NULL/undefined → 空串,含逗号/引号/换行的值按 RFC 4180 双引号包裹且内部
|
|
962
|
+
// 双写转义,行尾统一 \r\n;JSON 侧 NULL 保留 null(不落为空串,避免与空字符串列混淆)
|
|
963
|
+
function csvCell(v) {
|
|
964
|
+
const s = v == null ? "" : String(v);
|
|
965
|
+
return /[",\r\n]/.test(s) ? '"' + s.split('"').join('""') + '"' : s;
|
|
966
|
+
}
|
|
967
|
+
function toCsv(columns, rows) {
|
|
968
|
+
const cols = Array.isArray(columns) ? columns : [];
|
|
969
|
+
const lines = [cols.map(csvCell).join(",")];
|
|
970
|
+
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
971
|
+
lines.push(cols.map((_, i) => csvCell((row || [])[i])).join(","));
|
|
972
|
+
}
|
|
973
|
+
return lines.join("\r\n") + "\r\n";
|
|
974
|
+
}
|
|
975
|
+
function toJson(columns, rows) {
|
|
976
|
+
const cols = Array.isArray(columns) ? columns : [];
|
|
977
|
+
return JSON.stringify((Array.isArray(rows) ? rows : []).map((row) => {
|
|
978
|
+
const o = {};
|
|
979
|
+
cols.forEach((c, i) => { o[c] = (row || [])[i] == null ? null : (row || [])[i]; });
|
|
980
|
+
return o;
|
|
981
|
+
}));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 耗时展示:<1000ms 取整为 "N ms",否则秒保留两位小数
|
|
985
|
+
function fmtMs(n) {
|
|
986
|
+
return n < 1000 ? Math.round(n) + " ms" : (n / 1000).toFixed(2) + " s";
|
|
987
|
+
}
|
|
988
|
+
|
|
615
989
|
// Stryker disable all(buildMongoOp 之后:以下 auditTable/KIND_DEFAULTS/buildEditForm 为连接表单工具,无单元断言)
|
|
616
990
|
function auditTable(audit) {
|
|
617
991
|
return resultTable(
|
|
@@ -674,11 +1048,31 @@ window.__ModuleLoader__.load({
|
|
|
674
1048
|
|
|
675
1049
|
// UI 组件渲染层无单元断言(纯函数层止于此),禁用至文件尾
|
|
676
1050
|
// Stryker disable all
|
|
1051
|
+
// 键盘焦点环(单一事实源,ConnForm/ManageView/treerow/preview tr 共用):样式块本轮冻结、
|
|
1052
|
+
// :focus-visible 无内联 style 通道,focus 事件中探测键盘聚焦后直写 accent 内缩描边,blur 还原。
|
|
1053
|
+
// selfCheck=true 用于行容器(tr):子元素聚焦冒泡(React onFocus 走 focusin)时忽略,仅行自身生效。
|
|
1054
|
+
function focusRingProps(selfCheck) {
|
|
1055
|
+
return {
|
|
1056
|
+
onFocus: (e) => {
|
|
1057
|
+
const el = e.currentTarget;
|
|
1058
|
+
if (selfCheck && e.target !== el) return;
|
|
1059
|
+
if (el.matches && el.matches(":focus-visible")) {
|
|
1060
|
+
el.style.outline = "2px solid var(--dbt-accent, #0a84ff)";
|
|
1061
|
+
el.style.outlineOffset = "-1px";
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
onBlur: (e) => { e.currentTarget.style.outline = ""; e.currentTarget.style.outlineOffset = ""; },
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
677
1067
|
function ConnForm(props) {
|
|
678
1068
|
// 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
|
|
679
1069
|
const [form, setForm] = React.useState(() => props.initial || freshForm());
|
|
680
1070
|
const [dirty, setDirty] = React.useState(() => new Set()); // 用户手改过的字段(切 kind 时保留)
|
|
681
1071
|
const [draftTest, setDraftTest] = React.useState(null); // null | {ok, msg}
|
|
1072
|
+
// 无障碍:URL/分字段切换(及表单挂载)后焦点引导进该模式首个输入,避免键盘/SR 用户滞留切换按钮
|
|
1073
|
+
const firstInputRef = React.useRef(null);
|
|
1074
|
+
React.useEffect(() => { if (firstInputRef.current) firstInputRef.current.focus(); }, [form.mode]);
|
|
1075
|
+
// 按钮焦点环:统一走模块级 focusRingProps()(语义同前)
|
|
682
1076
|
function patch(p) {
|
|
683
1077
|
setForm((prev) => Object.assign({}, prev, p));
|
|
684
1078
|
setDirty((prev) => { const n = new Set(prev); for (const k of Object.keys(p)) n.add(k); return n; });
|
|
@@ -734,6 +1128,9 @@ window.__ModuleLoader__.load({
|
|
|
734
1128
|
setDraftTest({ ok: false, msg: t("testFail") + ":" + (e && e.message ? e.message : String(e)) });
|
|
735
1129
|
}
|
|
736
1130
|
}
|
|
1131
|
+
// aria-label 局部文案:与 placeholder 同源,避免表达式重复
|
|
1132
|
+
const pwLabel = props.initial && props.initial.hasPassword ? t("passwordSaved") : t("fieldsPassword");
|
|
1133
|
+
const idLabel = t("connId") + "(" + t("idAutoHint") + ")";
|
|
737
1134
|
return React.createElement(
|
|
738
1135
|
"div",
|
|
739
1136
|
{ className: "dbt-card" },
|
|
@@ -742,10 +1139,10 @@ window.__ModuleLoader__.load({
|
|
|
742
1139
|
React.createElement(
|
|
743
1140
|
"div",
|
|
744
1141
|
{ className: "dbt-row" },
|
|
745
|
-
React.createElement("select", { value: form.kind, onChange: (e) => switchKind(e.target.value) },
|
|
1142
|
+
React.createElement("select", { value: form.kind, "aria-label": t("fieldKind"), onChange: (e) => switchKind(e.target.value) },
|
|
746
1143
|
DB_KINDS.map((k) => React.createElement("option", { key: k, value: k }, k))),
|
|
747
|
-
React.createElement("input", { placeholder: t("connName"), value: form.name, onChange: (e) => patch({ name: e.target.value }) }),
|
|
748
|
-
React.createElement("input", { placeholder:
|
|
1144
|
+
React.createElement("input", { placeholder: t("connName"), "aria-label": t("connName"), value: form.name, onChange: (e) => patch({ name: e.target.value }) }),
|
|
1145
|
+
React.createElement("input", { placeholder: idLabel, "aria-label": idLabel, value: form.id, disabled: !!props.initial, onChange: (e) => patch({ id: e.target.value }) }),
|
|
749
1146
|
React.createElement("label", { className: "dbt-row", style: { flex: "none" } },
|
|
750
1147
|
React.createElement("input", { type: "checkbox", checked: !!form.ssl, onChange: (e) => patch({ ssl: e.target.checked }) }), t("ssl")),
|
|
751
1148
|
),
|
|
@@ -753,39 +1150,39 @@ window.__ModuleLoader__.load({
|
|
|
753
1150
|
React.createElement(
|
|
754
1151
|
"div",
|
|
755
1152
|
{ className: "dbt-seg" },
|
|
756
|
-
React.createElement("button", { className: form.mode === "url" ? "active" : "", onClick: () => patch({ mode: "url" }) }, t("urlMode")),
|
|
757
|
-
React.createElement("button", { className: form.mode === "fields" ? "active" : "", onClick: reenterFields }, t("fieldsMode")),
|
|
1153
|
+
React.createElement("button", { className: form.mode === "url" ? "active" : "", "aria-pressed": form.mode === "url", onClick: () => patch({ mode: "url" }), ...focusRingProps() }, t("urlMode")),
|
|
1154
|
+
React.createElement("button", { className: form.mode === "fields" ? "active" : "", "aria-pressed": form.mode === "fields", onClick: reenterFields, ...focusRingProps() }, t("fieldsMode")),
|
|
758
1155
|
),
|
|
759
1156
|
form.mode === "url"
|
|
760
1157
|
? React.createElement(
|
|
761
1158
|
"div",
|
|
762
1159
|
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
763
|
-
React.createElement("input", { placeholder: t("url"), value: form.url, onChange: (e) => patch({ url: e.target.value }) }),
|
|
1160
|
+
React.createElement("input", { placeholder: t("url"), "aria-label": t("url") + t("requiredMark"), "aria-required": "true", ref: firstInputRef, value: form.url, onChange: (e) => patch({ url: e.target.value }) }),
|
|
764
1161
|
React.createElement("div", { className: "dbt-row" },
|
|
765
|
-
React.createElement("input", { placeholder: t("urlUser"), value: form.urlUser, onChange: (e) => patch({ urlUser: e.target.value }) }),
|
|
766
|
-
React.createElement("input", { type: "password", placeholder: t("urlPassword"), value: form.urlPassword, onChange: (e) => patch({ urlPassword: e.target.value }) })),
|
|
1162
|
+
React.createElement("input", { placeholder: t("urlUser"), "aria-label": t("urlUser"), value: form.urlUser, onChange: (e) => patch({ urlUser: e.target.value }) }),
|
|
1163
|
+
React.createElement("input", { type: "password", placeholder: t("urlPassword"), "aria-label": t("urlPassword"), value: form.urlPassword, onChange: (e) => patch({ urlPassword: e.target.value }) })),
|
|
767
1164
|
)
|
|
768
1165
|
: React.createElement(
|
|
769
1166
|
"div",
|
|
770
1167
|
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
771
1168
|
React.createElement("div", { className: "dbt-row" },
|
|
772
|
-
React.createElement("input", { placeholder: t("fieldsHost"), value: form.host, onChange: (e) => patch({ host: e.target.value }) }),
|
|
773
|
-
React.createElement("input", { placeholder: t("fieldsPort"), value: form.port, onChange: (e) => patch({ port: e.target.value }) }),
|
|
774
|
-
React.createElement("input", { placeholder: t("fieldsDatabase"), value: form.database, onChange: (e) => patch({ database: e.target.value }) })),
|
|
1169
|
+
React.createElement("input", { placeholder: t("fieldsHost"), "aria-label": t("fieldsHost") + t("requiredMark"), "aria-required": "true", ref: firstInputRef, value: form.host, onChange: (e) => patch({ host: e.target.value }) }),
|
|
1170
|
+
React.createElement("input", { placeholder: t("fieldsPort"), "aria-label": t("fieldsPort"), value: form.port, onChange: (e) => patch({ port: e.target.value }) }),
|
|
1171
|
+
React.createElement("input", { placeholder: t("fieldsDatabase"), "aria-label": t("fieldsDatabase"), value: form.database, onChange: (e) => patch({ database: e.target.value }) })),
|
|
775
1172
|
React.createElement("div", { className: "dbt-row" },
|
|
776
|
-
React.createElement("input", { placeholder: t("fieldsUser"), value: form.user, onChange: (e) => patch({ user: e.target.value }) }),
|
|
777
|
-
React.createElement("input", { type: "password", placeholder:
|
|
1173
|
+
React.createElement("input", { placeholder: t("fieldsUser"), "aria-label": t("fieldsUser") + t("requiredMark"), "aria-required": "true", value: form.user, onChange: (e) => patch({ user: e.target.value }) }),
|
|
1174
|
+
React.createElement("input", { type: "password", placeholder: pwLabel, "aria-label": pwLabel, value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
|
|
778
1175
|
),
|
|
779
1176
|
// 分组 3:操作
|
|
780
1177
|
React.createElement(
|
|
781
1178
|
"div",
|
|
782
1179
|
{ className: "dbt-row" },
|
|
783
|
-
React.createElement("button", { className: "dbt-btn primary", disabled: props.busy, onClick: submit }, props.busy ? t("saving") : t("save")),
|
|
784
|
-
React.createElement("button", { className: "dbt-btn", disabled: draftTest && draftTest.ok === null, onClick: testDraft },
|
|
1180
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: props.busy, onClick: submit, ...focusRingProps() }, props.busy ? t("saving") : t("save")),
|
|
1181
|
+
React.createElement("button", { className: "dbt-btn", disabled: draftTest && draftTest.ok === null, onClick: testDraft, ...focusRingProps() },
|
|
785
1182
|
draftTest && draftTest.ok === null ? t("testing") : t("test")),
|
|
786
|
-
React.createElement("button", { className: "dbt-btn", onClick: props.onCancel }, t("cancel")),
|
|
1183
|
+
React.createElement("button", { className: "dbt-btn", onClick: props.onCancel, ...focusRingProps() }, t("cancel")),
|
|
787
1184
|
draftTest && draftTest.ok !== null
|
|
788
|
-
? React.createElement("span", { className: draftTest.ok ? "dbt-msg" : "dbt-err" }, draftTest.msg)
|
|
1185
|
+
? React.createElement("span", { className: draftTest.ok ? "dbt-msg" : "dbt-err", role: draftTest.ok ? "status" : "alert" }, draftTest.msg)
|
|
789
1186
|
: null,
|
|
790
1187
|
),
|
|
791
1188
|
);
|
|
@@ -797,6 +1194,12 @@ window.__ModuleLoader__.load({
|
|
|
797
1194
|
const [auditOpen, setAuditOpen] = React.useState(false);
|
|
798
1195
|
const [audit, setAudit] = React.useState(null);
|
|
799
1196
|
const [testInfo, setTestInfo] = React.useState({}); // connId -> "ok" | "fail: msg"
|
|
1197
|
+
// 导出/导入流程态:passReq 非空即弹口令窗("export" | "import");fileRef 承载隐藏文件选择器
|
|
1198
|
+
const [passReq, setPassReq] = React.useState(null);
|
|
1199
|
+
const [pendingJson, setPendingJson] = React.useState(null); // 待导入的密文文本(选文件后暂存,口令确认后发送)
|
|
1200
|
+
const [ioInfo, setIoInfo] = React.useState(null); // null | {kind: "ok"|"err", text}
|
|
1201
|
+
const fileRef = React.useRef(null);
|
|
1202
|
+
// 键盘焦点态:统一走模块级 focusRingProps()(描边同 DESIGN.md focus 规范,accent 2px 内缩)
|
|
800
1203
|
|
|
801
1204
|
async function saveConn(body, reset) {
|
|
802
1205
|
await props.run("save", async () => {
|
|
@@ -820,7 +1223,17 @@ window.__ModuleLoader__.load({
|
|
|
820
1223
|
}
|
|
821
1224
|
}
|
|
822
1225
|
async function delConn(c) {
|
|
823
|
-
|
|
1226
|
+
// 删除确认走全局 DangerDialog(askConfirm 通道),与危险 SQL 确认同一对话框与键盘契约;
|
|
1227
|
+
// window.confirm 已移除(原生弹窗无 aria/焦点管理,且样式割裂)
|
|
1228
|
+
const desc = c.safeUrl || [c.kind, c.host ? c.host + (c.port ? ":" + c.port : "") : ""].filter(Boolean).join(" · ") || c.id;
|
|
1229
|
+
const yes = await props.askConfirm({
|
|
1230
|
+
title: t("deleteConnTitle"),
|
|
1231
|
+
statement: desc,
|
|
1232
|
+
hint: t("deleteConnConfirm", { name: c.name || c.id }),
|
|
1233
|
+
confirmLabel: t("delete"),
|
|
1234
|
+
target: { conn: c.name || c.id, kind: c.kind || "", db: "", mode: "" },
|
|
1235
|
+
});
|
|
1236
|
+
if (!yes) return;
|
|
824
1237
|
await props.run("del", async () => {
|
|
825
1238
|
await api("connections/" + encodeURIComponent(c.id), { method: "DELETE" });
|
|
826
1239
|
await reload();
|
|
@@ -835,6 +1248,64 @@ window.__ModuleLoader__.load({
|
|
|
835
1248
|
}
|
|
836
1249
|
}
|
|
837
1250
|
|
|
1251
|
+
// 口令弹窗确认回调:按 passReq 分流导出/导入;value 为 null 即取消
|
|
1252
|
+
async function onPassphrase(value) {
|
|
1253
|
+
const mode = passReq;
|
|
1254
|
+
setPassReq(null);
|
|
1255
|
+
if (value === null || value === undefined) { setPendingJson(null); return; } // 取消即清密文
|
|
1256
|
+
if (mode === "export") await doExport(value);
|
|
1257
|
+
else if (mode === "import") await doImport(value);
|
|
1258
|
+
setPendingJson(null); // 完成即清密文,避免敏感数据滞留组件状态
|
|
1259
|
+
}
|
|
1260
|
+
// 导出:POST /connections/export 拿密文 json → Blob + a[download] 落盘(文件名带日期戳)
|
|
1261
|
+
async function doExport(passphrase) {
|
|
1262
|
+
await props.run("export", async () => {
|
|
1263
|
+
const data = await api("connections/export", { method: "POST", body: { passphrase } });
|
|
1264
|
+
const json = data && data.json;
|
|
1265
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); // YYYYMMDD
|
|
1266
|
+
const blob = new Blob([typeof json === "string" ? json : JSON.stringify(json, null, 2)], { type: "application/json" });
|
|
1267
|
+
const url = URL.createObjectURL(blob);
|
|
1268
|
+
const a = document.createElement("a");
|
|
1269
|
+
a.href = url;
|
|
1270
|
+
a.download = "dsh-db-connections-" + stamp + ".enc.json";
|
|
1271
|
+
document.body.appendChild(a);
|
|
1272
|
+
a.click();
|
|
1273
|
+
a.remove();
|
|
1274
|
+
// 延迟回收:部分浏览器在 click 后才异步 fetch blob,同步 revoke 可能中断下载(与结果导出 download 同模式)
|
|
1275
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1276
|
+
// 服务端回传权威条数 count;缺失时回退本地列表(不声称精确数字)
|
|
1277
|
+
const n = (data && typeof data.count === "number") ? data.count : conns.length;
|
|
1278
|
+
setIoInfo({ kind: "ok", text: t("exportDone", { n }) });
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
// 导入:密文已在 pendingJson;口令错(INVALID_ARGUMENT + 解密失败文案)给专用提示,其余透传后端 message
|
|
1282
|
+
async function doImport(passphrase) {
|
|
1283
|
+
const json = pendingJson;
|
|
1284
|
+
await props.run("import", async () => {
|
|
1285
|
+
const data = await api("connections/import", { method: "POST", body: { json, passphrase } });
|
|
1286
|
+
await reload();
|
|
1287
|
+
const failed = (data && data.errors ? data.errors.length : 0);
|
|
1288
|
+
const i = (data && data.imported) || 0;
|
|
1289
|
+
const s = (data && data.skipped ? data.skipped.length : 0);
|
|
1290
|
+
setIoInfo(failed > 0
|
|
1291
|
+
? { kind: "err", text: t("importPartialFail", { i, s, f: failed }) }
|
|
1292
|
+
: { kind: "ok", text: t("importResult", { i, s }) });
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
// 隐藏文件选择器回调:FileReader 读文本后暂存,再弹口令窗;读失败走通用错误通道
|
|
1296
|
+
function onPickFile(e) {
|
|
1297
|
+
const file = e.target.files && e.target.files[0];
|
|
1298
|
+
e.target.value = ""; // 允许重复选同一文件
|
|
1299
|
+
if (!file) return;
|
|
1300
|
+
const reader = new FileReader();
|
|
1301
|
+
reader.onload = () => {
|
|
1302
|
+
setPendingJson(String(reader.result || ""));
|
|
1303
|
+
setPassReq("import");
|
|
1304
|
+
};
|
|
1305
|
+
reader.onerror = () => props.onError(reader.error || new Error(t("loadFailed")));
|
|
1306
|
+
reader.readAsText(file);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
838
1309
|
if (editing) {
|
|
839
1310
|
return React.createElement(ConnForm, {
|
|
840
1311
|
initial: editing === "new" ? null : buildEditForm(editing),
|
|
@@ -846,16 +1317,33 @@ window.__ModuleLoader__.load({
|
|
|
846
1317
|
return React.createElement(
|
|
847
1318
|
"div",
|
|
848
1319
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
849
|
-
//
|
|
1320
|
+
// 顶部操作行:审计 + 导出/导入在左,「新建连接」主按钮置右侧
|
|
850
1321
|
React.createElement(
|
|
851
1322
|
"div",
|
|
852
1323
|
{ className: "dbt-row", style: { justifyContent: "space-between" } },
|
|
853
|
-
React.createElement("button", { className: "dbt-btn", onClick: loadAudit }, t("auditTitle", { n: 50 }).split("(")[0].split(" (")[0]),
|
|
854
|
-
React.createElement("button", { className: "dbt-btn primary", onClick: () => setEditing("new") }, t("newConn")),
|
|
855
|
-
),
|
|
856
|
-
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
857
|
-
// iOS inset grouped:一张卡装全部连接,行间 hairline 分隔
|
|
858
1324
|
React.createElement(
|
|
1325
|
+
"div",
|
|
1326
|
+
{ className: "dbt-row" },
|
|
1327
|
+
React.createElement("button", { className: "dbt-btn", "aria-expanded": auditOpen, ...focusRingProps(), onClick: loadAudit }, t("auditShort")),
|
|
1328
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "export", ...focusRingProps(), onClick: () => setPassReq("export") }, t("exportConns")),
|
|
1329
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "import", ...focusRingProps(), onClick: () => { if (fileRef.current) fileRef.current.click(); } }, t("importConns")),
|
|
1330
|
+
),
|
|
1331
|
+
React.createElement("button", { className: "dbt-btn primary", ...focusRingProps(), onClick: () => setEditing("new") }, t("newConn")),
|
|
1332
|
+
),
|
|
1333
|
+
// 隐藏文件选择器:仅导入用,accept 限定 .json
|
|
1334
|
+
React.createElement("input", { type: "file", accept: ".json", "aria-label": t("pickFile"), style: { display: "none" }, ref: fileRef, onChange: onPickFile }),
|
|
1335
|
+
// 导出/导入就地反馈(与测试反馈同语言:dbt-msg 绿 / dbt-err 红)
|
|
1336
|
+
ioInfo ? React.createElement("div", { role: ioInfo.kind === "ok" ? "status" : "alert", className: ioInfo.kind === "ok" ? "dbt-msg" : "dbt-err" }, ioInfo.text) : null,
|
|
1337
|
+
conns.length === 0
|
|
1338
|
+
? // 空状态引导:一句引导 + 主按钮直达新增表单(state 切换在 ManageView 内)
|
|
1339
|
+
React.createElement(
|
|
1340
|
+
"div",
|
|
1341
|
+
{ className: "dbt-card", style: { alignItems: "center", textAlign: "center" } },
|
|
1342
|
+
React.createElement("div", { className: "dbt-muted" }, t("noConnsGuide")),
|
|
1343
|
+
React.createElement("button", { className: "dbt-btn primary", ...focusRingProps(), onClick: () => setEditing("new") }, t("addConn")),
|
|
1344
|
+
)
|
|
1345
|
+
: // iOS inset grouped:一张卡装全部连接,行间 hairline 分隔
|
|
1346
|
+
React.createElement(
|
|
859
1347
|
"div",
|
|
860
1348
|
{ className: "dbt-group" },
|
|
861
1349
|
conns.map((c) =>
|
|
@@ -869,7 +1357,8 @@ window.__ModuleLoader__.load({
|
|
|
869
1357
|
React.createElement(
|
|
870
1358
|
"div",
|
|
871
1359
|
{ className: "dbt-row" },
|
|
872
|
-
|
|
1360
|
+
// kind 为技术元数据,降次级色避免与名称主级争夺权重(DESIGN.md 次级文字语义)
|
|
1361
|
+
React.createElement("span", { style: { background: "var(--dbt-surface-strong, rgba(120,120,128,.18))", color: "var(--dbt-text-secondary, rgba(235,235,245,.6))", borderRadius: "6px", padding: "2px 6px", fontFamily: MONO_FONT, fontSize: "var(--dbt-caption, 11px)", lineHeight: 1.45 } }, c.kind),
|
|
873
1362
|
React.createElement("strong", { style: { fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
|
|
874
1363
|
),
|
|
875
1364
|
(c.safeUrl || c.host) ? React.createElement(
|
|
@@ -877,15 +1366,16 @@ window.__ModuleLoader__.load({
|
|
|
877
1366
|
{ className: "dbt-muted", style: { fontFamily: MONO_FONT, fontSize: 11, wordBreak: "break-all" } },
|
|
878
1367
|
c.safeUrl || (c.host + ":" + (c.port || "")),
|
|
879
1368
|
) : null,
|
|
880
|
-
|
|
1369
|
+
// 测试反馈三态:✓ 绿=成功 / ✗ 红=失败 / 其余(测试中…)中性灰;DESIGN.md 语义色 红=失败 绿=成功,role=status 供读屏播报
|
|
1370
|
+
testInfo[c.id] ? React.createElement("div", { role: "status", className: testInfo[c.id].startsWith("✓") ? "dbt-msg" : testInfo[c.id].startsWith("✗") ? "dbt-err" : "dbt-muted" }, testInfo[c.id]) : null,
|
|
881
1371
|
),
|
|
882
1372
|
// 右列:动作按钮(次要语义,danger 仅删除)
|
|
883
1373
|
React.createElement(
|
|
884
1374
|
"div",
|
|
885
1375
|
{ className: "dbt-row", style: { flex: "none" } },
|
|
886
|
-
React.createElement("button", { className: "dbt-btn", disabled: busy === "test", onClick: () => testConn(c.id) }, t("test")),
|
|
887
|
-
React.createElement("button", { className: "dbt-btn", onClick: () => setEditing(c) }, t("edit")),
|
|
888
|
-
React.createElement("button", { className: "dbt-btn danger", disabled: busy === "del", onClick: () => delConn(c) }, t("delete")),
|
|
1376
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "test", ...focusRingProps(), onClick: () => testConn(c.id) }, t("test")),
|
|
1377
|
+
React.createElement("button", { className: "dbt-btn", ...focusRingProps(), onClick: () => setEditing(c) }, t("edit")),
|
|
1378
|
+
React.createElement("button", { className: "dbt-btn danger", disabled: busy === "del", ...focusRingProps(), onClick: () => delConn(c) }, t("delete")),
|
|
889
1379
|
),
|
|
890
1380
|
),
|
|
891
1381
|
),
|
|
@@ -893,19 +1383,27 @@ window.__ModuleLoader__.load({
|
|
|
893
1383
|
auditOpen
|
|
894
1384
|
? React.createElement(
|
|
895
1385
|
"div",
|
|
896
|
-
|
|
1386
|
+
// 展开入场复用既有 dbt-in(仅 opacity/transform、.22s、var(--dbt-ease);reduced-motion 全局已关),与视图切换语言一致
|
|
1387
|
+
{ className: "dbt-card", style: { animation: "dbt-in .22s var(--dbt-ease)" } },
|
|
897
1388
|
React.createElement("strong", null, t("auditTitle", { n: 50 })),
|
|
898
1389
|
!props.projectPath ? React.createElement("div", { className: "dbt-muted" }, t("projectUnbound")) :
|
|
899
1390
|
!audit || audit.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("auditEmpty")) :
|
|
900
1391
|
auditTable(audit),
|
|
901
1392
|
)
|
|
902
1393
|
: null,
|
|
1394
|
+
// 口令弹窗:导出/导入共用;取消(null)不动作
|
|
1395
|
+
passReq ? React.createElement(PassphraseDialog, {
|
|
1396
|
+
title: passReq === "export" ? t("exportConns") : t("importConns"),
|
|
1397
|
+
confirmLabel: passReq === "export" ? t("exportConns") : t("importConns"),
|
|
1398
|
+
onClose: onPassphrase,
|
|
1399
|
+
}) : null,
|
|
903
1400
|
);
|
|
904
1401
|
}
|
|
905
1402
|
|
|
906
1403
|
/* ---------------- 项目授权 ---------------- */
|
|
907
1404
|
function GrantsView(props) {
|
|
908
1405
|
const { conns, grants, projectPath } = props;
|
|
1406
|
+
const grantedCount = (grants || []).length;
|
|
909
1407
|
function modeOf(connId) {
|
|
910
1408
|
const g = (grants || []).find((x) => x.connId === connId);
|
|
911
1409
|
return g ? g.mode : "";
|
|
@@ -917,11 +1415,36 @@ window.__ModuleLoader__.load({
|
|
|
917
1415
|
await props.reload();
|
|
918
1416
|
});
|
|
919
1417
|
}
|
|
1418
|
+
// 模式徽标:语义色描边胶囊(rw=系统绿成功语义、ro=次级灰中性),颜色+文字双编码;
|
|
1419
|
+
// 描边胶囊复用 NULL chip 语言(11px/600、1px 8px、999px),light 模式自动跟 token 反转
|
|
1420
|
+
function modeBadge(m) {
|
|
1421
|
+
if (m !== "ro" && m !== "rw") return null;
|
|
1422
|
+
const color = m === "rw" ? "var(--dbt-success, #30d158)" : "var(--dbt-text-secondary, rgba(235,235,245,.6))";
|
|
1423
|
+
return React.createElement("span", {
|
|
1424
|
+
"aria-hidden": "true",
|
|
1425
|
+
style: {
|
|
1426
|
+
flex: "none",
|
|
1427
|
+
border: "1px solid " + color,
|
|
1428
|
+
color: color,
|
|
1429
|
+
borderRadius: "999px",
|
|
1430
|
+
padding: "1px 8px",
|
|
1431
|
+
fontSize: "var(--dbt-caption, 11px)",
|
|
1432
|
+
fontWeight: 600,
|
|
1433
|
+
lineHeight: 1.45,
|
|
1434
|
+
},
|
|
1435
|
+
}, m === "rw" ? t("modeBadgeRw") : t("modeBadgeRo"));
|
|
1436
|
+
}
|
|
920
1437
|
return React.createElement(
|
|
921
1438
|
"div",
|
|
922
1439
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
923
1440
|
React.createElement("div", { className: "dbt-muted" }, t("grantsHint")),
|
|
924
1441
|
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
1442
|
+
// 状态行:空授权引导 / 已授权计数摘要共用一槽(授权变更后 reload 触发重渲染,polite 播报)
|
|
1443
|
+
React.createElement(
|
|
1444
|
+
"div",
|
|
1445
|
+
{ className: "dbt-muted", "aria-live": "polite" },
|
|
1446
|
+
grantedCount === 0 ? t("grantsEmptyHint") : t("grantsSummary", { granted: grantedCount, total: conns.length }),
|
|
1447
|
+
),
|
|
925
1448
|
// iOS inset grouped:授权行装一张卡,行间 hairline(授权链路:seg 点击 → PUT/DELETE /grants)
|
|
926
1449
|
React.createElement(
|
|
927
1450
|
"div",
|
|
@@ -931,20 +1454,23 @@ window.__ModuleLoader__.load({
|
|
|
931
1454
|
return React.createElement(
|
|
932
1455
|
"div",
|
|
933
1456
|
{ className: "dbt-listrow", key: c.id },
|
|
934
|
-
//
|
|
1457
|
+
// 主行对齐连接管理:kind 填充 pill + 名称 13px semibold + 模式徽标(seg 为权威状态源,徽标 aria-hidden 免重复播报)
|
|
935
1458
|
React.createElement(
|
|
936
1459
|
"div",
|
|
937
|
-
{ style: { flex: 1, minWidth: 0
|
|
938
|
-
React.createElement("span", { style: {
|
|
939
|
-
React.createElement("
|
|
1460
|
+
{ className: "dbt-row", style: { flex: 1, minWidth: 0 } },
|
|
1461
|
+
React.createElement("span", { style: { background: "var(--dbt-surface-strong, rgba(120,120,128,.18))", borderRadius: "6px", padding: "2px 6px", fontFamily: MONO_FONT, fontSize: "var(--dbt-caption, 11px)", lineHeight: 1.45 } }, c.kind),
|
|
1462
|
+
React.createElement("strong", { style: { fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
|
|
1463
|
+
modeBadge(m),
|
|
940
1464
|
),
|
|
941
1465
|
React.createElement(
|
|
942
1466
|
"div",
|
|
943
|
-
{ className: "dbt-seg", "aria-label": c.name || c.id },
|
|
1467
|
+
{ className: "dbt-seg", role: "radiogroup", "aria-label": t("grantModeGroup", { name: c.name || c.id }) },
|
|
944
1468
|
["", "ro", "rw"].map((mode) =>
|
|
945
1469
|
React.createElement("button", {
|
|
946
1470
|
key: mode || "none",
|
|
947
1471
|
className: mode === m ? "active" : "",
|
|
1472
|
+
role: "radio",
|
|
1473
|
+
"aria-checked": mode === m,
|
|
948
1474
|
disabled: props.busy === "grant",
|
|
949
1475
|
onClick: () => setGrant(c.id, mode),
|
|
950
1476
|
}, mode === "" ? t("modeNone") : mode === "ro" ? t("modeRo") : t("modeRw")),
|
|
@@ -997,13 +1523,14 @@ window.__ModuleLoader__.load({
|
|
|
997
1523
|
}));
|
|
998
1524
|
}
|
|
999
1525
|
// autoExpand:挂载后自动展开第一层级(连接节点),复用 toggleConn 的展开+懒加载;
|
|
1000
|
-
// 加载失败仍走既有就地错误行机制。声明在清缓存 effect 之后:项目切换时先清 open
|
|
1526
|
+
// 加载失败仍走既有就地错误行机制。声明在清缓存 effect 之后:项目切换时先清 open 再重新展开。
|
|
1527
|
+
// autoExpand 受控于父级(BrowsePane 首次激活才置 true):常驻挂载下隐藏视图不自动发请求
|
|
1001
1528
|
React.useEffect(() => {
|
|
1002
1529
|
if (!autoExpand) return;
|
|
1003
1530
|
for (const c of conns) {
|
|
1004
1531
|
if (!open["c:" + c.id]) toggleConn(c);
|
|
1005
1532
|
}
|
|
1006
|
-
}, [conns, projectPath]); // eslint-disable-line
|
|
1533
|
+
}, [conns, projectPath, autoExpand]); // eslint-disable-line
|
|
1007
1534
|
// PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
|
|
1008
1535
|
const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
|
|
1009
1536
|
function toggleDb(c, d) {
|
|
@@ -1024,18 +1551,61 @@ window.__ModuleLoader__.load({
|
|
|
1024
1551
|
setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
|
|
1025
1552
|
}));
|
|
1026
1553
|
}
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1554
|
+
// 树键盘导航(WAI-ARIA treeview / roving tabindex):Tab 仅进停靠行,方向键在可见行间移动;
|
|
1555
|
+
// 右箭头展开、左箭头收起或回到父级(父级 = DOM 序向上第一个更浅层级行),Home/End 跳首尾
|
|
1556
|
+
const treeRef = React.useRef(null);
|
|
1557
|
+
const [rovingKey, setRovingKey] = React.useState(null); // roving 停靠行 key;null 时兜底首行
|
|
1558
|
+
const liveKeysRef = React.useRef(null); // 上一轮渲染的交互行 key 集:停靠行被折叠/清缓存后失效时回退首行
|
|
1559
|
+
const roving = rovingKey && liveKeysRef.current && liveKeysRef.current.has(rovingKey) ? rovingKey : null;
|
|
1560
|
+
const firstKey = conns.length ? "c:" + conns[0].id : null; // 树首行恒为第一个连接行
|
|
1561
|
+
const liveKeys = new Set(); // 本轮渲染收集,循环结束后写回 liveKeysRef
|
|
1562
|
+
function moveFocus(rows, i) {
|
|
1563
|
+
const row = rows[i];
|
|
1564
|
+
if (!row) return;
|
|
1565
|
+
row.focus();
|
|
1566
|
+
setRovingKey(row.getAttribute("data-treekey"));
|
|
1567
|
+
}
|
|
1568
|
+
function onTreeKeyDown(e) {
|
|
1569
|
+
const row = e.target && e.target.closest ? e.target.closest('[role="treeitem"]') : null;
|
|
1570
|
+
if (!row || !treeRef.current) return;
|
|
1571
|
+
const rows = Array.prototype.slice.call(treeRef.current.querySelectorAll('[role="treeitem"]'));
|
|
1572
|
+
const i = rows.indexOf(row);
|
|
1573
|
+
const exp = row.getAttribute("aria-expanded");
|
|
1574
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
1575
|
+
e.preventDefault();
|
|
1576
|
+
const j = e.key === "ArrowDown" ? i + 1 : i - 1;
|
|
1577
|
+
if (j >= 0 && j < rows.length) moveFocus(rows, j);
|
|
1578
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
1579
|
+
e.preventDefault();
|
|
1580
|
+
moveFocus(rows, e.key === "Home" ? 0 : rows.length - 1);
|
|
1581
|
+
} else if (e.key === "ArrowRight") {
|
|
1582
|
+
if (exp === "false") { e.preventDefault(); row.click(); } // 展开当前节点(复用点击行为)
|
|
1583
|
+
} else if (e.key === "ArrowLeft") {
|
|
1584
|
+
if (exp === "true") { e.preventDefault(); row.click(); return; } // 收起当前节点
|
|
1585
|
+
const lv = Number(row.getAttribute("data-level")) || 1;
|
|
1586
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
1587
|
+
if ((Number(rows[j].getAttribute("data-level")) || 1) < lv) { e.preventDefault(); moveFocus(rows, j); break; }
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注;交互行为 treeitem(roving tabindex 见上)
|
|
1029
1592
|
function treerow(key, level, isOpen, leaf, label, onClick, active, extra) {
|
|
1593
|
+
if (onClick) liveKeys.add(key);
|
|
1030
1594
|
return React.createElement(
|
|
1031
1595
|
"div",
|
|
1032
1596
|
{
|
|
1033
1597
|
className: "dbt-treerow" + (active ? " active" : ""), key,
|
|
1034
1598
|
role: onClick ? "treeitem" : undefined,
|
|
1035
|
-
|
|
1036
|
-
|
|
1599
|
+
"aria-expanded": onClick && !leaf ? isOpen : undefined, // 可展开节点披露展开态(叶/占位行不适用)
|
|
1600
|
+
"aria-level": onClick ? level + 1 : undefined, // 平铺树以 level 标注层级(WAI-ARIA treeview)
|
|
1601
|
+
"aria-selected": onClick && active ? true : undefined, // 选中表行的可访问标注(对应 .active 视觉态)
|
|
1602
|
+
"data-level": onClick ? level + 1 : undefined, // 左箭头父级查找依赖
|
|
1603
|
+
"data-treekey": key,
|
|
1604
|
+
tabIndex: onClick ? ((roving || firstKey) === key ? 0 : -1) : undefined,
|
|
1605
|
+
onClick: onClick ? (e) => { setRovingKey(key); onClick(e); } : undefined,
|
|
1606
|
+
...(onClick ? focusRingProps() : null), // 焦点描边统一走模块级 helper
|
|
1037
1607
|
onKeyDown: onClick ? (e) => {
|
|
1038
|
-
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick({ currentTarget: e.currentTarget }); }
|
|
1608
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setRovingKey(key); onClick({ currentTarget: e.currentTarget }); }
|
|
1039
1609
|
} : undefined,
|
|
1040
1610
|
style: { paddingLeft: 12 + level * 16 },
|
|
1041
1611
|
},
|
|
@@ -1102,13 +1672,15 @@ window.__ModuleLoader__.load({
|
|
|
1102
1672
|
}
|
|
1103
1673
|
}
|
|
1104
1674
|
|
|
1675
|
+
// 停靠行失效检测以下一轮渲染的可见集合为准(commit 后写回,不污染渲染期)
|
|
1676
|
+
React.useLayoutEffect(() => { liveKeysRef.current = liveKeys; });
|
|
1105
1677
|
return React.createElement(
|
|
1106
1678
|
"div",
|
|
1107
1679
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
1108
1680
|
// Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件就地预览)
|
|
1109
1681
|
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
1110
1682
|
React.createElement("div", { className: "dbt-group" },
|
|
1111
|
-
React.createElement("div", { className: "dbt-tree" }, treeRows)),
|
|
1683
|
+
React.createElement("div", { className: "dbt-tree", role: "tree", ref: treeRef, onKeyDown: onTreeKeyDown }, treeRows)),
|
|
1112
1684
|
);
|
|
1113
1685
|
}
|
|
1114
1686
|
|
|
@@ -1117,7 +1689,8 @@ window.__ModuleLoader__.load({
|
|
|
1117
1689
|
/* ---------------- PreviewGrid:数据预览网格(Navicat 式单元格查看/编辑) ---------------- */
|
|
1118
1690
|
// props 契约:{ preview:{columns,rows,rowCount,truncated?}, schema:ColumnInfo[](SQL/mongo;redis 为
|
|
1119
1691
|
// type/encoding/ttl/length,无 PRI), kind, editable:rw 授权布尔, tableType:sel.table.type(redis 键类型),
|
|
1120
|
-
// tableName, dbRef, schemaName, connId, projectPath, onSaved:保存成功回调,
|
|
1692
|
+
// tableName, dbRef, schemaName, connId, connName, grantMode, projectPath, onSaved:保存成功回调,
|
|
1693
|
+
// askConfirm:runGuarded 确认回调(确认信息附带 target:{conn,kind,db,mode} 与 params) }
|
|
1121
1694
|
// redis 各键类型可就地编辑的数据列(与 buildRedisOp 分支保持一致;键名/字段名等定位列只读)
|
|
1122
1695
|
const REDIS_EDITABLE_COLS = {
|
|
1123
1696
|
string: ["value"],
|
|
@@ -1126,13 +1699,13 @@ window.__ModuleLoader__.load({
|
|
|
1126
1699
|
set: ["member"],
|
|
1127
1700
|
};
|
|
1128
1701
|
function PreviewGrid(props) {
|
|
1129
|
-
const { preview, schema, kind, editable, tableType, tableName, dbRef, schemaName, connId, projectPath, onSaved, askConfirm } = props;
|
|
1702
|
+
const { preview, schema, kind, editable, tableType, tableName, dbRef, schemaName, connId, connName, grantMode, projectPath, onSaved, askConfirm } = props;
|
|
1130
1703
|
const [sel, setSel] = React.useState(null); // {rowIdx, colName, cell} 底部详情栏当前单元格(null=收起底栏)
|
|
1131
1704
|
const [text, setText] = React.useState(""); // 详情栏 textarea 值(选中即就位)
|
|
1132
1705
|
const [editNull, setEditNull] = React.useState(false); // 「设为 NULL」勾选(null 单元格默认勾选)
|
|
1133
1706
|
const [cellErr, setCellErr] = React.useState("");
|
|
1134
1707
|
const [busy, setBusy] = React.useState(false);
|
|
1135
|
-
const [copied, setCopied] = React.useState(
|
|
1708
|
+
const [copied, setCopied] = React.useState(""); // "" | "ok" | "fail"(复制结果反馈,1.5s 后还原 "")
|
|
1136
1709
|
const isMongo = kind === "mongodb";
|
|
1137
1710
|
// SQL 系主键列名(schema 中 key==="PRI");mongo 由 _id 单列承担,redis 无主键概念
|
|
1138
1711
|
const pkCols = (!isMongo && kind !== "redis" && Array.isArray(schema))
|
|
@@ -1150,9 +1723,13 @@ window.__ModuleLoader__.load({
|
|
|
1150
1723
|
function copyCell() {
|
|
1151
1724
|
if (!sel) return;
|
|
1152
1725
|
navigator.clipboard.writeText(String(sel.cell)).then(() => {
|
|
1153
|
-
setCopied(
|
|
1154
|
-
window.setTimeout(() => setCopied(
|
|
1155
|
-
}, () => {
|
|
1726
|
+
setCopied("ok");
|
|
1727
|
+
window.setTimeout(() => setCopied(""), 1500);
|
|
1728
|
+
}, () => {
|
|
1729
|
+
// 剪贴板不可用(无权限等):短暂显示失败文案后还原,不再静默
|
|
1730
|
+
setCopied("fail");
|
|
1731
|
+
window.setTimeout(() => setCopied(""), 1500);
|
|
1732
|
+
});
|
|
1156
1733
|
}
|
|
1157
1734
|
|
|
1158
1735
|
// 单元格编辑资格:返回 null=可编辑,否则为原因 i18n key(底栏 .dbt-readhint 展示)
|
|
@@ -1194,9 +1771,13 @@ window.__ModuleLoader__.load({
|
|
|
1194
1771
|
setBusy(true);
|
|
1195
1772
|
try {
|
|
1196
1773
|
for (const op of r.ops) {
|
|
1774
|
+
// 确认信息附带目标数据(连接名/kind/库/授权模式)与实际参数(statement 中 ? 占位符的值)
|
|
1197
1775
|
await runGuarded(
|
|
1198
1776
|
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: op.statement, params: op.params, database: op.database, challengeId } }),
|
|
1199
|
-
askConfirm,
|
|
1777
|
+
(info) => askConfirm(Object.assign({}, info, {
|
|
1778
|
+
target: { conn: connName || connId, kind: kind || "", db: dbRef || "", mode: grantMode || "" },
|
|
1779
|
+
params: op.params,
|
|
1780
|
+
})),
|
|
1200
1781
|
);
|
|
1201
1782
|
}
|
|
1202
1783
|
setSel(null);
|
|
@@ -1219,8 +1800,8 @@ window.__ModuleLoader__.load({
|
|
|
1219
1800
|
return React.createElement(
|
|
1220
1801
|
"div",
|
|
1221
1802
|
{ className: "dbt-browse-root", style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0 } },
|
|
1222
|
-
// 顶层只读提示:授权为 ro
|
|
1223
|
-
!editable ? React.createElement("div", { className: "dbt-readhint" }, t("readOnlyRo")) : null,
|
|
1803
|
+
// 顶层只读提示:授权为 ro 时整格只读,单元格仍可点开底栏查看完整值;10px 分区 gap
|
|
1804
|
+
!editable ? React.createElement("div", { className: "dbt-readhint", style: { marginBottom: 10 } }, t("readOnlyRo")) : null,
|
|
1224
1805
|
// 预览表格区(flex:1 撑满剩余空间;底部详情栏在表格之后、面板流内)
|
|
1225
1806
|
React.createElement(
|
|
1226
1807
|
"div",
|
|
@@ -1240,7 +1821,16 @@ window.__ModuleLoader__.load({
|
|
|
1240
1821
|
"tbody",
|
|
1241
1822
|
null,
|
|
1242
1823
|
(preview.rows || []).map((row, i) =>
|
|
1243
|
-
|
|
1824
|
+
// 行级键盘可达:Tab 聚焦行、Enter 打开该行首格详情(与单击同走 toggleCell,同格再按收起);
|
|
1825
|
+
// 焦点在行内 button 上时 Enter 已由 button 处理,target!==currentTarget 直接放行防双重触发
|
|
1826
|
+
React.createElement("tr", {
|
|
1827
|
+
key: i, tabIndex: 0,
|
|
1828
|
+
onKeyDown: (e) => {
|
|
1829
|
+
if (e.key !== "Enter" || e.target !== e.currentTarget || !(preview.columns || []).length) return;
|
|
1830
|
+
toggleCell(i, preview.columns[0], row[0]);
|
|
1831
|
+
},
|
|
1832
|
+
...focusRingProps(true), // 行容器:忽略子元素聚焦冒泡,仅行自身键盘聚焦描边
|
|
1833
|
+
},
|
|
1244
1834
|
(preview.columns || []).map((colName, j) =>
|
|
1245
1835
|
React.createElement("td", { key: j, title: row[j] === null ? "NULL" : String(row[j]) },
|
|
1246
1836
|
React.createElement("button", {
|
|
@@ -1251,16 +1841,18 @@ window.__ModuleLoader__.load({
|
|
|
1251
1841
|
),
|
|
1252
1842
|
),
|
|
1253
1843
|
),
|
|
1254
|
-
// 分页条(BrowsePane
|
|
1255
|
-
|
|
1844
|
+
// 分页条(BrowsePane 传入)在详情栏之上,详情栏钉在面板最底部;
|
|
1845
|
+
// 包裹层 flex:none 承接钉底语义并补 10px 分区 gap(footer 自身无 margin,与底栏 10px 节奏对齐)
|
|
1846
|
+
props.children ? React.createElement("div", { style: { flex: "none", marginTop: 10 } }, props.children) : null,
|
|
1256
1847
|
// 底部详情栏(Navicat/检查器式):预览表格下方一栏,textarea 直接可编辑,点保存写回
|
|
1257
1848
|
sel ? React.createElement(
|
|
1258
1849
|
"div",
|
|
1259
1850
|
{ className: "dbt-celldetail" },
|
|
1260
1851
|
// 标题行:完整值 · 列名;null 单元格带 NULL 芯片;只读原因随之展示
|
|
1261
1852
|
React.createElement(
|
|
1853
|
+
// 标题行 flex + 8px 控件 gap:值/列名、NULL 芯片、只读原因不再挤作一团;长列名 wrap 兜底
|
|
1262
1854
|
"div",
|
|
1263
|
-
|
|
1855
|
+
{ style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
|
|
1264
1856
|
React.createElement("strong", null, t("cellValue") + " · " + sel.colName),
|
|
1265
1857
|
sel.cell === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : null,
|
|
1266
1858
|
selReason ? React.createElement("span", { className: "dbt-readhint" }, t(selReason)) : null,
|
|
@@ -1284,7 +1876,11 @@ window.__ModuleLoader__.load({
|
|
|
1284
1876
|
React.createElement(
|
|
1285
1877
|
"div",
|
|
1286
1878
|
{ className: "dbt-row" },
|
|
1287
|
-
|
|
1879
|
+
// 复制反馈三态附语义色(DESIGN.md:绿=成功、红=错误),静态内联色值走 --dbt-* token + 回退
|
|
1880
|
+
React.createElement("button", {
|
|
1881
|
+
className: "dbt-btn", onClick: copyCell,
|
|
1882
|
+
style: copied === "ok" ? { color: "var(--dbt-success,#30d158)" } : copied === "fail" ? { color: "var(--dbt-danger,#ff453a)" } : undefined,
|
|
1883
|
+
}, copied === "ok" ? t("copied") : copied === "fail" ? t("copyFailed") : t("copyBtn")),
|
|
1288
1884
|
selReason === null ? React.createElement("button", { className: "dbt-btn primary", disabled: busy, onClick: saveCell }, busy ? t("cellSaving") : t("save")) : null,
|
|
1289
1885
|
React.createElement("button", { className: "dbt-btn", onClick: () => setSel(null) }, t("close")),
|
|
1290
1886
|
),
|
|
@@ -1294,7 +1890,8 @@ window.__ModuleLoader__.load({
|
|
|
1294
1890
|
|
|
1295
1891
|
// Navicat 式浏览面板(内嵌侧边栏):上半对象树选库-表,下半结构/数据预览,点表即看,不再弹窗
|
|
1296
1892
|
function BrowsePane(props) {
|
|
1297
|
-
const { conns, projectPath, grants, askConfirm } = props;
|
|
1893
|
+
const { conns, projectPath, grants, askConfirm, active } = props;
|
|
1894
|
+
// active:所属 tab 是否激活(Panel 常驻挂载后传入);首次激活才展开树并拉取,隐藏不发请求
|
|
1298
1895
|
// grants(editable 判定)与 askConfirm(单元格写回确认)下传 PreviewGrid
|
|
1299
1896
|
const [sel, setSel] = React.useState(null); // 面板内当前选中(null=未选表,内容区显示引导空态)
|
|
1300
1897
|
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
@@ -1329,8 +1926,8 @@ window.__ModuleLoader__.load({
|
|
|
1329
1926
|
// 内容区互斥状态:未选中引导 / 加载骨架 / 错误重试 / 空态 / 表格
|
|
1330
1927
|
function rightPane() {
|
|
1331
1928
|
if (!sel && busy !== "open" && !loadErr) {
|
|
1332
|
-
//
|
|
1333
|
-
return React.createElement("div", { className: "dbt-browse-empty" }, t("selectTableHint"));
|
|
1929
|
+
// 未选表:显示引导空态(内嵌面板可直接浏览,不强制先选表);flex:1 撑满内容区使类内居中生效
|
|
1930
|
+
return React.createElement("div", { className: "dbt-browse-empty", style: { flex: 1 } }, t("selectTableHint"));
|
|
1334
1931
|
}
|
|
1335
1932
|
if (busy === "open") {
|
|
1336
1933
|
// 表形状骨架:6 行 6 列静态色块(不引入新动画,reduced-motion 天然安全)
|
|
@@ -1339,20 +1936,20 @@ window.__ModuleLoader__.load({
|
|
|
1339
1936
|
[0, 1, 2, 3, 4, 5].map((c) => React.createElement("div", { className: "dbt-browse-skelcell", key: c })))));
|
|
1340
1937
|
}
|
|
1341
1938
|
if (loadErr) {
|
|
1342
|
-
// 错误就地显示 + 重试(内嵌面板保持就地呈现,不走 props.onError)
|
|
1343
|
-
return React.createElement("div", { className: "dbt-browse-errorbox" },
|
|
1939
|
+
// 错误就地显示 + 重试(内嵌面板保持就地呈现,不走 props.onError);role=alert 使异步失败被读屏播报(对齐 Panel 顶层 aria-live)
|
|
1940
|
+
return React.createElement("div", { className: "dbt-browse-errorbox", role: "alert" },
|
|
1344
1941
|
React.createElement("div", { className: "dbt-err" }, t("error") + ": " + loadErr),
|
|
1345
1942
|
React.createElement("button", { className: "dbt-btn", onClick: () => openTable(sel, 1) }, t("retry")));
|
|
1346
1943
|
}
|
|
1347
1944
|
if (view === "structure") {
|
|
1348
|
-
if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyStructure"));
|
|
1945
|
+
if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty", style: { flex: 1 } }, t("emptyStructure"));
|
|
1349
1946
|
return resultTable(
|
|
1350
1947
|
[t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
|
|
1351
1948
|
schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
|
|
1352
1949
|
undefined, "dbt-browse-tablewrap");
|
|
1353
1950
|
}
|
|
1354
1951
|
if (!preview || !preview.rows || preview.rows.length === 0) {
|
|
1355
|
-
return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData"));
|
|
1952
|
+
return React.createElement("div", { className: "dbt-browse-empty", style: { flex: 1 } }, t("emptyData"));
|
|
1356
1953
|
}
|
|
1357
1954
|
// Navicat 式单元格网格:kind/可编辑性由当前连接与授权决定(rw=可编辑),保存后重拉当前页
|
|
1358
1955
|
const conn = conns.find((c) => c.id === sel.connId);
|
|
@@ -1376,21 +1973,27 @@ window.__ModuleLoader__.load({
|
|
|
1376
1973
|
dbRef: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
|
|
1377
1974
|
schemaName: sel.schemaName || null,
|
|
1378
1975
|
connId: sel.connId, projectPath,
|
|
1976
|
+
connName: conn ? (conn.name || sel.connId) : sel.connId,
|
|
1977
|
+
grantMode: grant ? grant.mode : "",
|
|
1379
1978
|
onSaved: () => openTable(sel, page),
|
|
1380
1979
|
askConfirm,
|
|
1381
1980
|
}, footer);
|
|
1382
1981
|
}
|
|
1383
1982
|
|
|
1384
1983
|
// 内嵌面板纵向布局:撑满面板高度(.dbt-panel 为 height:100% 的 flex column),
|
|
1385
|
-
// 树限高 / main flex:1 内滚 /
|
|
1984
|
+
// 树限高 / main flex:1 内滚 / 分页与详情栏依次钉底。
|
|
1985
|
+
// 根不再挂 .dbt-view:该类自带 dbt-in 入场动画,会与 Panel browse 容器同类叠加双播(4px+4px 位移),
|
|
1986
|
+
// 与 Panel 层「入场动画只在面板首次打开播一次」意图相悖;布局职责(flex column + 10px 分区 gap)内联承担
|
|
1386
1987
|
return React.createElement(
|
|
1387
1988
|
"div",
|
|
1388
|
-
{
|
|
1989
|
+
{ style: { display: "flex", flexDirection: "column", gap: 10, flex: "1 1 auto", minHeight: 0 } },
|
|
1389
1990
|
React.createElement(
|
|
1390
1991
|
"div",
|
|
1391
|
-
|
|
1992
|
+
// flex:none:树卡固定限高不被压缩(面板过矮时收缩的是主区),对齐 DESIGN.md 分区规格
|
|
1993
|
+
{ style: { flex: "none", maxHeight: 280, overflowY: "auto" } },
|
|
1392
1994
|
React.createElement(BrowseTree, {
|
|
1393
|
-
conns, projectPath, sel,
|
|
1995
|
+
conns, projectPath, sel,
|
|
1996
|
+
autoExpand: !!active, // 首次激活才自动展开连接层级(常驻挂载下隐藏不发请求)
|
|
1394
1997
|
onSelect: (s) => setSel(s), // 树内切表
|
|
1395
1998
|
}),
|
|
1396
1999
|
),
|
|
@@ -1403,9 +2006,10 @@ window.__ModuleLoader__.load({
|
|
|
1403
2006
|
{ className: "dbt-browse-tabs" },
|
|
1404
2007
|
React.createElement(
|
|
1405
2008
|
"div",
|
|
1406
|
-
|
|
2009
|
+
// role 模式对齐 Panel 全局 tabs(tablist/tab/aria-selected),seg 交互语义一致
|
|
2010
|
+
{ className: "dbt-seg", role: "tablist" },
|
|
1407
2011
|
["structure", "preview"].map((v) =>
|
|
1408
|
-
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
|
|
2012
|
+
React.createElement("button", { key: v, className: view === v ? "active" : "", role: "tab", "aria-selected": view === v, onClick: () => setView(v) },
|
|
1409
2013
|
v === "structure" ? t("structure") : t("preview"))),
|
|
1410
2014
|
),
|
|
1411
2015
|
),
|
|
@@ -1415,116 +2019,600 @@ window.__ModuleLoader__.load({
|
|
|
1415
2019
|
);
|
|
1416
2020
|
}
|
|
1417
2021
|
|
|
1418
|
-
/* ---------------- SQL
|
|
2022
|
+
/* ---------------- 结果网格(SQL 控制台查询结果:客户端分页/排序/复制/导出) ---------------- */
|
|
2023
|
+
// 单元格 → 数值:number 直接用;非空数字字符串可转;NaN/Infinity/其余 → null(走字符串比较)
|
|
2024
|
+
function gridNum(v) {
|
|
2025
|
+
if (typeof v === "number") return isFinite(v) ? v : null;
|
|
2026
|
+
if (typeof v === "string" && v.trim() !== "") { const n = Number(v); if (isFinite(n)) return n; }
|
|
2027
|
+
return null;
|
|
2028
|
+
}
|
|
2029
|
+
// 排序:dir="none" 或 colIndex<0 原样返回;null/undefined 恒最大(SQL NULL 语义:asc 尾部/desc 头部);
|
|
2030
|
+
// 两值均可转有限数字时按数值比较(防 "9">"10" 字典序),否则字符串比较;相等按原序(稳定)
|
|
2031
|
+
function sortRows(rows, colIndex, dir) {
|
|
2032
|
+
if (!Array.isArray(rows) || (dir !== "asc" && dir !== "desc")) return rows;
|
|
2033
|
+
if (!Number.isInteger(colIndex) || colIndex < 0) return rows;
|
|
2034
|
+
const pairs = rows.map((row, i) => ({ row, i }));
|
|
2035
|
+
pairs.sort((a, b) => {
|
|
2036
|
+
const va = Array.isArray(a.row) ? a.row[colIndex] : undefined;
|
|
2037
|
+
const vb = Array.isArray(b.row) ? b.row[colIndex] : undefined;
|
|
2038
|
+
let c;
|
|
2039
|
+
const na = va === null || va === undefined, nb = vb === null || vb === undefined;
|
|
2040
|
+
if (na || nb) c = na && nb ? 0 : na ? 1 : -1;
|
|
2041
|
+
else {
|
|
2042
|
+
const fa = gridNum(va), fb = gridNum(vb);
|
|
2043
|
+
if (fa !== null && fb !== null) c = fa === fb ? 0 : fa < fb ? -1 : 1;
|
|
2044
|
+
else { const sa = String(va), sb = String(vb); c = sa < sb ? -1 : sa > sb ? 1 : 0; }
|
|
2045
|
+
}
|
|
2046
|
+
return c !== 0 ? (dir === "desc" ? -c : c) : a.i - b.i;
|
|
2047
|
+
});
|
|
2048
|
+
return pairs.map((p) => p.row);
|
|
2049
|
+
}
|
|
2050
|
+
// 分页切片:page 从 1 起;page≤0/NaN 返回空页(不触发 slice 负索引从尾部计数)
|
|
2051
|
+
function slicePage(rows, page, pageSize) {
|
|
2052
|
+
if (!Array.isArray(rows) || !(page >= 1)) return [];
|
|
2053
|
+
const size = pageSize || PAGE_SIZE;
|
|
2054
|
+
const start = (page - 1) * size;
|
|
2055
|
+
return rows.slice(start, start + size);
|
|
2056
|
+
}
|
|
2057
|
+
// 结果网格组件:exportHandlers { toCsv, toJson, download } 由集成层注入,缺省隐藏导出按钮
|
|
2058
|
+
function ResultSetGrid(props) {
|
|
2059
|
+
const { columns, rows, truncated, elapsedMs, exportHandlers } = props;
|
|
2060
|
+
const [sortCol, setSortCol] = React.useState(-1);
|
|
2061
|
+
const [dir, setDir] = React.useState("none");
|
|
2062
|
+
const [page, setPage] = React.useState(1);
|
|
2063
|
+
const [copyState, setCopyState] = React.useState(""); // ""|"ok"|"fail"(复制反馈 1.5s 还原,对齐 PreviewGrid)
|
|
2064
|
+
const allRows = rows || [];
|
|
2065
|
+
const sorted = sortRows(allRows, sortCol, dir);
|
|
2066
|
+
const pageCount = Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));
|
|
2067
|
+
const curPage = Math.min(page, pageCount); // 数据重查变短时钳制显示页
|
|
2068
|
+
const pageRows = slicePage(sorted, curPage);
|
|
2069
|
+
// 表头三态轮换:none→asc→desc→none;换列直接 asc;重排后回第一页
|
|
2070
|
+
function toggleSort(i) {
|
|
2071
|
+
setPage(1);
|
|
2072
|
+
if (sortCol !== i) { setSortCol(i); setDir("asc"); return; }
|
|
2073
|
+
setDir(dir === "asc" ? "desc" : dir === "desc" ? "none" : "asc");
|
|
2074
|
+
}
|
|
2075
|
+
// 单元格点击复制:null 复制空串;反馈样式对齐 PreviewGrid(成功/失败各 1.5s 后还原)
|
|
2076
|
+
function copyCell(cell) {
|
|
2077
|
+
navigator.clipboard.writeText(cell === null ? "" : String(cell)).then(() => {
|
|
2078
|
+
setCopyState("ok");
|
|
2079
|
+
window.setTimeout(() => setCopyState(""), 1500);
|
|
2080
|
+
}, () => {
|
|
2081
|
+
setCopyState("fail");
|
|
2082
|
+
window.setTimeout(() => setCopyState(""), 1500);
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
function exportAs(ext, pack) {
|
|
2086
|
+
if (!exportHandlers) return;
|
|
2087
|
+
exportHandlers.download("result." + ext, pack(columns, sorted));
|
|
2088
|
+
}
|
|
2089
|
+
const arrow = dir === "asc" ? " ↑" : dir === "desc" ? " ↓" : "";
|
|
2090
|
+
return React.createElement(
|
|
2091
|
+
"div",
|
|
2092
|
+
{ className: "dbt-col" },
|
|
2093
|
+
allRows.length === 0 ? React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData")) : React.createElement(
|
|
2094
|
+
"div",
|
|
2095
|
+
{ className: "dbt-tablewrap" },
|
|
2096
|
+
React.createElement(
|
|
2097
|
+
"table",
|
|
2098
|
+
{ className: "dbt-table" },
|
|
2099
|
+
React.createElement("thead", null,
|
|
2100
|
+
React.createElement("tr", null, (columns || []).map((c, i) =>
|
|
2101
|
+
React.createElement("th", { key: i },
|
|
2102
|
+
React.createElement("button", { className: "dbt-grid-sort", onClick: () => toggleSort(i), title: String(c) },
|
|
2103
|
+
String(c) + (sortCol === i ? arrow : "")))))),
|
|
2104
|
+
React.createElement("tbody", null, pageRows.map((row, ri) =>
|
|
2105
|
+
React.createElement("tr", { key: ri }, (row || []).map((cell, ci) =>
|
|
2106
|
+
React.createElement("td", { key: ci },
|
|
2107
|
+
React.createElement("button", {
|
|
2108
|
+
className: "dbt-cellbtn",
|
|
2109
|
+
title: cell === null ? "NULL" : String(cell),
|
|
2110
|
+
onClick: () => copyCell(cell),
|
|
2111
|
+
}, cell === null ? React.createElement("span", { className: "dbt-muted" }, "NULL") : String(cell))))))),
|
|
2112
|
+
),
|
|
2113
|
+
),
|
|
2114
|
+
// 状态条 + 导出 + 分页条(复用 BrowsePane footer 模式)
|
|
2115
|
+
React.createElement(
|
|
2116
|
+
"div",
|
|
2117
|
+
{ className: "dbt-browse-footer" },
|
|
2118
|
+
React.createElement("span", { className: "dbt-muted" },
|
|
2119
|
+
t("rowsResult", { n: allRows.length })
|
|
2120
|
+
+ (truncated ? " · " + t("previewTruncated") : "")
|
|
2121
|
+
+ (typeof elapsedMs === "number" && isFinite(elapsedMs) ? " · " + fmtMs(elapsedMs) : "")),
|
|
2122
|
+
copyState ? React.createElement("span", {
|
|
2123
|
+
className: "dbt-muted",
|
|
2124
|
+
style: { color: copyState === "ok" ? "var(--dbt-success,#30d158)" : "var(--dbt-danger,#ff453a)" },
|
|
2125
|
+
}, copyState === "ok" ? t("copied") : t("copyFailed")) : null,
|
|
2126
|
+
React.createElement("span", { style: { flex: 1 } }),
|
|
2127
|
+
exportHandlers ? React.createElement("button", { className: "dbt-btn", onClick: () => exportAs("csv", exportHandlers.toCsv) }, "CSV") : null,
|
|
2128
|
+
exportHandlers ? React.createElement("button", { className: "dbt-btn", onClick: () => exportAs("json", exportHandlers.toJson) }, "JSON") : null,
|
|
2129
|
+
React.createElement("button", { className: "dbt-btn", disabled: curPage <= 1, onClick: () => setPage(curPage - 1) }, "‹ " + t("prevPage")),
|
|
2130
|
+
React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page: curPage })),
|
|
2131
|
+
React.createElement("button", { className: "dbt-btn", disabled: curPage >= pageCount, onClick: () => setPage(curPage + 1) }, t("nextPage") + " ›"),
|
|
2132
|
+
),
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/* ---------------- SQL 控制台(多标签 + CodeMirror + 逐条预分类 + 事务会话) ---------------- */
|
|
1419
2137
|
// Apple 等宽字体栈(规范 §1):优先样式层 --dbt-mono token,fallback 内联栈
|
|
1420
2138
|
const MONO_FONT = "var(--dbt-mono, ui-monospace, \"SF Mono\", Menlo, Consolas, monospace)";
|
|
2139
|
+
|
|
2140
|
+
// 逐条预分类(纯函数,挂 __testables):SQL 读首词为契约清单(SELECT/WITH/SHOW/
|
|
2141
|
+
// EXPLAIN/DESC/DESCRIBE → /api/query,其余 → /api/execute);redis/mongo 语句为 JSON
|
|
2142
|
+
// 命令形态(数组首元素 / 对象唯一键),比对客户端读命令表——完全镜像服务端
|
|
2143
|
+
// READ_COMMANDS(lib/adapters/redis/index.ts:34)与 READ_OPS(lib/adapters/mongodb/
|
|
2144
|
+
// index.ts:76)单一事实来源,避免客户端放行服务端拒绝(或反之)的读命令。
|
|
2145
|
+
const CONSOLE_SQL_READ_HEADS = new Set(["SELECT", "WITH", "SHOW", "EXPLAIN", "DESC", "DESCRIBE"]);
|
|
2146
|
+
const CONSOLE_REDIS_READ = new Set([
|
|
2147
|
+
"GET", "MGET", "EXISTS", "TYPE", "HGET", "HGETALL", "HKEYS", "HVALS", "HLEN", "LRANGE",
|
|
2148
|
+
"LLEN", "SMEMBERS", "SCARD", "SISMEMBER", "ZRANGE", "ZSCORE", "ZCARD", "ZRANK", "XLEN",
|
|
2149
|
+
"XRANGE", "SCAN", "DBSIZE", "INFO", "SELECT", "TTL", "PTTL", "GETRANGE", "STRLEN", "OBJECT", "MEMORY",
|
|
2150
|
+
]);
|
|
2151
|
+
const CONSOLE_MONGO_READ = new Set([
|
|
2152
|
+
"find", "aggregate", "count", "countDocuments", "estimatedDocumentCount",
|
|
2153
|
+
"distinct", "listCollections", "dbStats", "collStats", "indexes",
|
|
2154
|
+
]);
|
|
2155
|
+
function classifyHead(kind, statement) {
|
|
2156
|
+
const s = String(statement == null ? "" : statement).trim();
|
|
2157
|
+
if (kind === "redis" || kind === "mongodb") {
|
|
2158
|
+
let head = null;
|
|
2159
|
+
try {
|
|
2160
|
+
const v = JSON.parse(s);
|
|
2161
|
+
if (Array.isArray(v)) head = v[0];
|
|
2162
|
+
else if (v && typeof v === "object") head = Object.keys(v)[0];
|
|
2163
|
+
} catch (e) { /* 非 JSON 命令落回首词分类 */ }
|
|
2164
|
+
if (typeof head === "string") {
|
|
2165
|
+
return (kind === "redis" ? CONSOLE_REDIS_READ : CONSOLE_MONGO_READ).has(head) ? "query" : "execute";
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
const head = (s.match(/^[A-Za-z]+/) || [""])[0].toUpperCase();
|
|
2169
|
+
return CONSOLE_SQL_READ_HEADS.has(head) ? "query" : "execute";
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// vendor bundle 加载(单例 promise):宿主 require 相对文件路径(Node/e2e 语境,与
|
|
2173
|
+
// vendor/mongodb-driver.cjs 同款通道)。require 失败(web 宿主 require 无文件模块)即
|
|
2174
|
+
// reject,调用方降级 textarea(失败后置空单例允许重试,如宿主后续就绪)。
|
|
2175
|
+
// 不做同源 fetch bundle + Function 动态执行:生产无 /vendor 静态路由(lib/http 仅 JSON
|
|
2176
|
+
// API),该分支必死;且 Function 构造器等价 eval,fetched 文本响应投毒即代码注入。
|
|
2177
|
+
let cmModulePromise = null;
|
|
2178
|
+
function loadConsoleEditorModule() {
|
|
2179
|
+
if (cmModulePromise) return cmModulePromise;
|
|
2180
|
+
cmModulePromise = (async () => {
|
|
2181
|
+
const m = require("../vendor/codemirror-sql.cjs");
|
|
2182
|
+
if (!(m && typeof m.createConsoleEditor === "function")) throw new Error("bundle 缺 createConsoleEditor");
|
|
2183
|
+
return m;
|
|
2184
|
+
})();
|
|
2185
|
+
cmModulePromise.catch(() => { cmModulePromise = null; });
|
|
2186
|
+
return cmModulePromise;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// 结果导出 download:Blob + a.download(点击后延时 revoke,防过早回收);渲染期只建闭包不触 DOM
|
|
2190
|
+
function makeExportHandlers() {
|
|
2191
|
+
return {
|
|
2192
|
+
toCsv, toJson,
|
|
2193
|
+
download(name, text) {
|
|
2194
|
+
const blob = new Blob([String(text == null ? "" : text)], { type: "text/plain;charset=utf-8" });
|
|
2195
|
+
const url = URL.createObjectURL(blob);
|
|
2196
|
+
const a = document.createElement("a");
|
|
2197
|
+
a.href = url;
|
|
2198
|
+
a.download = name || "result.txt";
|
|
2199
|
+
document.body.appendChild(a);
|
|
2200
|
+
a.click();
|
|
2201
|
+
a.remove();
|
|
2202
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
2203
|
+
},
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
const CONSOLE_SQL_KINDS = { mysql: true, postgresql: true, gaussdb: true, sqlite: true, oracle: true, dmdb: true };
|
|
2208
|
+
|
|
1421
2209
|
function ConsoleView(props) {
|
|
1422
|
-
const { conns, projectPath, askConfirm } = props;
|
|
2210
|
+
const { conns, grants, projectPath, askConfirm, onError } = props;
|
|
1423
2211
|
const [connId, setConnId] = React.useState("");
|
|
1424
|
-
const [mode, setMode] = React.useState("
|
|
1425
|
-
const [sql, setSql] = React.useState("");
|
|
2212
|
+
const [mode, setMode] = React.useState("sql"); // sql(逐条预分类)| script(整段 /api/script)
|
|
1426
2213
|
const [params, setParams] = React.useState("");
|
|
1427
|
-
const [result, setResult] = React.useState(null); // {kind:'query',...}|{kind:'exec',...}
|
|
1428
2214
|
const [busy, setBusy] = React.useState(false);
|
|
1429
2215
|
// Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
|
|
1430
2216
|
const [dbList, setDbList] = React.useState([]);
|
|
1431
2217
|
const [db, setDb] = React.useState("");
|
|
2218
|
+
// 多标签(内存态,不落盘):tabs[{id,title,sql,result,history}]
|
|
2219
|
+
const tabSeq = React.useRef(1);
|
|
2220
|
+
const [tabs, setTabs] = React.useState(() => [{ id: "t1", title: t("queryTab", { n: 1 }), sql: "", result: null, history: [] }]);
|
|
2221
|
+
const [activeIdx, setActiveIdx] = React.useState(0);
|
|
2222
|
+
// CodeMirror 模块与事务会话状态
|
|
2223
|
+
const [cm, setCm] = React.useState(null); // null(加载中)| module | "failed"(降级 textarea)
|
|
2224
|
+
const [txToken, setTxToken] = React.useState(null);
|
|
2225
|
+
const [txBusy, setTxBusy] = React.useState(false);
|
|
2226
|
+
const [historyOpen, setHistoryOpen] = React.useState(false);
|
|
2227
|
+
|
|
2228
|
+
const editorHostRef = React.useRef(null);
|
|
2229
|
+
const editorRef = React.useRef(null); // { ed, kind }
|
|
2230
|
+
const schemaRef = React.useRef({}); // 最近一次拉取的 SQLNamespace(表 → 列名数组)
|
|
2231
|
+
const activeIdxRef = React.useRef(0);
|
|
2232
|
+
React.useLayoutEffect(() => { activeIdxRef.current = activeIdx; }); // commit 后写回,不污染渲染期
|
|
2233
|
+
const exportRef = React.useRef(null);
|
|
2234
|
+
if (!exportRef.current) exportRef.current = makeExportHandlers();
|
|
2235
|
+
|
|
2236
|
+
const conn = conns.find((c) => c.id === connId);
|
|
2237
|
+
const kind = conn ? conn.kind : "";
|
|
2238
|
+
const grant = (grants || []).find((g) => g.connId === connId);
|
|
2239
|
+
const rw = !!grant && grant.mode === "rw";
|
|
2240
|
+
const activeTab = tabs[activeIdx] || tabs[0];
|
|
2241
|
+
const activeTabId = activeTab ? activeTab.id : "";
|
|
2242
|
+
|
|
2243
|
+
// 跨库下拉(保留既有行为:换连接重置)
|
|
1432
2244
|
React.useEffect(() => {
|
|
1433
2245
|
setDbList([]); setDb("");
|
|
1434
2246
|
if (!connId || !projectPath) return;
|
|
1435
2247
|
api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
|
|
1436
2248
|
}, [connId, projectPath]);
|
|
1437
2249
|
|
|
1438
|
-
|
|
2250
|
+
// vendor bundle 异步加载(失败降级 textarea,静默)
|
|
2251
|
+
React.useEffect(() => {
|
|
2252
|
+
let dead = false;
|
|
2253
|
+
loadConsoleEditorModule().then(
|
|
2254
|
+
(m) => { if (!dead) setCm(m); },
|
|
2255
|
+
() => { if (!dead) setCm("failed"); },
|
|
2256
|
+
);
|
|
2257
|
+
return () => { dead = true; };
|
|
2258
|
+
}, []);
|
|
2259
|
+
|
|
2260
|
+
// 编辑器挂载(CM6 生命周期:cleanup 调 destroy);kind 变化(换连接)整体重建以切方言
|
|
2261
|
+
React.useEffect(() => {
|
|
2262
|
+
if (!cm || cm === "failed" || !editorHostRef.current) return undefined;
|
|
2263
|
+
let ed = null;
|
|
2264
|
+
try {
|
|
2265
|
+
ed = cm.createConsoleEditor(editorHostRef.current, {
|
|
2266
|
+
kind,
|
|
2267
|
+
onRunAll: () => runRef.current("all"),
|
|
2268
|
+
onRunSelection: () => runRef.current("selection"),
|
|
2269
|
+
onChange: (text) => patchActive({ sql: text }),
|
|
2270
|
+
});
|
|
2271
|
+
} catch (e) {
|
|
2272
|
+
setCm("failed");
|
|
2273
|
+
return undefined;
|
|
2274
|
+
}
|
|
2275
|
+
editorRef.current = { ed, kind };
|
|
2276
|
+
ed.setDoc((tabs[activeIdxRef.current] && tabs[activeIdxRef.current].sql) || "");
|
|
2277
|
+
if (Object.keys(schemaRef.current).length) ed.setSchema(schemaRef.current);
|
|
2278
|
+
return () => {
|
|
2279
|
+
ed.destroy();
|
|
2280
|
+
editorRef.current = null;
|
|
2281
|
+
};
|
|
2282
|
+
}, [cm, kind]);
|
|
2283
|
+
|
|
2284
|
+
// 切换标签:编辑器文档同步到目标标签(不重建,保 undo 历史)
|
|
2285
|
+
React.useEffect(() => {
|
|
2286
|
+
const rec = editorRef.current;
|
|
2287
|
+
if (rec && rec.ed) rec.ed.setDoc((tabs[activeIdx] && tabs[activeIdx].sql) || "");
|
|
2288
|
+
}, [activeTabId]);
|
|
2289
|
+
|
|
2290
|
+
// 表/列元数据异步拉取(tables + 逐表 schema → SQLNamespace)供编辑器 setSchema;
|
|
2291
|
+
// 失败静默降级(无补全但不影响编辑),单表失败跳过该表
|
|
2292
|
+
React.useEffect(() => {
|
|
2293
|
+
let dead = false;
|
|
2294
|
+
schemaRef.current = {};
|
|
2295
|
+
const rec = editorRef.current;
|
|
2296
|
+
if (rec && rec.ed) rec.ed.setSchema({});
|
|
2297
|
+
if (!connId || !projectPath || !CONSOLE_SQL_KINDS[kind]) return undefined;
|
|
2298
|
+
const dbRef = db || undefined;
|
|
2299
|
+
api("tables" + qs({ project: projectPath, connId, database: dbRef }))
|
|
2300
|
+
.then(async (list) => {
|
|
2301
|
+
const names = (Array.isArray(list) ? list : []).map((x) => x && x.name).filter(Boolean).slice(0, 50);
|
|
2302
|
+
const entries = await Promise.all(names.map((name) =>
|
|
2303
|
+
api("schema" + qs({ project: projectPath, connId, table: name, database: dbRef }))
|
|
2304
|
+
.then((cols) => [name, (Array.isArray(cols) ? cols : []).map((c) => c && c.name).filter(Boolean)])
|
|
2305
|
+
.catch(() => [name, []])));
|
|
2306
|
+
if (dead) return undefined;
|
|
2307
|
+
const ns = {};
|
|
2308
|
+
for (const [name, cols] of entries) ns[name] = cols;
|
|
2309
|
+
schemaRef.current = ns;
|
|
2310
|
+
const rec2 = editorRef.current;
|
|
2311
|
+
if (rec2 && rec2.ed) rec2.ed.setSchema(ns);
|
|
2312
|
+
return undefined;
|
|
2313
|
+
})
|
|
2314
|
+
.catch(() => { /* 静默降级 */ });
|
|
2315
|
+
return () => { dead = true; };
|
|
2316
|
+
}, [connId, db, projectPath, kind]);
|
|
2317
|
+
|
|
2318
|
+
function patchActive(patch) {
|
|
2319
|
+
setTabs((prev) => prev.map((tb, i) => (i === activeIdxRef.current ? Object.assign({}, tb, patch) : tb)));
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// 确认对话框目标信息:连接名 / kind / 当前库(空=默认库)/ 该连接的授权模式(无授权留空)
|
|
2323
|
+
function confirmTarget() {
|
|
2324
|
+
return { conn: conn ? (conn.name || connId) : connId, kind, db: db || "", mode: grant ? grant.mode : "" };
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// 执行入口(scope:"all" 全量 | "selection" 选中,无选区回退全量)。逐条分割后按
|
|
2328
|
+
// classifyHead 预分类路由;事务会话存在时全部语句走 /api/console/exec(粘性连接,
|
|
2329
|
+
// 能看到未提交数据);ro 授权下写类语句跳过并标注。结果逐条就地渲染,错误带
|
|
2330
|
+
// start 偏移可定位光标。
|
|
2331
|
+
async function runBatch(scope) {
|
|
2332
|
+
if (busy || !connId) return;
|
|
2333
|
+
const rec = editorRef.current;
|
|
2334
|
+
let text = rec && rec.ed ? rec.ed.getDoc() : (activeTab ? activeTab.sql : "");
|
|
2335
|
+
if (scope === "selection") {
|
|
2336
|
+
const sel = rec && rec.ed ? rec.ed.getSelection() : textareaSelection();
|
|
2337
|
+
if (String(sel || "").trim()) text = sel;
|
|
2338
|
+
}
|
|
2339
|
+
if (!String(text || "").trim()) return;
|
|
2340
|
+
const tIdx = activeIdxRef.current; // 执行期间切标签不串写结果:锁定目标标签
|
|
2341
|
+
const targetTab = tabs[tIdx];
|
|
2342
|
+
let parsedParams;
|
|
2343
|
+
if (params.trim()) {
|
|
2344
|
+
try { parsedParams = JSON.parse(params); } catch (e) { onError(new Error(t("paramsJson") + ": " + e.message)); return; }
|
|
2345
|
+
}
|
|
1439
2346
|
setBusy(true);
|
|
1440
|
-
|
|
2347
|
+
const started = Date.now();
|
|
2348
|
+
const items = [];
|
|
1441
2349
|
try {
|
|
1442
2350
|
if (mode === "script") {
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
(
|
|
1453
|
-
|
|
1454
|
-
);
|
|
1455
|
-
setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
2351
|
+
const st = Date.now();
|
|
2352
|
+
try {
|
|
2353
|
+
const data = await runGuarded(
|
|
2354
|
+
(challengeId) => api("script", { method: "POST", body: { projectPath, connId, code: text, challengeId } }),
|
|
2355
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget() })),
|
|
2356
|
+
);
|
|
2357
|
+
items.push({ text, start: 0, route: "script", status: "ok", elapsedMs: Date.now() - st, message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
2358
|
+
} catch (e) {
|
|
2359
|
+
if (!e.cancelled) onError(e);
|
|
2360
|
+
items.push({ text, start: 0, route: "script", status: e.cancelled ? "cancelled" : "error", error: String(e && e.message ? e.message : e), elapsedMs: Date.now() - st });
|
|
2361
|
+
}
|
|
1456
2362
|
} else {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2363
|
+
const stmts = CONSOLE_SQL_KINDS[kind] ? splitSqlStatements(text, kind) : [{ text: String(text).trim(), start: 0 }];
|
|
2364
|
+
for (const s of stmts) {
|
|
2365
|
+
if (!String(s.text).trim()) continue;
|
|
2366
|
+
const route = classifyHead(kind, s.text);
|
|
2367
|
+
// ro 授权:写类语句(execute 路由)跳过并标注;事务会话仅 rw 可开,无需复查
|
|
2368
|
+
if (!rw && !txToken && route === "execute") {
|
|
2369
|
+
items.push({ text: s.text, start: s.start, route, status: "skipped" });
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
const st = Date.now();
|
|
2373
|
+
try {
|
|
2374
|
+
let data;
|
|
2375
|
+
if (txToken) {
|
|
2376
|
+
data = await runGuarded(
|
|
2377
|
+
(challengeId) => api("console/exec", { method: "POST", body: { sessionToken: txToken, statement: s.text, params: parsedParams, challengeId } }),
|
|
2378
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget(), params: parsedParams })),
|
|
2379
|
+
);
|
|
2380
|
+
} else if (route === "query") {
|
|
2381
|
+
data = await runGuarded(
|
|
2382
|
+
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql: s.text, params: parsedParams, database: db || undefined, challengeId } }),
|
|
2383
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget(), params: parsedParams })),
|
|
2384
|
+
);
|
|
2385
|
+
} else {
|
|
2386
|
+
data = await runGuarded(
|
|
2387
|
+
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: s.text, params: parsedParams, database: db || undefined, challengeId } }),
|
|
2388
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget(), params: parsedParams })),
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
const item = { text: s.text, start: s.start, route, status: "ok", elapsedMs: Date.now() - st, inTx: !!txToken };
|
|
2392
|
+
if (data && Array.isArray(data.columns)) item.data = data; // QueryResult(结果集)
|
|
2393
|
+
else { item.message = (data && data.message) || ""; item.affectedRows = data && data.affectedRows; }
|
|
2394
|
+
items.push(item);
|
|
2395
|
+
} catch (e) {
|
|
2396
|
+
if (e.cancelled) items.push({ text: s.text, start: s.start, route, status: "cancelled" });
|
|
2397
|
+
else items.push({ text: s.text, start: s.start, route, status: "error", error: String(e && e.message ? e.message : e), elapsedMs: Date.now() - st });
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
1464
2400
|
}
|
|
1465
|
-
} catch (e) {
|
|
1466
|
-
if (!e.cancelled) props.onError(e);
|
|
1467
2401
|
} finally {
|
|
1468
2402
|
setBusy(false);
|
|
1469
2403
|
}
|
|
2404
|
+
const elapsedMs = Date.now() - started;
|
|
2405
|
+
const ok = items.length > 0 && items.every((it) => it.status === "ok");
|
|
2406
|
+
setTabs((prev) => prev.map((tb, i) => (i === tIdx ? Object.assign({}, tb, {
|
|
2407
|
+
result: { items, elapsedMs },
|
|
2408
|
+
history: [{ sql: text, ts: started, elapsedMs, ok }].concat(targetTab ? targetTab.history : []).slice(0, 50),
|
|
2409
|
+
}) : tb)));
|
|
2410
|
+
}
|
|
2411
|
+
const runRef = React.useRef(() => {});
|
|
2412
|
+
// 每轮 commit 后刷新闭包,按钮/编辑器回调恒拿最新 state(回调仅在 commit 后触发,时序不变)
|
|
2413
|
+
React.useLayoutEffect(() => { runRef.current = runBatch; });
|
|
2414
|
+
|
|
2415
|
+
function textareaSelection() {
|
|
2416
|
+
const ta = editorHostRef.current && editorHostRef.current.querySelector("textarea");
|
|
2417
|
+
if (!ta || ta.selectionStart === ta.selectionEnd) return "";
|
|
2418
|
+
return ta.value.slice(ta.selectionStart, ta.selectionEnd);
|
|
1470
2419
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2420
|
+
|
|
2421
|
+
// 错误定位:按语句 start 偏移移动编辑器光标(CM dispatch / textarea setSelectionRange)
|
|
2422
|
+
function locateOffset(start) {
|
|
2423
|
+
const rec = editorRef.current;
|
|
2424
|
+
if (rec && rec.ed && rec.ed.view && rec.ed.view.state) {
|
|
2425
|
+
try {
|
|
2426
|
+
rec.ed.view.dispatch({ selection: { anchor: Math.max(0, Math.min(start || 0, rec.ed.view.state.doc.length)) }, scrollIntoView: true });
|
|
2427
|
+
rec.ed.view.focus();
|
|
2428
|
+
return;
|
|
2429
|
+
} catch (e) { /* 落 textarea 降级 */ }
|
|
1476
2430
|
}
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
2431
|
+
const ta = editorHostRef.current && editorHostRef.current.querySelector("textarea");
|
|
2432
|
+
if (ta) { ta.focus(); ta.setSelectionRange(start || 0, start || 0); }
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function formatSql() {
|
|
2436
|
+
const rec = editorRef.current;
|
|
2437
|
+
if (rec && rec.ed && typeof rec.ed.format === "function") rec.ed.format();
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
async function txBegin() {
|
|
2441
|
+
// 服务端对非默认库 begin 显式拒绝(粘性事务无法跨库),客户端同等守卫避免无效请求
|
|
2442
|
+
if (!rw || txBusy || txToken || !connId || db) return;
|
|
2443
|
+
setTxBusy(true);
|
|
2444
|
+
try {
|
|
2445
|
+
const r = await api("console/begin", { method: "POST", body: { projectPath, connId, database: db || undefined } });
|
|
2446
|
+
setTxToken((r && r.sessionToken) || "");
|
|
2447
|
+
} catch (e) { onError(e); } finally { setTxBusy(false); }
|
|
2448
|
+
}
|
|
2449
|
+
async function txEnd(commit) {
|
|
2450
|
+
if (!txToken || txBusy) return;
|
|
2451
|
+
setTxBusy(true);
|
|
2452
|
+
try {
|
|
2453
|
+
const r = await api(commit ? "console/commit" : "console/rollback", { method: "POST", body: { sessionToken: txToken } });
|
|
2454
|
+
setTxToken(null);
|
|
2455
|
+
patchActive({
|
|
2456
|
+
result: { items: [{ text: commit ? "COMMIT" : "ROLLBACK", start: 0, route: "script", status: "ok", message: (r && r.message) || t("ok") }], elapsedMs: 0 },
|
|
2457
|
+
});
|
|
2458
|
+
} catch (e) { onError(e); } finally { setTxBusy(false); }
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function addTab() {
|
|
2462
|
+
tabSeq.current += 1;
|
|
2463
|
+
const n = tabSeq.current;
|
|
2464
|
+
setTabs((prev) => prev.concat([{ id: "t" + n, title: t("queryTab", { n }), sql: "", result: null, history: [] }]));
|
|
2465
|
+
setActiveIdx(tabs.length);
|
|
2466
|
+
}
|
|
2467
|
+
function closeTab(i) {
|
|
2468
|
+
if (tabs.length <= 1) return;
|
|
2469
|
+
setTabs((prev) => prev.filter((_, k) => k !== i));
|
|
2470
|
+
setActiveIdx((cur) => (i < cur ? cur - 1 : Math.min(cur, tabs.length - 2)));
|
|
2471
|
+
}
|
|
2472
|
+
function restoreHistory(h) {
|
|
2473
|
+
const rec = editorRef.current;
|
|
2474
|
+
if (rec && rec.ed) rec.ed.setDoc(h.sql);
|
|
2475
|
+
patchActive({ sql: h.sql });
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// 单条结果行:路由徽标 + 语句摘要 + 耗时 + 行数/状态;查询结果接 ResultSetGrid
|
|
2479
|
+
//(exportHandlers 由组件注入:toCsv/toJson 纯函数 + Blob download)
|
|
2480
|
+
function renderResultItem(it, i) {
|
|
2481
|
+
const routeLabel = it.route === "query" ? t("routeQuery") : it.route === "script" ? t("routeScript") : t("routeExec");
|
|
2482
|
+
const head = React.createElement(
|
|
2483
|
+
"div",
|
|
2484
|
+
{ className: "dbt-listrow", key: "h" + i },
|
|
2485
|
+
React.createElement("span", { className: "dbt-routechip" }, routeLabel),
|
|
2486
|
+
// 事务内执行的查询语句加「事务内」徽标;写语句维持路由徽标不变
|
|
2487
|
+
it.inTx && it.route === "query" ? React.createElement("span", { className: "dbt-routechip" }, t("txInline")) : null,
|
|
2488
|
+
React.createElement("span", { style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: MONO_FONT, fontSize: 12 }, title: it.text }, String(it.text || "").replace(/\s+/g, " ").trim().slice(0, 120)),
|
|
2489
|
+
it.elapsedMs != null ? React.createElement("span", { className: "dbt-muted", style: { flex: "none" } }, fmtMs(it.elapsedMs)) : null,
|
|
2490
|
+
it.status === "ok" && it.data ? React.createElement("span", { className: "dbt-muted", style: { flex: "none" } }, t("rowsResult", { n: it.data.rowCount })) : null,
|
|
2491
|
+
it.status === "ok" && !it.data ? React.createElement("span", { className: "dbt-msg", style: { flex: "none" } }, it.message + (it.affectedRows !== undefined ? " · " + t("execResult", { n: it.affectedRows }) : "")) : null,
|
|
2492
|
+
it.status === "skipped" ? React.createElement("span", { className: "dbt-readhint", style: { flex: "none" } }, t("skippedRo")) : null,
|
|
2493
|
+
it.status === "cancelled" ? React.createElement("span", { className: "dbt-muted", style: { flex: "none" } }, t("cancelled")) : null,
|
|
2494
|
+
it.status === "error" ? React.createElement("span", { className: "dbt-err", style: { flex: "none", maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis" } }, t("error") + ": " + it.error) : null,
|
|
2495
|
+
it.status === "error" ? React.createElement("button", { className: "dbt-btn", style: { flex: "none" }, "aria-label": t("locate"), onClick: () => locateOffset(it.start) }, t("locate")) : null,
|
|
1484
2496
|
);
|
|
2497
|
+
const grid = it.status === "ok" && it.data ? React.createElement(ResultSetGrid, {
|
|
2498
|
+
key: "g" + i,
|
|
2499
|
+
columns: it.data.columns || [],
|
|
2500
|
+
rows: it.data.rows || [],
|
|
2501
|
+
truncated: it.data.truncated,
|
|
2502
|
+
elapsedMs: it.elapsedMs,
|
|
2503
|
+
exportHandlers: exportRef.current,
|
|
2504
|
+
}) : null;
|
|
2505
|
+
return grid ? [head, grid] : head;
|
|
1485
2506
|
}
|
|
2507
|
+
|
|
2508
|
+
const segBtn = (m, label) => React.createElement("button", { key: m, className: mode === m ? "active" : "", onClick: () => setMode(m) }, label);
|
|
1486
2509
|
return React.createElement(
|
|
1487
2510
|
"div",
|
|
1488
2511
|
{ className: "dbt-card" },
|
|
1489
|
-
//
|
|
2512
|
+
// 选择器:连接(事务进行中锁定,防语句写到另一连接)+ 跨库下拉
|
|
1490
2513
|
React.createElement(
|
|
1491
2514
|
"div",
|
|
1492
2515
|
{ className: "dbt-group" },
|
|
1493
2516
|
React.createElement(
|
|
1494
2517
|
"div",
|
|
1495
2518
|
{ className: "dbt-listrow" },
|
|
1496
|
-
React.createElement("select", { value: connId, onChange: (e) => setConnId(e.target.value) },
|
|
1497
|
-
React.createElement("option", { value: "" }, t("
|
|
1498
|
-
conns.map((c) =>
|
|
1499
|
-
|
|
1500
|
-
|
|
2519
|
+
React.createElement("select", { value: connId, disabled: !!txToken, onChange: (e) => setConnId(e.target.value) },
|
|
2520
|
+
React.createElement("option", { value: "" }, t("selectConn")),
|
|
2521
|
+
conns.map((c) => {
|
|
2522
|
+
const g = (grants || []).find((x) => x.connId === c.id);
|
|
2523
|
+
const suffix = g && g.mode ? " · " + g.mode : "";
|
|
2524
|
+
return React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + suffix + ")");
|
|
2525
|
+
})),
|
|
2526
|
+
dbList.length > 1 ? React.createElement("select", { value: db, disabled: !!txToken, onChange: (e) => setDb(e.target.value) },
|
|
1501
2527
|
React.createElement("option", { value: "" }, t("defaultDb")),
|
|
1502
2528
|
dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
|
|
1503
2529
|
),
|
|
1504
|
-
|
|
2530
|
+
),
|
|
2531
|
+
// 多标签条(内存态):标签 + 关闭 + 新建
|
|
2532
|
+
React.createElement(
|
|
2533
|
+
"div",
|
|
2534
|
+
{ className: "dbt-cm-tabbar" },
|
|
2535
|
+
tabs.map((tb, i) => React.createElement(
|
|
2536
|
+
"button",
|
|
2537
|
+
{
|
|
2538
|
+
key: tb.id, className: "dbt-cm-tab" + (i === activeIdx ? " active" : ""),
|
|
2539
|
+
onClick: () => setActiveIdx(i),
|
|
2540
|
+
},
|
|
2541
|
+
tb.title,
|
|
2542
|
+
tabs.length > 1 ? React.createElement("span", {
|
|
2543
|
+
className: "dbt-cm-tabclose", role: "button", "aria-label": t("closeTab"), tabIndex: 0,
|
|
2544
|
+
onClick: (e) => { e.stopPropagation(); closeTab(i); },
|
|
2545
|
+
}, "×") : null,
|
|
2546
|
+
)),
|
|
2547
|
+
React.createElement("button", { className: "dbt-cm-tab", "aria-label": t("newTab"), title: t("newTab"), onClick: addTab }, "+"),
|
|
2548
|
+
),
|
|
2549
|
+
// 工具行 1:SQL/脚本 seg + 格式化 + 事务按钮组(仅 rw 授权显示)
|
|
2550
|
+
React.createElement(
|
|
2551
|
+
"div",
|
|
2552
|
+
{ className: "dbt-row" },
|
|
2553
|
+
React.createElement("div", { className: "dbt-seg" }, [segBtn("sql", t("modeSql")), segBtn("script", t("modeScript"))]),
|
|
2554
|
+
mode === "sql" ? React.createElement("button", { className: "dbt-btn", disabled: !connId, onClick: formatSql }, t("formatSql")) : null,
|
|
2555
|
+
React.createElement("span", { style: { flex: 1 } }),
|
|
2556
|
+
rw ? React.createElement(
|
|
1505
2557
|
"div",
|
|
1506
|
-
{ className: "dbt-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
2558
|
+
{ className: "dbt-row", style: { flex: "none" } },
|
|
2559
|
+
txToken
|
|
2560
|
+
? [
|
|
2561
|
+
React.createElement("span", { className: "dbt-muted", key: "txs" }, t("txActive")),
|
|
2562
|
+
React.createElement("button", { className: "dbt-btn primary", key: "c", disabled: txBusy, onClick: () => txEnd(true) }, t("txCommit")),
|
|
2563
|
+
React.createElement("button", { className: "dbt-btn danger", key: "r", disabled: txBusy, onClick: () => txEnd(false) }, t("txRollback")),
|
|
2564
|
+
]
|
|
2565
|
+
: React.createElement("button", { className: "dbt-btn", disabled: txBusy || !connId || !!db, title: db ? t("txDefaultDbOnly") : undefined, onClick: txBegin }, t("txBegin")),
|
|
2566
|
+
) : null,
|
|
2567
|
+
),
|
|
2568
|
+
// 工具行 2:执行(全量)/ 执行选中 + params(script 模式无绑定参数)
|
|
2569
|
+
React.createElement(
|
|
2570
|
+
"div",
|
|
2571
|
+
{ className: "dbt-row" },
|
|
2572
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: busy || !connId, onClick: () => runRef.current("all") }, busy ? t("running") : t("run")),
|
|
2573
|
+
mode === "sql" ? React.createElement("button", { className: "dbt-btn", disabled: busy || !connId, onClick: () => runRef.current("selection") }, t("runSelection")) : null,
|
|
2574
|
+
mode !== "script" ? React.createElement("input", { placeholder: t("paramsJson"), value: params, onChange: (e) => setParams(e.target.value), style: { fontFamily: MONO_FONT } }) : null,
|
|
2575
|
+
),
|
|
2576
|
+
// 编辑器:CM 挂载(div ref)或降级 textarea(bundle 加载中/失败);脚本模式提示 JS 占位
|
|
2577
|
+
React.createElement(
|
|
2578
|
+
"div",
|
|
2579
|
+
{ className: "dbt-editor-host", ref: editorHostRef },
|
|
2580
|
+
cm && cm !== "failed"
|
|
2581
|
+
? null // CM 自建 .cm-editor 填充宿主(bundle 主题 height:100%)
|
|
2582
|
+
: React.createElement("textarea", {
|
|
2583
|
+
placeholder: mode === "script" ? t("scriptPlaceholder") : t("sqlPlaceholder"),
|
|
2584
|
+
value: (activeTab && activeTab.sql) || "",
|
|
2585
|
+
onChange: (e) => patchActive({ sql: e.target.value }),
|
|
2586
|
+
style: Object.assign({ fontFamily: MONO_FONT },
|
|
2587
|
+
mode !== "sql" ? { outline: "1px solid var(--dbt-danger, #ff453a)" } : null),
|
|
2588
|
+
}),
|
|
1512
2589
|
),
|
|
1513
|
-
//
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
value: sql, onChange: (e) => setSql(e.target.value),
|
|
1517
|
-
style: { fontFamily: MONO_FONT, minHeight: mode === "script" ? 160 : 96 },
|
|
1518
|
-
}),
|
|
1519
|
-
mode !== "script"
|
|
1520
|
-
? React.createElement("input", { placeholder: t("paramsJson"), value: params, onChange: (e) => setParams(e.target.value), style: { fontFamily: MONO_FONT } })
|
|
2590
|
+
// 结果区:逐条结果行 + 查询结果网格
|
|
2591
|
+
activeTab && activeTab.result && Array.isArray(activeTab.result.items)
|
|
2592
|
+
? React.createElement("div", { className: "dbt-col" }, activeTab.result.items.map(renderResultItem))
|
|
1521
2593
|
: null,
|
|
2594
|
+
// 历史卡(每 tab 内存态):可展开、点击回填,不落盘
|
|
1522
2595
|
React.createElement(
|
|
1523
2596
|
"div",
|
|
1524
2597
|
{ className: "dbt-row" },
|
|
1525
|
-
React.createElement("button", { className: "dbt-btn
|
|
2598
|
+
React.createElement("button", { className: "dbt-btn", "aria-expanded": !!historyOpen, onClick: () => setHistoryOpen(!historyOpen) }, t("historyTitle")),
|
|
1526
2599
|
),
|
|
1527
|
-
|
|
2600
|
+
historyOpen ? (
|
|
2601
|
+
!activeTab || activeTab.history.length === 0
|
|
2602
|
+
? React.createElement("div", { className: "dbt-muted" }, t("historyEmpty"))
|
|
2603
|
+
: React.createElement(
|
|
2604
|
+
"div",
|
|
2605
|
+
{ className: "dbt-group" },
|
|
2606
|
+
activeTab.history.map((h, i) => React.createElement(
|
|
2607
|
+
"div",
|
|
2608
|
+
{ className: "dbt-listrow", key: i },
|
|
2609
|
+
React.createElement("span", { className: h.ok ? "dbt-msg" : "dbt-err", style: { flex: "none" } }, h.ok ? "✓" : "✗"),
|
|
2610
|
+
React.createElement("button", { className: "dbt-cellbtn", style: { flex: 1, fontFamily: MONO_FONT, fontSize: 12 }, title: h.sql, onClick: () => restoreHistory(h) }, String(h.sql).replace(/\s+/g, " ").trim().slice(0, 120)),
|
|
2611
|
+
React.createElement("span", { className: "dbt-muted", style: { flex: "none" } },
|
|
2612
|
+
new Date(h.ts).toLocaleTimeString() + (typeof h.elapsedMs === "number" ? " · " + fmtMs(h.elapsedMs) : "")),
|
|
2613
|
+
)),
|
|
2614
|
+
)
|
|
2615
|
+
) : null,
|
|
1528
2616
|
);
|
|
1529
2617
|
}
|
|
1530
2618
|
|
|
@@ -1587,16 +2675,23 @@ window.__ModuleLoader__.load({
|
|
|
1587
2675
|
}
|
|
1588
2676
|
function onError(e) { setError(String(e && e.message ? e.message : e)); }
|
|
1589
2677
|
|
|
1590
|
-
const shared = { conns, grants, projectPath, busy, run, reload, onError };
|
|
2678
|
+
const shared = { conns, grants, projectPath, busy, run, reload, onError, askConfirm };
|
|
2679
|
+
// 四视图常驻元素:Panel 渲染期构建一次,切 tab 不重建(状态保留),ManageView/GrantsView/ConsoleView 复用 shared
|
|
2680
|
+
const viewEls = {
|
|
2681
|
+
manage: React.createElement(ManageView, shared),
|
|
2682
|
+
grants: React.createElement(GrantsView, shared),
|
|
2683
|
+
console: React.createElement(ConsoleView, shared),
|
|
2684
|
+
};
|
|
1591
2685
|
return React.createElement(
|
|
1592
2686
|
"div",
|
|
1593
2687
|
{ className: "dbt-panel" },
|
|
1594
2688
|
React.createElement(
|
|
1595
2689
|
"div",
|
|
1596
|
-
{ className: "dbt-tabs" },
|
|
2690
|
+
{ className: "dbt-tabs", role: "tablist" },
|
|
1597
2691
|
[["manage", t("viewManage")], ["grants", t("viewGrants")], ["browse", t("viewBrowse")], ["console", t("viewConsole")]].map(([v, label]) =>
|
|
1598
2692
|
React.createElement("button", {
|
|
1599
2693
|
key: v, className: view === v ? "active" : "",
|
|
2694
|
+
role: "tab", "aria-selected": view === v,
|
|
1600
2695
|
onClick: () => setView(v),
|
|
1601
2696
|
}, label)),
|
|
1602
2697
|
),
|
|
@@ -1619,7 +2714,7 @@ window.__ModuleLoader__.load({
|
|
|
1619
2714
|
React.createElement("span", { className: "dbt-muted" }, t("projectLabel", { path: projectPath })),
|
|
1620
2715
|
// 编辑图标:极简铅笔 SVG(验收要求无 emoji;svg aria-hidden,按钮 aria-label 提供语义)
|
|
1621
2716
|
React.createElement("button", {
|
|
1622
|
-
className: "dbt-btn", onClick: () => setProjectEdited(true), "aria-label": "edit",
|
|
2717
|
+
className: "dbt-btn", onClick: () => setProjectEdited(true), "aria-label": t("edit"),
|
|
1623
2718
|
},
|
|
1624
2719
|
React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
|
|
1625
2720
|
React.createElement("path", {
|
|
@@ -1627,19 +2722,22 @@ window.__ModuleLoader__.load({
|
|
|
1627
2722
|
stroke: "currentColor", strokeWidth: 1.2, strokeLinejoin: "round",
|
|
1628
2723
|
})),
|
|
1629
2724
|
)),
|
|
1630
|
-
error ? React.createElement("div", { className: "dbt-err" }, t("error") + ": " + error) : null,
|
|
1631
|
-
message ? React.createElement("div", { className: "dbt-msg" }, message) : null,
|
|
1632
|
-
//
|
|
1633
|
-
//
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2725
|
+
error ? React.createElement("div", { className: "dbt-err", "aria-live": "polite" }, t("error") + ": " + error) : null,
|
|
2726
|
+
message ? React.createElement("div", { className: "dbt-msg", "aria-live": "polite" }, message) : null,
|
|
2727
|
+
// 视图常驻挂载 + hidden 切换:切 tab 保留各视图内部状态(控制台 SQL、浏览选中不丢);
|
|
2728
|
+
// dbt-in 入场动画只在面板首次打开播一次(.dbt-view 类自带,常驻后不随 tab 重播);
|
|
2729
|
+
// browse 撑满面板剩余高度(BrowsePane 内详情栏才能钉在面板最底部),active 控制首次激活才拉树。
|
|
2730
|
+
// .dbt-view 类的 display:flex 会盖过 hidden 的 UA 样式,故内联 display 同步切换
|
|
2731
|
+
["manage", "grants", "browse", "console"].map((v) =>
|
|
2732
|
+
React.createElement("div", {
|
|
2733
|
+
key: v, className: "dbt-view", hidden: view !== v,
|
|
2734
|
+
style: v === "browse"
|
|
2735
|
+
? (view === "browse" ? { flex: "1 1 auto", minHeight: 0 } : { display: "none" })
|
|
2736
|
+
: (view === v ? undefined : { display: "none" }),
|
|
2737
|
+
},
|
|
2738
|
+
v === "browse"
|
|
2739
|
+
? React.createElement(BrowsePane, { ctx: props.ctx, conns, projectPath, grants, askConfirm, active: view === "browse" })
|
|
2740
|
+
: viewEls[v])),
|
|
1643
2741
|
React.createElement("div", { className: "dbt-muted" }, t("footerHint")),
|
|
1644
2742
|
confirmReq ? React.createElement(DangerDialog, { challenge: confirmReq, onClose: () => setConfirmReq(null) }) : null,
|
|
1645
2743
|
);
|
|
@@ -1702,10 +2800,13 @@ window.__ModuleLoader__.load({
|
|
|
1702
2800
|
}
|
|
1703
2801
|
exports.apply = apply;
|
|
1704
2802
|
exports.inject = inject;
|
|
1705
|
-
//
|
|
2803
|
+
// 测试面:单元格写回 + 控制台工具纯函数 + ConsoleView(集成测试浅渲染元素树)
|
|
1706
2804
|
exports.__testables = {
|
|
1707
2805
|
dialectOf, quoteIdent, parseCellText, isTruncatedCell, isBlobCell,
|
|
1708
|
-
buildUpdate, buildRedisOp, buildMongoOp,
|
|
2806
|
+
buildUpdate, buildRedisOp, buildMongoOp, renderStatementWithParams,
|
|
2807
|
+
splitSqlStatements, fmtDialectOf, toCsv, toJson, fmtMs,
|
|
2808
|
+
sortRows, slicePage, ResultSetGrid,
|
|
2809
|
+
classifyHead, ConsoleView,
|
|
1709
2810
|
};
|
|
1710
2811
|
return module.exports;
|
|
1711
2812
|
},
|