dsh-db-tool 1.5.8 → 1.5.91
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 +1130 -190
- 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 +19 -0
- package/dist/manager.js +306 -4
- 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: "保存中…",
|
|
@@ -66,6 +74,10 @@ window.__ModuleLoader__.load({
|
|
|
66
74
|
auditConfirmed: "已确认",
|
|
67
75
|
// 授权
|
|
68
76
|
grantsHint: "为当前项目授权连接。只读=仅查询/浏览,读写=允许执行语句与脚本。未授权的连接在业务操作中一律拒绝。",
|
|
77
|
+
grantsEmptyHint: "当前项目尚未授权任何连接,在下方每行选择「只读」或「读写」即可完成授权。",
|
|
78
|
+
grantsSummary: "已授权 {granted}/{total} 个连接",
|
|
79
|
+
modeBadgeRo: "只读",
|
|
80
|
+
modeBadgeRw: "读写",
|
|
69
81
|
modeNone: "未授权",
|
|
70
82
|
modeRo: "只读",
|
|
71
83
|
modeRw: "读写",
|
|
@@ -105,14 +117,35 @@ window.__ModuleLoader__.load({
|
|
|
105
117
|
blobNoEdit: "二进制值不支持就地编辑",
|
|
106
118
|
redisTypeRo: "Redis 该类型不支持就地编辑",
|
|
107
119
|
// SQL 控制台
|
|
108
|
-
modeQuery: "只读查询",
|
|
109
|
-
modeExecute: "写入执行",
|
|
110
120
|
modeScript: "脚本",
|
|
111
121
|
run: "执行",
|
|
112
122
|
running: "执行中…",
|
|
113
123
|
sqlPlaceholder: "输入 SQL / 命令…(Redis: [\"GET\",\"key\"];Mongo: {\"find\":\"users\",\"filter\":{}})",
|
|
124
|
+
// SQL 控制台(多标签重构):标签 / 工具 / 预分类 / 事务 / 历史
|
|
125
|
+
modeSql: "SQL",
|
|
126
|
+
newTab: "新建标签",
|
|
127
|
+
closeTab: "关闭标签页",
|
|
128
|
+
queryTab: "查询 {n}",
|
|
129
|
+
runSelection: "执行选中",
|
|
130
|
+
formatSql: "格式化",
|
|
131
|
+
skippedRo: "已跳过(只读授权)",
|
|
132
|
+
locate: "定位",
|
|
133
|
+
historyTitle: "历史",
|
|
134
|
+
historyEmpty: "暂无历史",
|
|
135
|
+
txBegin: "开始事务",
|
|
136
|
+
txCommit: "提交事务",
|
|
137
|
+
txRollback: "回滚事务",
|
|
138
|
+
txActive: "事务进行中",
|
|
139
|
+
txInline: "事务内",
|
|
140
|
+
txDefaultDbOnly: "事务绑定连接默认库:请先清空库选择",
|
|
141
|
+
routeQuery: "查询",
|
|
142
|
+
routeExec: "写入",
|
|
143
|
+
routeScript: "脚本",
|
|
144
|
+
// 视图状态与控制台(T2)
|
|
145
|
+
selectConn: "选择连接…",
|
|
146
|
+
copyFailed: "复制失败",
|
|
114
147
|
scriptPlaceholder: "输入 JS 脚本(vm 沙箱,含 db 助手对象)…",
|
|
115
|
-
paramsJson: "查询参数(JSON
|
|
148
|
+
paramsJson: "查询参数(JSON 数组,可选;多语句时每条共用同一组参数)",
|
|
116
149
|
rowsResult: "{n} 行",
|
|
117
150
|
execResult: "受影响 {n} 行",
|
|
118
151
|
needConfirmTitle: "危险操作确认",
|
|
@@ -160,6 +193,14 @@ window.__ModuleLoader__.load({
|
|
|
160
193
|
edit: "Edit",
|
|
161
194
|
delete: "Delete",
|
|
162
195
|
deleteConnConfirm: "Delete connection \"{name}\"? Its secrets and all project grants will be removed.",
|
|
196
|
+
fieldKind: "Database type",
|
|
197
|
+
requiredMark: " (required)",
|
|
198
|
+
deleteConnTitle: "Delete Connection",
|
|
199
|
+
auditShort: "Audit log",
|
|
200
|
+
noConnsGuide: "No connections yet — add one first, then grant it to your project.",
|
|
201
|
+
addConn: "Add Connection",
|
|
202
|
+
confirmParams: "Params",
|
|
203
|
+
grantModeGroup: "Access mode for {name}",
|
|
163
204
|
save: "Save",
|
|
164
205
|
cancel: "Cancel",
|
|
165
206
|
saving: "Saving…",
|
|
@@ -173,6 +214,10 @@ window.__ModuleLoader__.load({
|
|
|
173
214
|
auditDenied: "denied",
|
|
174
215
|
auditConfirmed: "confirmed",
|
|
175
216
|
grantsHint: "Grant connections to the current project. ro = read-only (query/browse), rw = read-write (execute/script allowed). Unauthorized connections are always rejected.",
|
|
217
|
+
grantsEmptyHint: "No connections granted to this project yet. Pick read-only or read-write on a row below to grant.",
|
|
218
|
+
grantsSummary: "{granted}/{total} connections granted",
|
|
219
|
+
modeBadgeRo: "RO",
|
|
220
|
+
modeBadgeRw: "RW",
|
|
176
221
|
modeNone: "none",
|
|
177
222
|
modeRo: "read-only ro",
|
|
178
223
|
modeRw: "read-write rw",
|
|
@@ -210,14 +255,35 @@ window.__ModuleLoader__.load({
|
|
|
210
255
|
truncatedNoEdit: "Value shown truncated; edit via SQL Console instead",
|
|
211
256
|
blobNoEdit: "Binary values cannot be edited here",
|
|
212
257
|
redisTypeRo: "This Redis type cannot be edited here",
|
|
213
|
-
modeQuery: "query (read-only)",
|
|
214
|
-
modeExecute: "execute (write)",
|
|
215
258
|
modeScript: "script",
|
|
216
259
|
run: "Run",
|
|
217
260
|
running: "Running…",
|
|
218
261
|
sqlPlaceholder: "SQL / command… (Redis: [\"GET\",\"key\"]; Mongo: {\"find\":\"users\",\"filter\":{}})",
|
|
262
|
+
// SQL console (multi-tab rework): tabs / toolbar / pre-classification / transactions / history
|
|
263
|
+
modeSql: "SQL",
|
|
264
|
+
newTab: "New tab",
|
|
265
|
+
closeTab: "Close tab",
|
|
266
|
+
queryTab: "Query {n}",
|
|
267
|
+
runSelection: "Run selection",
|
|
268
|
+
formatSql: "Format",
|
|
269
|
+
skippedRo: "Skipped (read-only grant)",
|
|
270
|
+
locate: "Locate",
|
|
271
|
+
historyTitle: "History",
|
|
272
|
+
historyEmpty: "No history yet",
|
|
273
|
+
txBegin: "Begin",
|
|
274
|
+
txCommit: "Commit",
|
|
275
|
+
txRollback: "Roll back",
|
|
276
|
+
txActive: "In transaction",
|
|
277
|
+
txInline: "In tx",
|
|
278
|
+
txDefaultDbOnly: "Transactions bind the connection's default database: clear the database selection first",
|
|
279
|
+
routeQuery: "read",
|
|
280
|
+
routeExec: "write",
|
|
281
|
+
routeScript: "script",
|
|
282
|
+
// View state & console (T2)
|
|
283
|
+
selectConn: "Select a connection…",
|
|
284
|
+
copyFailed: "Copy failed",
|
|
219
285
|
scriptPlaceholder: "JS script (vm sandbox, with db helper)…",
|
|
220
|
-
paramsJson: "params (JSON array, optional)",
|
|
286
|
+
paramsJson: "params (JSON array, optional; shared by every statement in a batch)",
|
|
221
287
|
rowsResult: "{n} rows",
|
|
222
288
|
execResult: "{n} rows affected",
|
|
223
289
|
needConfirmTitle: "Dangerous operation",
|
|
@@ -274,6 +340,7 @@ window.__ModuleLoader__.load({
|
|
|
274
340
|
err.statement = data.statement;
|
|
275
341
|
err.danger = data.danger;
|
|
276
342
|
err.reason = data.reason;
|
|
343
|
+
err.params = data.params; // 服务端如回传参数则透传(对话框代入展示用;缺失时 undefined 无害)
|
|
277
344
|
}
|
|
278
345
|
throw err;
|
|
279
346
|
}
|
|
@@ -292,7 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
292
359
|
return await send(undefined);
|
|
293
360
|
} catch (e) {
|
|
294
361
|
if (e && e.code === "NEEDS_CONFIRMATION" && e.challengeId) {
|
|
295
|
-
const yes = await askConfirm({ statement: e.statement, danger: e.danger, reason: e.reason });
|
|
362
|
+
const yes = await askConfirm({ statement: e.statement, danger: e.danger, reason: e.reason, params: e.params });
|
|
296
363
|
if (!yes) { const err = new Error(t("cancelled")); err.cancelled = true; throw err; }
|
|
297
364
|
return await send(e.challengeId);
|
|
298
365
|
}
|
|
@@ -317,7 +384,7 @@ window.__ModuleLoader__.load({
|
|
|
317
384
|
styleEl = document.createElement("style");
|
|
318
385
|
styleEl.textContent = [
|
|
319
386
|
"/* ===== 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;}",
|
|
387
|
+
".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
388
|
"@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
389
|
// 浏览面板根自持文字色:PiP 独立文档挂 body 后无侧栏祖先 color 可继承(内嵌/浮窗两态共用)
|
|
323
390
|
".dbt-browse-root{color:var(--dbt-text);}",
|
|
@@ -325,7 +392,7 @@ window.__ModuleLoader__.load({
|
|
|
325
392
|
".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
393
|
"/* ===== iOS segmented control(顶部 tab) ===== */",
|
|
327
394
|
".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:
|
|
395
|
+
".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
396
|
".dbt-tabs button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
330
397
|
"/* ===== inset grouped 卡片 ===== */",
|
|
331
398
|
".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 +401,14 @@ window.__ModuleLoader__.load({
|
|
|
334
401
|
".dbt-col{display:flex;flex-direction:column;gap:8px;}",
|
|
335
402
|
".dbt-col.dbt-col-tight{gap:2px;}",
|
|
336
403
|
"/* ===== 填充式无边框输入 ===== */",
|
|
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:
|
|
404
|
+
".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
405
|
".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
406
|
".dbt-card textarea{font-family:var(--dbt-mono);min-height:96px;resize:vertical;}",
|
|
340
407
|
"/* ===== 按钮 ===== */",
|
|
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);}",
|
|
408
|
+
".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
409
|
".dbt-btn:active{transform:scale(.97);}",
|
|
344
410
|
".dbt-btn.primary{background:var(--dbt-accent);color:#fff;}",
|
|
345
|
-
".dbt-btn.primary:hover{filter:brightness(1.1);}",
|
|
346
411
|
".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
412
|
".dbt-btn:disabled{opacity:.4;cursor:default;transform:none;}",
|
|
349
413
|
"/* ===== 文本反馈 ===== */",
|
|
350
414
|
".dbt-panel strong,.dbt-browse-root strong{font-size:13px;font-weight:600;}",
|
|
@@ -357,11 +421,23 @@ window.__ModuleLoader__.load({
|
|
|
357
421
|
".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
422
|
".dbt-table td{font-family:var(--dbt-mono);}",
|
|
359
423
|
".dbt-tablewrap{border:none;border-radius:var(--dbt-radius-card);overflow:auto;max-height:320px;background:var(--dbt-surface);}",
|
|
424
|
+
"/* ===== 结果网格(SQL 控制台):可排序表头按钮(毛玻璃 sticky 表头复用 .dbt-table th,不另设) ===== */",
|
|
425
|
+
".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;}",
|
|
426
|
+
".dbt-grid-sort:focus-visible{outline:2px solid var(--dbt-accent);outline-offset:-1px;border-radius:2px;}",
|
|
427
|
+
"/* ===== SQL 控制台(多标签):标签条 / 编辑器宿主 / 结果行路由徽标(全 token,跟随明暗) ===== */",
|
|
428
|
+
".dbt-cm-tabbar{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;overflow-x:auto;}",
|
|
429
|
+
".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);}",
|
|
430
|
+
".dbt-cm-tab.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
431
|
+
".dbt-cm-tabclose{border:none;background:transparent;color:inherit;opacity:.5;padding:0 1px;cursor:pointer;font-size:11px;line-height:1;}",
|
|
432
|
+
".dbt-cm-tabclose:hover{opacity:1;}",
|
|
433
|
+
".dbt-editor-host{height:280px;display:flex;flex-direction:column;}",
|
|
434
|
+
".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;}",
|
|
435
|
+
".dbt-editor-host textarea:focus{outline:2px solid var(--dbt-accent);outline-offset:-1px;}",
|
|
436
|
+
".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
437
|
"/* ===== 浏览面板表格:列分明(列间 hairline + 表头加深一档 + 行 hover) ===== */",
|
|
361
438
|
".dbt-browse-root .dbt-table th + th,.dbt-browse-root .dbt-table td + td{border-left:1px solid var(--dbt-separator);}",
|
|
362
439
|
".dbt-browse-root .dbt-table th{background:rgba(30,30,32,.85);}",
|
|
363
440
|
".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
441
|
"@media (prefers-color-scheme: light){.dbt-browse-root .dbt-table th{background:rgba(242,242,247,.9);}}",
|
|
366
442
|
"/* ===== 对话框(macOS alert 材质) ===== */",
|
|
367
443
|
".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 +448,12 @@ window.__ModuleLoader__.load({
|
|
|
372
448
|
".dbt-group{background:var(--dbt-surface);border-radius:var(--dbt-radius-card);overflow:hidden;display:flex;flex-direction:column;}",
|
|
373
449
|
".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
450
|
".dbt-listrow:last-child{border-bottom:none;}",
|
|
375
|
-
".dbt-listrow:hover{background:var(--dbt-surface-strong);}",
|
|
376
451
|
".dbt-tree{display:flex;flex-direction:column;}",
|
|
377
452
|
"/* ===== Navicat 式对象树(数据浏览) ===== */",
|
|
378
453
|
".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
454
|
".dbt-treerow:last-child{border-bottom:none;}",
|
|
380
|
-
".dbt-treerow:hover{background:var(--dbt-surface-strong);}",
|
|
381
455
|
".dbt-treerow.active{color:var(--dbt-accent);font-weight:600;}",
|
|
382
|
-
".dbt-chev{flex:none;width:12px;text-align:center;color:var(--dbt-
|
|
456
|
+
".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
457
|
".dbt-chev.open{transform:rotate(90deg);}",
|
|
384
458
|
".dbt-chev.leaf{visibility:hidden;}",
|
|
385
459
|
".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
@@ -412,40 +486,108 @@ window.__ModuleLoader__.load({
|
|
|
412
486
|
".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
|
|
413
487
|
"/* ===== mini segmented(ro/rw 等切换) ===== */",
|
|
414
488
|
".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:
|
|
489
|
+
".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
490
|
".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
491
|
+
"/* ===== hover 仅精确指针(触屏 tap 不残留 hover 底色) ===== */",
|
|
492
|
+
"@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
493
|
"/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
|
|
418
494
|
"@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:#
|
|
495
|
+
"@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
496
|
"@media (prefers-reduced-motion: reduce){.dbt-panel *,.dbt-browse-root *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
421
497
|
].join("\n");
|
|
422
498
|
document.head.appendChild(styleEl);
|
|
423
499
|
}
|
|
424
500
|
|
|
425
501
|
function DangerDialog(props) {
|
|
426
|
-
const ch = props.challenge; // {statement, danger, reason, resolve}
|
|
502
|
+
const ch = props.challenge; // {statement, danger, reason, resolve, target?, params?, title?, confirmLabel?, hint?}
|
|
503
|
+
const dialogRef = React.useRef(null);
|
|
504
|
+
const cancelRef = React.useRef(null);
|
|
505
|
+
const titleId = "dbt-confirm-title";
|
|
427
506
|
function done(v) { props.onClose(); ch.resolve(v); }
|
|
507
|
+
// 键盘契约:Escape=取消;Tab/Shift+Tab 在「确认执行」「取消」两枚按钮间循环锁定,焦点不逃出对话框
|
|
508
|
+
function onKeyDown(e) {
|
|
509
|
+
if (e.key === "Escape") { e.preventDefault(); done(false); return; }
|
|
510
|
+
if (e.key !== "Tab") return;
|
|
511
|
+
const dialog = dialogRef.current;
|
|
512
|
+
if (!dialog) return;
|
|
513
|
+
const btns = dialog.querySelectorAll("button");
|
|
514
|
+
if (btns.length === 0) return;
|
|
515
|
+
const first = btns[0];
|
|
516
|
+
const last = btns[btns.length - 1];
|
|
517
|
+
const doc = dialog.ownerDocument || document;
|
|
518
|
+
if (!dialog.contains(doc.activeElement)) { e.preventDefault(); (e.shiftKey ? last : first).focus(); return; }
|
|
519
|
+
if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
520
|
+
else if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
521
|
+
}
|
|
522
|
+
// 挂载后焦点落「取消」:次动作为键盘默认,防回车/空格误触危险确认
|
|
523
|
+
React.useEffect(() => {
|
|
524
|
+
if (cancelRef.current && typeof cancelRef.current.focus === "function") cancelRef.current.focus();
|
|
525
|
+
}, []);
|
|
526
|
+
// 有 params 时 pre 展示代入后的完整语句(? → 字面量);params 为空/非法 JSON 视为无参数
|
|
527
|
+
let hasParams = false;
|
|
528
|
+
try {
|
|
529
|
+
const parsed = typeof ch.params === "string" ? JSON.parse(ch.params) : ch.params;
|
|
530
|
+
if (Array.isArray(parsed) && parsed.length > 0) hasParams = true;
|
|
531
|
+
} catch (e) { /* 非法 JSON:按无参数展示 */ }
|
|
532
|
+
const shownStatement = hasParams ? renderStatementWithParams(ch.statement, ch.params) : (ch.statement || "(?)");
|
|
533
|
+
// 目标条徽标:ro=muted、rw=danger,形状对齐 kind 徽标(纯内联样式,样式块不承载对话框专属规则)
|
|
534
|
+
const target = ch.target;
|
|
535
|
+
const modeBadge = target && target.mode
|
|
536
|
+
? React.createElement("span", {
|
|
537
|
+
style: {
|
|
538
|
+
background: "var(--dbt-surface-strong, rgba(120,120,128,.18))",
|
|
539
|
+
borderRadius: "6px",
|
|
540
|
+
padding: "2px 6px",
|
|
541
|
+
fontSize: "11px",
|
|
542
|
+
lineHeight: 1.45,
|
|
543
|
+
fontWeight: 600,
|
|
544
|
+
color: target.mode === "rw" ? "var(--dbt-danger, #ff453a)" : "var(--dbt-text-secondary, rgba(235,235,245,.6))",
|
|
545
|
+
},
|
|
546
|
+
}, target.mode === "rw" ? t("modeRw") : t("modeRo"))
|
|
547
|
+
: null;
|
|
428
548
|
return React.createElement(
|
|
429
549
|
"div",
|
|
430
|
-
{ className: "dbt-overlay", onClick: () => done(false) },
|
|
550
|
+
{ className: "dbt-overlay", onClick: () => done(false), onKeyDown },
|
|
431
551
|
React.createElement(
|
|
432
552
|
"div",
|
|
433
|
-
{
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
553
|
+
{
|
|
554
|
+
className: "dbt-dialog",
|
|
555
|
+
onClick: (e) => e.stopPropagation(),
|
|
556
|
+
role: "dialog",
|
|
557
|
+
"aria-modal": "true",
|
|
558
|
+
"aria-labelledby": titleId,
|
|
559
|
+
ref: dialogRef,
|
|
560
|
+
},
|
|
561
|
+
// macOS alert 风格:纯文字标题 + danger 色,不用 emoji;title 可被 challenge 覆盖(如删除连接)
|
|
562
|
+
React.createElement("strong", { id: titleId, style: { color: "var(--dbt-danger, #ff453a)" } }, ch.title || t("needConfirmTitle")),
|
|
563
|
+
// 目标条:连接名(semibold 主行)+ kind/db(muted)+ ro/rw 徽标;target 缺失不渲染
|
|
564
|
+
target && target.conn ? React.createElement(
|
|
565
|
+
"div",
|
|
566
|
+
{ className: "dbt-row" },
|
|
567
|
+
React.createElement("strong", { style: { fontWeight: 600, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, target.conn),
|
|
568
|
+
target.kind ? React.createElement("span", { className: "dbt-muted" }, target.kind) : null,
|
|
569
|
+
target.db ? React.createElement("span", { className: "dbt-muted" }, target.db) : null,
|
|
570
|
+
modeBadge,
|
|
571
|
+
) : null,
|
|
572
|
+
React.createElement("pre", null, shownStatement),
|
|
573
|
+
hasParams ? React.createElement(
|
|
574
|
+
"div",
|
|
575
|
+
{ className: "dbt-muted", style: { fontFamily: MONO_FONT, wordBreak: "break-all" } },
|
|
576
|
+
t("confirmParams") + " " + (typeof ch.params === "string" ? ch.params : JSON.stringify(ch.params)),
|
|
577
|
+
) : null,
|
|
578
|
+
// 风险等级/原因行仅服务端危险判定场景渲染(删除连接等本地确认无此二字段)
|
|
579
|
+
(ch.danger || ch.reason) ? React.createElement(
|
|
438
580
|
"div",
|
|
439
581
|
{ className: "dbt-muted" },
|
|
440
582
|
t("dangerLevel") + ": " + (ch.danger || "danger") + " · " + t("reason") + ": " + (ch.reason || "-"),
|
|
441
|
-
),
|
|
442
|
-
React.createElement("div", { className: "dbt-muted" }, t("confirmHint")),
|
|
583
|
+
) : null,
|
|
584
|
+
React.createElement("div", { className: "dbt-muted" }, ch.hint || t("confirmHint")),
|
|
443
585
|
React.createElement(
|
|
444
586
|
"div",
|
|
445
587
|
// 末对齐由样式层 .dbt-dialog .dbt-row 规则承担,无需内联 style
|
|
446
588
|
{ 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")),
|
|
589
|
+
React.createElement("button", { className: "dbt-btn danger", onClick: () => done(true) }, ch.confirmLabel || t("confirmRun")),
|
|
590
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => done(false), ref: cancelRef }, t("confirmCancel")),
|
|
449
591
|
),
|
|
450
592
|
),
|
|
451
593
|
);
|
|
@@ -532,6 +674,7 @@ window.__ModuleLoader__.load({
|
|
|
532
674
|
}
|
|
533
675
|
// 表全限定:pg/gauss 用 schema 前缀(库由 execute 的 database 参数路由,语句内不写库名);
|
|
534
676
|
// mysql 带库名(adapter 无 database 路由);oracle/dmdb 的 dbRef 即 OWNER;sqlite 仅表名
|
|
677
|
+
// execute 的 database 参数只传库名(db.schema 形态取点前段),连接目标只能是库,schema 靠语句内全限定
|
|
535
678
|
let table;
|
|
536
679
|
if (kind === "postgresql" || kind === "gaussdb") {
|
|
537
680
|
table = schemaName
|
|
@@ -560,7 +703,7 @@ window.__ModuleLoader__.load({
|
|
|
560
703
|
ops: [{
|
|
561
704
|
statement: "UPDATE " + table + " SET " + setSql + " WHERE " + wheres.join(" AND "),
|
|
562
705
|
params,
|
|
563
|
-
database: kind === "sqlite" ? undefined : dbRef,
|
|
706
|
+
database: kind === "sqlite" ? undefined : dbRef.split(".")[0],
|
|
564
707
|
}],
|
|
565
708
|
};
|
|
566
709
|
}
|
|
@@ -611,6 +754,151 @@ window.__ModuleLoader__.load({
|
|
|
611
754
|
};
|
|
612
755
|
}
|
|
613
756
|
|
|
757
|
+
// 危险确认对话框展示用:把语句里的 ? 占位符逐个替换为参数的字面渲染(仅展示,不参与真实执行)
|
|
758
|
+
// params 支持 JSON 字符串/数组/undefined;字符串加单引号并内部双写转义,数字/布尔原样,
|
|
759
|
+
// null/undefined 渲染为 NULL;字面量超 120 字符截断加省略号;占位符多于参数时剩余 ? 原样保留
|
|
760
|
+
function renderStatementWithParams(statement, params) {
|
|
761
|
+
let arr = params;
|
|
762
|
+
if (typeof arr === "string") {
|
|
763
|
+
try { arr = JSON.parse(arr); } catch (e) { arr = null; }
|
|
764
|
+
}
|
|
765
|
+
if (!Array.isArray(arr)) arr = [];
|
|
766
|
+
const lit = (v) => {
|
|
767
|
+
let s;
|
|
768
|
+
if (v === null || v === undefined) s = "NULL";
|
|
769
|
+
else if (typeof v === "number" || typeof v === "boolean") s = String(v);
|
|
770
|
+
else s = "'" + String(v).split("'").join("''") + "'";
|
|
771
|
+
return s.length > 120 ? s.slice(0, 120) + "…" : s;
|
|
772
|
+
};
|
|
773
|
+
const parts = String(statement == null ? "" : statement).split("?");
|
|
774
|
+
return parts.map((seg, i) => (i === parts.length - 1 ? seg : seg + (i < arr.length ? lit(arr[i]) : "?"))).join("");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/* ---------------- 控制台工具纯函数(语句切分 / 格式化方言 / 导出 / 耗时,ConsoleView 与测试共用) ---------------- */
|
|
778
|
+
|
|
779
|
+
// sql-formatter 方言映射(vendor bundle 的 format() 用);mongodb/redis 非 SQL 方言返回 null
|
|
780
|
+
function fmtDialectOf(kind) {
|
|
781
|
+
if (kind === "mysql") return "mysql";
|
|
782
|
+
if (kind === "postgresql" || kind === "gaussdb") return "postgresql";
|
|
783
|
+
if (kind === "sqlite") return "sqlite";
|
|
784
|
+
if (kind === "oracle" || kind === "dmdb") return "plsql";
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// SQL 文本 → 语句列表 [{ text, start }]:text 为原文切片(保留内部注释/空白),start 为语句
|
|
789
|
+
// 首字符偏移(跳过前导空白/注释,错误就地显示后定位光标用)。词法跳过与 lang-sql 方言 spec
|
|
790
|
+
// 一一对应:单/双引号字符串(反斜杠与双写转义,mysql 另含反引号标识符)、-- 行注释
|
|
791
|
+
// (slashComments)、/* */ 块注释、$$...$$ dollar-quoting(doubleDollarQuotedStrings,仅
|
|
792
|
+
// postgresql/gaussdb)、MySQL DELIMITER 自定义分隔符(客户端指令:仅语句缓冲为空时切换后续
|
|
793
|
+
// 分隔符,指令行不产生语句)、Oracle/达梦 BEGIN..END 块(块内分号不切分;CREATE
|
|
794
|
+
// PROCEDURE/FUNCTION 体的分号由该块覆盖)。空/纯注释语句被过滤;无尾分隔符的最后一条也返回。
|
|
795
|
+
// ponytail: depth 计数不识别 CASE..END/LOOP/IF 等(块内混用可能提前切分);需要时换完整 PL/SQL 词法
|
|
796
|
+
function splitSqlStatements(sql, kind) {
|
|
797
|
+
const src = String(sql == null ? "" : sql);
|
|
798
|
+
const out = [];
|
|
799
|
+
const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
800
|
+
let sep = ";";
|
|
801
|
+
let i = 0, stmtStart = 0, contentStart = 0, hasContent = false, depth = 0; // depth: BEGIN..END 嵌套计数
|
|
802
|
+
while (i < src.length) {
|
|
803
|
+
const c = src[i];
|
|
804
|
+
if (c === "-" && src[i + 1] === "-") { // -- 行注释至行尾
|
|
805
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (c === "/" && src[i + 1] === "*") { // /* 块注释至 */(未闭合则吞到结尾)
|
|
809
|
+
i += 2;
|
|
810
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
811
|
+
i = Math.min(src.length, i + 2);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (c === "'" || c === '"' || (kind === "mysql" && c === "`")) { // 引号串:反斜杠与双写转义
|
|
815
|
+
if (!hasContent) contentStart = i;
|
|
816
|
+
hasContent = true;
|
|
817
|
+
i++;
|
|
818
|
+
while (i < src.length) {
|
|
819
|
+
if (src[i] === "\\") { i += 2; continue; }
|
|
820
|
+
if (src[i] === c) {
|
|
821
|
+
if (src[i + 1] === c) { i += 2; continue; } // 双写转义
|
|
822
|
+
i++;
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
i++;
|
|
826
|
+
}
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if ((kind === "postgresql" || kind === "gaussdb") && c === "$" && src[i + 1] === "$") {
|
|
830
|
+
if (!hasContent) contentStart = i; // $$dollar 串至下一 $$(未闭合则吞到结尾)
|
|
831
|
+
hasContent = true;
|
|
832
|
+
i += 2;
|
|
833
|
+
while (i < src.length && !(src[i] === "$" && src[i + 1] === "$")) i++;
|
|
834
|
+
i = Math.min(src.length, i + 2);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
// DELIMITER <sep> 客户端指令:仅当前语句缓冲无内容时生效(mysql cli 行首语义)
|
|
838
|
+
if (kind === "mysql" && depth === 0 && !hasContent
|
|
839
|
+
&& (i === 0 || !isWord(src[i - 1]))
|
|
840
|
+
&& src.slice(i, i + 9).toUpperCase() === "DELIMITER"
|
|
841
|
+
&& (src[i + 9] === " " || src[i + 9] === "\t")) {
|
|
842
|
+
let j = i + 9, e = j;
|
|
843
|
+
while (e < src.length && src[e] !== "\n" && src[e] !== "\r") e++;
|
|
844
|
+
while (j < e && (src[j] === " " || src[j] === "\t")) j++;
|
|
845
|
+
if (j < e) { sep = src.slice(j, e); stmtStart = e; i = e; continue; }
|
|
846
|
+
}
|
|
847
|
+
// BEGIN/END 词计数(oracle/dmdb):块内分号不切分;END 不把 depth 减到负(顶层 CASE..END 不受影响)
|
|
848
|
+
if ((kind === "oracle" || kind === "dmdb") && (i === 0 || !isWord(src[i - 1]))) {
|
|
849
|
+
if (src.slice(i, i + 5).toUpperCase() === "BEGIN" && !isWord(src[i + 5] || " ")) {
|
|
850
|
+
if (!hasContent) contentStart = i;
|
|
851
|
+
depth++; hasContent = true; i += 5; continue;
|
|
852
|
+
}
|
|
853
|
+
if (src.slice(i, i + 3).toUpperCase() === "END" && !isWord(src[i + 3] || " ")) {
|
|
854
|
+
if (depth > 0) depth--;
|
|
855
|
+
if (!hasContent) contentStart = i;
|
|
856
|
+
hasContent = true; i += 3; continue;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (depth === 0 && src.startsWith(sep, i)) { // 语句终止:收割有内容的语句
|
|
860
|
+
if (hasContent) out.push({ text: src.slice(stmtStart, i), start: contentStart });
|
|
861
|
+
i += sep.length;
|
|
862
|
+
stmtStart = i;
|
|
863
|
+
contentStart = i;
|
|
864
|
+
hasContent = false;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (!/\s/.test(c)) { if (!hasContent) contentStart = i; hasContent = true; }
|
|
868
|
+
i++;
|
|
869
|
+
}
|
|
870
|
+
if (hasContent) out.push({ text: src.slice(stmtStart), start: contentStart }); // 无尾分隔符的最后一条
|
|
871
|
+
return out;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// 结果导出:CSV 侧 NULL/undefined → 空串,含逗号/引号/换行的值按 RFC 4180 双引号包裹且内部
|
|
875
|
+
// 双写转义,行尾统一 \r\n;JSON 侧 NULL 保留 null(不落为空串,避免与空字符串列混淆)
|
|
876
|
+
function csvCell(v) {
|
|
877
|
+
const s = v == null ? "" : String(v);
|
|
878
|
+
return /[",\r\n]/.test(s) ? '"' + s.split('"').join('""') + '"' : s;
|
|
879
|
+
}
|
|
880
|
+
function toCsv(columns, rows) {
|
|
881
|
+
const cols = Array.isArray(columns) ? columns : [];
|
|
882
|
+
const lines = [cols.map(csvCell).join(",")];
|
|
883
|
+
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
884
|
+
lines.push(cols.map((_, i) => csvCell((row || [])[i])).join(","));
|
|
885
|
+
}
|
|
886
|
+
return lines.join("\r\n") + "\r\n";
|
|
887
|
+
}
|
|
888
|
+
function toJson(columns, rows) {
|
|
889
|
+
const cols = Array.isArray(columns) ? columns : [];
|
|
890
|
+
return JSON.stringify((Array.isArray(rows) ? rows : []).map((row) => {
|
|
891
|
+
const o = {};
|
|
892
|
+
cols.forEach((c, i) => { o[c] = (row || [])[i] == null ? null : (row || [])[i]; });
|
|
893
|
+
return o;
|
|
894
|
+
}));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 耗时展示:<1000ms 取整为 "N ms",否则秒保留两位小数
|
|
898
|
+
function fmtMs(n) {
|
|
899
|
+
return n < 1000 ? Math.round(n) + " ms" : (n / 1000).toFixed(2) + " s";
|
|
900
|
+
}
|
|
901
|
+
|
|
614
902
|
// Stryker disable all(buildMongoOp 之后:以下 auditTable/KIND_DEFAULTS/buildEditForm 为连接表单工具,无单元断言)
|
|
615
903
|
function auditTable(audit) {
|
|
616
904
|
return resultTable(
|
|
@@ -673,11 +961,31 @@ window.__ModuleLoader__.load({
|
|
|
673
961
|
|
|
674
962
|
// UI 组件渲染层无单元断言(纯函数层止于此),禁用至文件尾
|
|
675
963
|
// Stryker disable all
|
|
964
|
+
// 键盘焦点环(单一事实源,ConnForm/ManageView/treerow/preview tr 共用):样式块本轮冻结、
|
|
965
|
+
// :focus-visible 无内联 style 通道,focus 事件中探测键盘聚焦后直写 accent 内缩描边,blur 还原。
|
|
966
|
+
// selfCheck=true 用于行容器(tr):子元素聚焦冒泡(React onFocus 走 focusin)时忽略,仅行自身生效。
|
|
967
|
+
function focusRingProps(selfCheck) {
|
|
968
|
+
return {
|
|
969
|
+
onFocus: (e) => {
|
|
970
|
+
const el = e.currentTarget;
|
|
971
|
+
if (selfCheck && e.target !== el) return;
|
|
972
|
+
if (el.matches && el.matches(":focus-visible")) {
|
|
973
|
+
el.style.outline = "2px solid var(--dbt-accent, #0a84ff)";
|
|
974
|
+
el.style.outlineOffset = "-1px";
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
onBlur: (e) => { e.currentTarget.style.outline = ""; e.currentTarget.style.outlineOffset = ""; },
|
|
978
|
+
};
|
|
979
|
+
}
|
|
676
980
|
function ConnForm(props) {
|
|
677
981
|
// 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
|
|
678
982
|
const [form, setForm] = React.useState(() => props.initial || freshForm());
|
|
679
983
|
const [dirty, setDirty] = React.useState(() => new Set()); // 用户手改过的字段(切 kind 时保留)
|
|
680
984
|
const [draftTest, setDraftTest] = React.useState(null); // null | {ok, msg}
|
|
985
|
+
// 无障碍:URL/分字段切换(及表单挂载)后焦点引导进该模式首个输入,避免键盘/SR 用户滞留切换按钮
|
|
986
|
+
const firstInputRef = React.useRef(null);
|
|
987
|
+
React.useEffect(() => { if (firstInputRef.current) firstInputRef.current.focus(); }, [form.mode]);
|
|
988
|
+
// 按钮焦点环:统一走模块级 focusRingProps()(语义同前)
|
|
681
989
|
function patch(p) {
|
|
682
990
|
setForm((prev) => Object.assign({}, prev, p));
|
|
683
991
|
setDirty((prev) => { const n = new Set(prev); for (const k of Object.keys(p)) n.add(k); return n; });
|
|
@@ -733,6 +1041,9 @@ window.__ModuleLoader__.load({
|
|
|
733
1041
|
setDraftTest({ ok: false, msg: t("testFail") + ":" + (e && e.message ? e.message : String(e)) });
|
|
734
1042
|
}
|
|
735
1043
|
}
|
|
1044
|
+
// aria-label 局部文案:与 placeholder 同源,避免表达式重复
|
|
1045
|
+
const pwLabel = props.initial && props.initial.hasPassword ? t("passwordSaved") : t("fieldsPassword");
|
|
1046
|
+
const idLabel = t("connId") + "(" + t("idAutoHint") + ")";
|
|
736
1047
|
return React.createElement(
|
|
737
1048
|
"div",
|
|
738
1049
|
{ className: "dbt-card" },
|
|
@@ -741,10 +1052,10 @@ window.__ModuleLoader__.load({
|
|
|
741
1052
|
React.createElement(
|
|
742
1053
|
"div",
|
|
743
1054
|
{ className: "dbt-row" },
|
|
744
|
-
React.createElement("select", { value: form.kind, onChange: (e) => switchKind(e.target.value) },
|
|
1055
|
+
React.createElement("select", { value: form.kind, "aria-label": t("fieldKind"), onChange: (e) => switchKind(e.target.value) },
|
|
745
1056
|
DB_KINDS.map((k) => React.createElement("option", { key: k, value: k }, k))),
|
|
746
|
-
React.createElement("input", { placeholder: t("connName"), value: form.name, onChange: (e) => patch({ name: e.target.value }) }),
|
|
747
|
-
React.createElement("input", { placeholder:
|
|
1057
|
+
React.createElement("input", { placeholder: t("connName"), "aria-label": t("connName"), value: form.name, onChange: (e) => patch({ name: e.target.value }) }),
|
|
1058
|
+
React.createElement("input", { placeholder: idLabel, "aria-label": idLabel, value: form.id, disabled: !!props.initial, onChange: (e) => patch({ id: e.target.value }) }),
|
|
748
1059
|
React.createElement("label", { className: "dbt-row", style: { flex: "none" } },
|
|
749
1060
|
React.createElement("input", { type: "checkbox", checked: !!form.ssl, onChange: (e) => patch({ ssl: e.target.checked }) }), t("ssl")),
|
|
750
1061
|
),
|
|
@@ -752,39 +1063,39 @@ window.__ModuleLoader__.load({
|
|
|
752
1063
|
React.createElement(
|
|
753
1064
|
"div",
|
|
754
1065
|
{ className: "dbt-seg" },
|
|
755
|
-
React.createElement("button", { className: form.mode === "url" ? "active" : "", onClick: () => patch({ mode: "url" }) }, t("urlMode")),
|
|
756
|
-
React.createElement("button", { className: form.mode === "fields" ? "active" : "", onClick: reenterFields }, t("fieldsMode")),
|
|
1066
|
+
React.createElement("button", { className: form.mode === "url" ? "active" : "", "aria-pressed": form.mode === "url", onClick: () => patch({ mode: "url" }), ...focusRingProps() }, t("urlMode")),
|
|
1067
|
+
React.createElement("button", { className: form.mode === "fields" ? "active" : "", "aria-pressed": form.mode === "fields", onClick: reenterFields, ...focusRingProps() }, t("fieldsMode")),
|
|
757
1068
|
),
|
|
758
1069
|
form.mode === "url"
|
|
759
1070
|
? React.createElement(
|
|
760
1071
|
"div",
|
|
761
1072
|
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
762
|
-
React.createElement("input", { placeholder: t("url"), value: form.url, onChange: (e) => patch({ url: e.target.value }) }),
|
|
1073
|
+
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 }) }),
|
|
763
1074
|
React.createElement("div", { className: "dbt-row" },
|
|
764
|
-
React.createElement("input", { placeholder: t("urlUser"), value: form.urlUser, onChange: (e) => patch({ urlUser: e.target.value }) }),
|
|
765
|
-
React.createElement("input", { type: "password", placeholder: t("urlPassword"), value: form.urlPassword, onChange: (e) => patch({ urlPassword: e.target.value }) })),
|
|
1075
|
+
React.createElement("input", { placeholder: t("urlUser"), "aria-label": t("urlUser"), value: form.urlUser, onChange: (e) => patch({ urlUser: e.target.value }) }),
|
|
1076
|
+
React.createElement("input", { type: "password", placeholder: t("urlPassword"), "aria-label": t("urlPassword"), value: form.urlPassword, onChange: (e) => patch({ urlPassword: e.target.value }) })),
|
|
766
1077
|
)
|
|
767
1078
|
: React.createElement(
|
|
768
1079
|
"div",
|
|
769
1080
|
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
770
1081
|
React.createElement("div", { className: "dbt-row" },
|
|
771
|
-
React.createElement("input", { placeholder: t("fieldsHost"), value: form.host, onChange: (e) => patch({ host: e.target.value }) }),
|
|
772
|
-
React.createElement("input", { placeholder: t("fieldsPort"), value: form.port, onChange: (e) => patch({ port: e.target.value }) }),
|
|
773
|
-
React.createElement("input", { placeholder: t("fieldsDatabase"), value: form.database, onChange: (e) => patch({ database: e.target.value }) })),
|
|
1082
|
+
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 }) }),
|
|
1083
|
+
React.createElement("input", { placeholder: t("fieldsPort"), "aria-label": t("fieldsPort"), value: form.port, onChange: (e) => patch({ port: e.target.value }) }),
|
|
1084
|
+
React.createElement("input", { placeholder: t("fieldsDatabase"), "aria-label": t("fieldsDatabase"), value: form.database, onChange: (e) => patch({ database: e.target.value }) })),
|
|
774
1085
|
React.createElement("div", { className: "dbt-row" },
|
|
775
|
-
React.createElement("input", { placeholder: t("fieldsUser"), value: form.user, onChange: (e) => patch({ user: e.target.value }) }),
|
|
776
|
-
React.createElement("input", { type: "password", placeholder:
|
|
1086
|
+
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 }) }),
|
|
1087
|
+
React.createElement("input", { type: "password", placeholder: pwLabel, "aria-label": pwLabel, value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
|
|
777
1088
|
),
|
|
778
1089
|
// 分组 3:操作
|
|
779
1090
|
React.createElement(
|
|
780
1091
|
"div",
|
|
781
1092
|
{ className: "dbt-row" },
|
|
782
|
-
React.createElement("button", { className: "dbt-btn primary", disabled: props.busy, onClick: submit }, props.busy ? t("saving") : t("save")),
|
|
783
|
-
React.createElement("button", { className: "dbt-btn", disabled: draftTest && draftTest.ok === null, onClick: testDraft },
|
|
1093
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: props.busy, onClick: submit, ...focusRingProps() }, props.busy ? t("saving") : t("save")),
|
|
1094
|
+
React.createElement("button", { className: "dbt-btn", disabled: draftTest && draftTest.ok === null, onClick: testDraft, ...focusRingProps() },
|
|
784
1095
|
draftTest && draftTest.ok === null ? t("testing") : t("test")),
|
|
785
|
-
React.createElement("button", { className: "dbt-btn", onClick: props.onCancel }, t("cancel")),
|
|
1096
|
+
React.createElement("button", { className: "dbt-btn", onClick: props.onCancel, ...focusRingProps() }, t("cancel")),
|
|
786
1097
|
draftTest && draftTest.ok !== null
|
|
787
|
-
? React.createElement("span", { className: draftTest.ok ? "dbt-msg" : "dbt-err" }, draftTest.msg)
|
|
1098
|
+
? React.createElement("span", { className: draftTest.ok ? "dbt-msg" : "dbt-err", role: draftTest.ok ? "status" : "alert" }, draftTest.msg)
|
|
788
1099
|
: null,
|
|
789
1100
|
),
|
|
790
1101
|
);
|
|
@@ -796,6 +1107,7 @@ window.__ModuleLoader__.load({
|
|
|
796
1107
|
const [auditOpen, setAuditOpen] = React.useState(false);
|
|
797
1108
|
const [audit, setAudit] = React.useState(null);
|
|
798
1109
|
const [testInfo, setTestInfo] = React.useState({}); // connId -> "ok" | "fail: msg"
|
|
1110
|
+
// 键盘焦点态:统一走模块级 focusRingProps()(描边同 DESIGN.md focus 规范,accent 2px 内缩)
|
|
799
1111
|
|
|
800
1112
|
async function saveConn(body, reset) {
|
|
801
1113
|
await props.run("save", async () => {
|
|
@@ -819,7 +1131,17 @@ window.__ModuleLoader__.load({
|
|
|
819
1131
|
}
|
|
820
1132
|
}
|
|
821
1133
|
async function delConn(c) {
|
|
822
|
-
|
|
1134
|
+
// 删除确认走全局 DangerDialog(askConfirm 通道),与危险 SQL 确认同一对话框与键盘契约;
|
|
1135
|
+
// window.confirm 已移除(原生弹窗无 aria/焦点管理,且样式割裂)
|
|
1136
|
+
const desc = c.safeUrl || [c.kind, c.host ? c.host + (c.port ? ":" + c.port : "") : ""].filter(Boolean).join(" · ") || c.id;
|
|
1137
|
+
const yes = await props.askConfirm({
|
|
1138
|
+
title: t("deleteConnTitle"),
|
|
1139
|
+
statement: desc,
|
|
1140
|
+
hint: t("deleteConnConfirm", { name: c.name || c.id }),
|
|
1141
|
+
confirmLabel: t("delete"),
|
|
1142
|
+
target: { conn: c.name || c.id, kind: c.kind || "", db: "", mode: "" },
|
|
1143
|
+
});
|
|
1144
|
+
if (!yes) return;
|
|
823
1145
|
await props.run("del", async () => {
|
|
824
1146
|
await api("connections/" + encodeURIComponent(c.id), { method: "DELETE" });
|
|
825
1147
|
await reload();
|
|
@@ -849,12 +1171,19 @@ window.__ModuleLoader__.load({
|
|
|
849
1171
|
React.createElement(
|
|
850
1172
|
"div",
|
|
851
1173
|
{ className: "dbt-row", style: { justifyContent: "space-between" } },
|
|
852
|
-
React.createElement("button", { className: "dbt-btn",
|
|
853
|
-
React.createElement("button", { className: "dbt-btn primary", onClick: () => setEditing("new") }, t("newConn")),
|
|
1174
|
+
React.createElement("button", { className: "dbt-btn", "aria-expanded": auditOpen, ...focusRingProps(), onClick: loadAudit }, t("auditShort")),
|
|
1175
|
+
React.createElement("button", { className: "dbt-btn primary", ...focusRingProps(), onClick: () => setEditing("new") }, t("newConn")),
|
|
854
1176
|
),
|
|
855
|
-
conns.length === 0
|
|
856
|
-
//
|
|
857
|
-
|
|
1177
|
+
conns.length === 0
|
|
1178
|
+
? // 空状态引导:一句引导 + 主按钮直达新增表单(state 切换在 ManageView 内)
|
|
1179
|
+
React.createElement(
|
|
1180
|
+
"div",
|
|
1181
|
+
{ className: "dbt-card", style: { alignItems: "center", textAlign: "center" } },
|
|
1182
|
+
React.createElement("div", { className: "dbt-muted" }, t("noConnsGuide")),
|
|
1183
|
+
React.createElement("button", { className: "dbt-btn primary", ...focusRingProps(), onClick: () => setEditing("new") }, t("addConn")),
|
|
1184
|
+
)
|
|
1185
|
+
: // iOS inset grouped:一张卡装全部连接,行间 hairline 分隔
|
|
1186
|
+
React.createElement(
|
|
858
1187
|
"div",
|
|
859
1188
|
{ className: "dbt-group" },
|
|
860
1189
|
conns.map((c) =>
|
|
@@ -868,7 +1197,8 @@ window.__ModuleLoader__.load({
|
|
|
868
1197
|
React.createElement(
|
|
869
1198
|
"div",
|
|
870
1199
|
{ className: "dbt-row" },
|
|
871
|
-
|
|
1200
|
+
// kind 为技术元数据,降次级色避免与名称主级争夺权重(DESIGN.md 次级文字语义)
|
|
1201
|
+
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),
|
|
872
1202
|
React.createElement("strong", { style: { fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
|
|
873
1203
|
),
|
|
874
1204
|
(c.safeUrl || c.host) ? React.createElement(
|
|
@@ -876,15 +1206,16 @@ window.__ModuleLoader__.load({
|
|
|
876
1206
|
{ className: "dbt-muted", style: { fontFamily: MONO_FONT, fontSize: 11, wordBreak: "break-all" } },
|
|
877
1207
|
c.safeUrl || (c.host + ":" + (c.port || "")),
|
|
878
1208
|
) : null,
|
|
879
|
-
|
|
1209
|
+
// 测试反馈三态:✓ 绿=成功 / ✗ 红=失败 / 其余(测试中…)中性灰;DESIGN.md 语义色 红=失败 绿=成功,role=status 供读屏播报
|
|
1210
|
+
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,
|
|
880
1211
|
),
|
|
881
1212
|
// 右列:动作按钮(次要语义,danger 仅删除)
|
|
882
1213
|
React.createElement(
|
|
883
1214
|
"div",
|
|
884
1215
|
{ className: "dbt-row", style: { flex: "none" } },
|
|
885
|
-
React.createElement("button", { className: "dbt-btn", disabled: busy === "test", onClick: () => testConn(c.id) }, t("test")),
|
|
886
|
-
React.createElement("button", { className: "dbt-btn", onClick: () => setEditing(c) }, t("edit")),
|
|
887
|
-
React.createElement("button", { className: "dbt-btn danger", disabled: busy === "del", onClick: () => delConn(c) }, t("delete")),
|
|
1216
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "test", ...focusRingProps(), onClick: () => testConn(c.id) }, t("test")),
|
|
1217
|
+
React.createElement("button", { className: "dbt-btn", ...focusRingProps(), onClick: () => setEditing(c) }, t("edit")),
|
|
1218
|
+
React.createElement("button", { className: "dbt-btn danger", disabled: busy === "del", ...focusRingProps(), onClick: () => delConn(c) }, t("delete")),
|
|
888
1219
|
),
|
|
889
1220
|
),
|
|
890
1221
|
),
|
|
@@ -892,7 +1223,8 @@ window.__ModuleLoader__.load({
|
|
|
892
1223
|
auditOpen
|
|
893
1224
|
? React.createElement(
|
|
894
1225
|
"div",
|
|
895
|
-
|
|
1226
|
+
// 展开入场复用既有 dbt-in(仅 opacity/transform、.22s、var(--dbt-ease);reduced-motion 全局已关),与视图切换语言一致
|
|
1227
|
+
{ className: "dbt-card", style: { animation: "dbt-in .22s var(--dbt-ease)" } },
|
|
896
1228
|
React.createElement("strong", null, t("auditTitle", { n: 50 })),
|
|
897
1229
|
!props.projectPath ? React.createElement("div", { className: "dbt-muted" }, t("projectUnbound")) :
|
|
898
1230
|
!audit || audit.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("auditEmpty")) :
|
|
@@ -905,6 +1237,7 @@ window.__ModuleLoader__.load({
|
|
|
905
1237
|
/* ---------------- 项目授权 ---------------- */
|
|
906
1238
|
function GrantsView(props) {
|
|
907
1239
|
const { conns, grants, projectPath } = props;
|
|
1240
|
+
const grantedCount = (grants || []).length;
|
|
908
1241
|
function modeOf(connId) {
|
|
909
1242
|
const g = (grants || []).find((x) => x.connId === connId);
|
|
910
1243
|
return g ? g.mode : "";
|
|
@@ -916,11 +1249,36 @@ window.__ModuleLoader__.load({
|
|
|
916
1249
|
await props.reload();
|
|
917
1250
|
});
|
|
918
1251
|
}
|
|
1252
|
+
// 模式徽标:语义色描边胶囊(rw=系统绿成功语义、ro=次级灰中性),颜色+文字双编码;
|
|
1253
|
+
// 描边胶囊复用 NULL chip 语言(11px/600、1px 8px、999px),light 模式自动跟 token 反转
|
|
1254
|
+
function modeBadge(m) {
|
|
1255
|
+
if (m !== "ro" && m !== "rw") return null;
|
|
1256
|
+
const color = m === "rw" ? "var(--dbt-success, #30d158)" : "var(--dbt-text-secondary, rgba(235,235,245,.6))";
|
|
1257
|
+
return React.createElement("span", {
|
|
1258
|
+
"aria-hidden": "true",
|
|
1259
|
+
style: {
|
|
1260
|
+
flex: "none",
|
|
1261
|
+
border: "1px solid " + color,
|
|
1262
|
+
color: color,
|
|
1263
|
+
borderRadius: "999px",
|
|
1264
|
+
padding: "1px 8px",
|
|
1265
|
+
fontSize: "var(--dbt-caption, 11px)",
|
|
1266
|
+
fontWeight: 600,
|
|
1267
|
+
lineHeight: 1.45,
|
|
1268
|
+
},
|
|
1269
|
+
}, m === "rw" ? t("modeBadgeRw") : t("modeBadgeRo"));
|
|
1270
|
+
}
|
|
919
1271
|
return React.createElement(
|
|
920
1272
|
"div",
|
|
921
1273
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
922
1274
|
React.createElement("div", { className: "dbt-muted" }, t("grantsHint")),
|
|
923
1275
|
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
1276
|
+
// 状态行:空授权引导 / 已授权计数摘要共用一槽(授权变更后 reload 触发重渲染,polite 播报)
|
|
1277
|
+
React.createElement(
|
|
1278
|
+
"div",
|
|
1279
|
+
{ className: "dbt-muted", "aria-live": "polite" },
|
|
1280
|
+
grantedCount === 0 ? t("grantsEmptyHint") : t("grantsSummary", { granted: grantedCount, total: conns.length }),
|
|
1281
|
+
),
|
|
924
1282
|
// iOS inset grouped:授权行装一张卡,行间 hairline(授权链路:seg 点击 → PUT/DELETE /grants)
|
|
925
1283
|
React.createElement(
|
|
926
1284
|
"div",
|
|
@@ -930,20 +1288,23 @@ window.__ModuleLoader__.load({
|
|
|
930
1288
|
return React.createElement(
|
|
931
1289
|
"div",
|
|
932
1290
|
{ className: "dbt-listrow", key: c.id },
|
|
933
|
-
//
|
|
1291
|
+
// 主行对齐连接管理:kind 填充 pill + 名称 13px semibold + 模式徽标(seg 为权威状态源,徽标 aria-hidden 免重复播报)
|
|
934
1292
|
React.createElement(
|
|
935
1293
|
"div",
|
|
936
|
-
{ style: { flex: 1, minWidth: 0
|
|
937
|
-
React.createElement("span", { style: {
|
|
938
|
-
React.createElement("
|
|
1294
|
+
{ className: "dbt-row", style: { flex: 1, minWidth: 0 } },
|
|
1295
|
+
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),
|
|
1296
|
+
React.createElement("strong", { style: { fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
|
|
1297
|
+
modeBadge(m),
|
|
939
1298
|
),
|
|
940
1299
|
React.createElement(
|
|
941
1300
|
"div",
|
|
942
|
-
{ className: "dbt-seg", "aria-label": c.name || c.id },
|
|
1301
|
+
{ className: "dbt-seg", role: "radiogroup", "aria-label": t("grantModeGroup", { name: c.name || c.id }) },
|
|
943
1302
|
["", "ro", "rw"].map((mode) =>
|
|
944
1303
|
React.createElement("button", {
|
|
945
1304
|
key: mode || "none",
|
|
946
1305
|
className: mode === m ? "active" : "",
|
|
1306
|
+
role: "radio",
|
|
1307
|
+
"aria-checked": mode === m,
|
|
947
1308
|
disabled: props.busy === "grant",
|
|
948
1309
|
onClick: () => setGrant(c.id, mode),
|
|
949
1310
|
}, mode === "" ? t("modeNone") : mode === "ro" ? t("modeRo") : t("modeRw")),
|
|
@@ -996,13 +1357,14 @@ window.__ModuleLoader__.load({
|
|
|
996
1357
|
}));
|
|
997
1358
|
}
|
|
998
1359
|
// autoExpand:挂载后自动展开第一层级(连接节点),复用 toggleConn 的展开+懒加载;
|
|
999
|
-
// 加载失败仍走既有就地错误行机制。声明在清缓存 effect 之后:项目切换时先清 open
|
|
1360
|
+
// 加载失败仍走既有就地错误行机制。声明在清缓存 effect 之后:项目切换时先清 open 再重新展开。
|
|
1361
|
+
// autoExpand 受控于父级(BrowsePane 首次激活才置 true):常驻挂载下隐藏视图不自动发请求
|
|
1000
1362
|
React.useEffect(() => {
|
|
1001
1363
|
if (!autoExpand) return;
|
|
1002
1364
|
for (const c of conns) {
|
|
1003
1365
|
if (!open["c:" + c.id]) toggleConn(c);
|
|
1004
1366
|
}
|
|
1005
|
-
}, [conns, projectPath]); // eslint-disable-line
|
|
1367
|
+
}, [conns, projectPath, autoExpand]); // eslint-disable-line
|
|
1006
1368
|
// PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
|
|
1007
1369
|
const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
|
|
1008
1370
|
function toggleDb(c, d) {
|
|
@@ -1023,18 +1385,61 @@ window.__ModuleLoader__.load({
|
|
|
1023
1385
|
setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
|
|
1024
1386
|
}));
|
|
1025
1387
|
}
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1388
|
+
// 树键盘导航(WAI-ARIA treeview / roving tabindex):Tab 仅进停靠行,方向键在可见行间移动;
|
|
1389
|
+
// 右箭头展开、左箭头收起或回到父级(父级 = DOM 序向上第一个更浅层级行),Home/End 跳首尾
|
|
1390
|
+
const treeRef = React.useRef(null);
|
|
1391
|
+
const [rovingKey, setRovingKey] = React.useState(null); // roving 停靠行 key;null 时兜底首行
|
|
1392
|
+
const liveKeysRef = React.useRef(null); // 上一轮渲染的交互行 key 集:停靠行被折叠/清缓存后失效时回退首行
|
|
1393
|
+
const roving = rovingKey && liveKeysRef.current && liveKeysRef.current.has(rovingKey) ? rovingKey : null;
|
|
1394
|
+
const firstKey = conns.length ? "c:" + conns[0].id : null; // 树首行恒为第一个连接行
|
|
1395
|
+
const liveKeys = new Set(); // 本轮渲染收集,循环结束后写回 liveKeysRef
|
|
1396
|
+
function moveFocus(rows, i) {
|
|
1397
|
+
const row = rows[i];
|
|
1398
|
+
if (!row) return;
|
|
1399
|
+
row.focus();
|
|
1400
|
+
setRovingKey(row.getAttribute("data-treekey"));
|
|
1401
|
+
}
|
|
1402
|
+
function onTreeKeyDown(e) {
|
|
1403
|
+
const row = e.target && e.target.closest ? e.target.closest('[role="treeitem"]') : null;
|
|
1404
|
+
if (!row || !treeRef.current) return;
|
|
1405
|
+
const rows = Array.prototype.slice.call(treeRef.current.querySelectorAll('[role="treeitem"]'));
|
|
1406
|
+
const i = rows.indexOf(row);
|
|
1407
|
+
const exp = row.getAttribute("aria-expanded");
|
|
1408
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
1409
|
+
e.preventDefault();
|
|
1410
|
+
const j = e.key === "ArrowDown" ? i + 1 : i - 1;
|
|
1411
|
+
if (j >= 0 && j < rows.length) moveFocus(rows, j);
|
|
1412
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
1413
|
+
e.preventDefault();
|
|
1414
|
+
moveFocus(rows, e.key === "Home" ? 0 : rows.length - 1);
|
|
1415
|
+
} else if (e.key === "ArrowRight") {
|
|
1416
|
+
if (exp === "false") { e.preventDefault(); row.click(); } // 展开当前节点(复用点击行为)
|
|
1417
|
+
} else if (e.key === "ArrowLeft") {
|
|
1418
|
+
if (exp === "true") { e.preventDefault(); row.click(); return; } // 收起当前节点
|
|
1419
|
+
const lv = Number(row.getAttribute("data-level")) || 1;
|
|
1420
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
1421
|
+
if ((Number(rows[j].getAttribute("data-level")) || 1) < lv) { e.preventDefault(); moveFocus(rows, j); break; }
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
// 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注;交互行为 treeitem(roving tabindex 见上)
|
|
1028
1426
|
function treerow(key, level, isOpen, leaf, label, onClick, active, extra) {
|
|
1427
|
+
if (onClick) liveKeys.add(key);
|
|
1029
1428
|
return React.createElement(
|
|
1030
1429
|
"div",
|
|
1031
1430
|
{
|
|
1032
1431
|
className: "dbt-treerow" + (active ? " active" : ""), key,
|
|
1033
1432
|
role: onClick ? "treeitem" : undefined,
|
|
1034
|
-
|
|
1035
|
-
|
|
1433
|
+
"aria-expanded": onClick && !leaf ? isOpen : undefined, // 可展开节点披露展开态(叶/占位行不适用)
|
|
1434
|
+
"aria-level": onClick ? level + 1 : undefined, // 平铺树以 level 标注层级(WAI-ARIA treeview)
|
|
1435
|
+
"aria-selected": onClick && active ? true : undefined, // 选中表行的可访问标注(对应 .active 视觉态)
|
|
1436
|
+
"data-level": onClick ? level + 1 : undefined, // 左箭头父级查找依赖
|
|
1437
|
+
"data-treekey": key,
|
|
1438
|
+
tabIndex: onClick ? ((roving || firstKey) === key ? 0 : -1) : undefined,
|
|
1439
|
+
onClick: onClick ? (e) => { setRovingKey(key); onClick(e); } : undefined,
|
|
1440
|
+
...(onClick ? focusRingProps() : null), // 焦点描边统一走模块级 helper
|
|
1036
1441
|
onKeyDown: onClick ? (e) => {
|
|
1037
|
-
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick({ currentTarget: e.currentTarget }); }
|
|
1442
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setRovingKey(key); onClick({ currentTarget: e.currentTarget }); }
|
|
1038
1443
|
} : undefined,
|
|
1039
1444
|
style: { paddingLeft: 12 + level * 16 },
|
|
1040
1445
|
},
|
|
@@ -1101,13 +1506,15 @@ window.__ModuleLoader__.load({
|
|
|
1101
1506
|
}
|
|
1102
1507
|
}
|
|
1103
1508
|
|
|
1509
|
+
// 停靠行失效检测以下一轮渲染的可见集合为准(commit 后写回,不污染渲染期)
|
|
1510
|
+
React.useLayoutEffect(() => { liveKeysRef.current = liveKeys; });
|
|
1104
1511
|
return React.createElement(
|
|
1105
1512
|
"div",
|
|
1106
1513
|
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
1107
1514
|
// Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件就地预览)
|
|
1108
1515
|
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
1109
1516
|
React.createElement("div", { className: "dbt-group" },
|
|
1110
|
-
React.createElement("div", { className: "dbt-tree" }, treeRows)),
|
|
1517
|
+
React.createElement("div", { className: "dbt-tree", role: "tree", ref: treeRef, onKeyDown: onTreeKeyDown }, treeRows)),
|
|
1111
1518
|
);
|
|
1112
1519
|
}
|
|
1113
1520
|
|
|
@@ -1116,7 +1523,8 @@ window.__ModuleLoader__.load({
|
|
|
1116
1523
|
/* ---------------- PreviewGrid:数据预览网格(Navicat 式单元格查看/编辑) ---------------- */
|
|
1117
1524
|
// props 契约:{ preview:{columns,rows,rowCount,truncated?}, schema:ColumnInfo[](SQL/mongo;redis 为
|
|
1118
1525
|
// type/encoding/ttl/length,无 PRI), kind, editable:rw 授权布尔, tableType:sel.table.type(redis 键类型),
|
|
1119
|
-
// tableName, dbRef, schemaName, connId, projectPath, onSaved:保存成功回调,
|
|
1526
|
+
// tableName, dbRef, schemaName, connId, connName, grantMode, projectPath, onSaved:保存成功回调,
|
|
1527
|
+
// askConfirm:runGuarded 确认回调(确认信息附带 target:{conn,kind,db,mode} 与 params) }
|
|
1120
1528
|
// redis 各键类型可就地编辑的数据列(与 buildRedisOp 分支保持一致;键名/字段名等定位列只读)
|
|
1121
1529
|
const REDIS_EDITABLE_COLS = {
|
|
1122
1530
|
string: ["value"],
|
|
@@ -1125,13 +1533,13 @@ window.__ModuleLoader__.load({
|
|
|
1125
1533
|
set: ["member"],
|
|
1126
1534
|
};
|
|
1127
1535
|
function PreviewGrid(props) {
|
|
1128
|
-
const { preview, schema, kind, editable, tableType, tableName, dbRef, schemaName, connId, projectPath, onSaved, askConfirm } = props;
|
|
1536
|
+
const { preview, schema, kind, editable, tableType, tableName, dbRef, schemaName, connId, connName, grantMode, projectPath, onSaved, askConfirm } = props;
|
|
1129
1537
|
const [sel, setSel] = React.useState(null); // {rowIdx, colName, cell} 底部详情栏当前单元格(null=收起底栏)
|
|
1130
1538
|
const [text, setText] = React.useState(""); // 详情栏 textarea 值(选中即就位)
|
|
1131
1539
|
const [editNull, setEditNull] = React.useState(false); // 「设为 NULL」勾选(null 单元格默认勾选)
|
|
1132
1540
|
const [cellErr, setCellErr] = React.useState("");
|
|
1133
1541
|
const [busy, setBusy] = React.useState(false);
|
|
1134
|
-
const [copied, setCopied] = React.useState(
|
|
1542
|
+
const [copied, setCopied] = React.useState(""); // "" | "ok" | "fail"(复制结果反馈,1.5s 后还原 "")
|
|
1135
1543
|
const isMongo = kind === "mongodb";
|
|
1136
1544
|
// SQL 系主键列名(schema 中 key==="PRI");mongo 由 _id 单列承担,redis 无主键概念
|
|
1137
1545
|
const pkCols = (!isMongo && kind !== "redis" && Array.isArray(schema))
|
|
@@ -1149,9 +1557,13 @@ window.__ModuleLoader__.load({
|
|
|
1149
1557
|
function copyCell() {
|
|
1150
1558
|
if (!sel) return;
|
|
1151
1559
|
navigator.clipboard.writeText(String(sel.cell)).then(() => {
|
|
1152
|
-
setCopied(
|
|
1153
|
-
window.setTimeout(() => setCopied(
|
|
1154
|
-
}, () => {
|
|
1560
|
+
setCopied("ok");
|
|
1561
|
+
window.setTimeout(() => setCopied(""), 1500);
|
|
1562
|
+
}, () => {
|
|
1563
|
+
// 剪贴板不可用(无权限等):短暂显示失败文案后还原,不再静默
|
|
1564
|
+
setCopied("fail");
|
|
1565
|
+
window.setTimeout(() => setCopied(""), 1500);
|
|
1566
|
+
});
|
|
1155
1567
|
}
|
|
1156
1568
|
|
|
1157
1569
|
// 单元格编辑资格:返回 null=可编辑,否则为原因 i18n key(底栏 .dbt-readhint 展示)
|
|
@@ -1193,9 +1605,13 @@ window.__ModuleLoader__.load({
|
|
|
1193
1605
|
setBusy(true);
|
|
1194
1606
|
try {
|
|
1195
1607
|
for (const op of r.ops) {
|
|
1608
|
+
// 确认信息附带目标数据(连接名/kind/库/授权模式)与实际参数(statement 中 ? 占位符的值)
|
|
1196
1609
|
await runGuarded(
|
|
1197
1610
|
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: op.statement, params: op.params, database: op.database, challengeId } }),
|
|
1198
|
-
askConfirm,
|
|
1611
|
+
(info) => askConfirm(Object.assign({}, info, {
|
|
1612
|
+
target: { conn: connName || connId, kind: kind || "", db: dbRef || "", mode: grantMode || "" },
|
|
1613
|
+
params: op.params,
|
|
1614
|
+
})),
|
|
1199
1615
|
);
|
|
1200
1616
|
}
|
|
1201
1617
|
setSel(null);
|
|
@@ -1218,8 +1634,8 @@ window.__ModuleLoader__.load({
|
|
|
1218
1634
|
return React.createElement(
|
|
1219
1635
|
"div",
|
|
1220
1636
|
{ className: "dbt-browse-root", style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0 } },
|
|
1221
|
-
// 顶层只读提示:授权为 ro
|
|
1222
|
-
!editable ? React.createElement("div", { className: "dbt-readhint" }, t("readOnlyRo")) : null,
|
|
1637
|
+
// 顶层只读提示:授权为 ro 时整格只读,单元格仍可点开底栏查看完整值;10px 分区 gap
|
|
1638
|
+
!editable ? React.createElement("div", { className: "dbt-readhint", style: { marginBottom: 10 } }, t("readOnlyRo")) : null,
|
|
1223
1639
|
// 预览表格区(flex:1 撑满剩余空间;底部详情栏在表格之后、面板流内)
|
|
1224
1640
|
React.createElement(
|
|
1225
1641
|
"div",
|
|
@@ -1239,7 +1655,16 @@ window.__ModuleLoader__.load({
|
|
|
1239
1655
|
"tbody",
|
|
1240
1656
|
null,
|
|
1241
1657
|
(preview.rows || []).map((row, i) =>
|
|
1242
|
-
|
|
1658
|
+
// 行级键盘可达:Tab 聚焦行、Enter 打开该行首格详情(与单击同走 toggleCell,同格再按收起);
|
|
1659
|
+
// 焦点在行内 button 上时 Enter 已由 button 处理,target!==currentTarget 直接放行防双重触发
|
|
1660
|
+
React.createElement("tr", {
|
|
1661
|
+
key: i, tabIndex: 0,
|
|
1662
|
+
onKeyDown: (e) => {
|
|
1663
|
+
if (e.key !== "Enter" || e.target !== e.currentTarget || !(preview.columns || []).length) return;
|
|
1664
|
+
toggleCell(i, preview.columns[0], row[0]);
|
|
1665
|
+
},
|
|
1666
|
+
...focusRingProps(true), // 行容器:忽略子元素聚焦冒泡,仅行自身键盘聚焦描边
|
|
1667
|
+
},
|
|
1243
1668
|
(preview.columns || []).map((colName, j) =>
|
|
1244
1669
|
React.createElement("td", { key: j, title: row[j] === null ? "NULL" : String(row[j]) },
|
|
1245
1670
|
React.createElement("button", {
|
|
@@ -1250,14 +1675,18 @@ window.__ModuleLoader__.load({
|
|
|
1250
1675
|
),
|
|
1251
1676
|
),
|
|
1252
1677
|
),
|
|
1678
|
+
// 分页条(BrowsePane 传入)在详情栏之上,详情栏钉在面板最底部;
|
|
1679
|
+
// 包裹层 flex:none 承接钉底语义并补 10px 分区 gap(footer 自身无 margin,与底栏 10px 节奏对齐)
|
|
1680
|
+
props.children ? React.createElement("div", { style: { flex: "none", marginTop: 10 } }, props.children) : null,
|
|
1253
1681
|
// 底部详情栏(Navicat/检查器式):预览表格下方一栏,textarea 直接可编辑,点保存写回
|
|
1254
1682
|
sel ? React.createElement(
|
|
1255
1683
|
"div",
|
|
1256
1684
|
{ className: "dbt-celldetail" },
|
|
1257
1685
|
// 标题行:完整值 · 列名;null 单元格带 NULL 芯片;只读原因随之展示
|
|
1258
1686
|
React.createElement(
|
|
1687
|
+
// 标题行 flex + 8px 控件 gap:值/列名、NULL 芯片、只读原因不再挤作一团;长列名 wrap 兜底
|
|
1259
1688
|
"div",
|
|
1260
|
-
|
|
1689
|
+
{ style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
|
|
1261
1690
|
React.createElement("strong", null, t("cellValue") + " · " + sel.colName),
|
|
1262
1691
|
sel.cell === null ? React.createElement("span", { className: "dbt-nullchip" }, "NULL") : null,
|
|
1263
1692
|
selReason ? React.createElement("span", { className: "dbt-readhint" }, t(selReason)) : null,
|
|
@@ -1281,7 +1710,11 @@ window.__ModuleLoader__.load({
|
|
|
1281
1710
|
React.createElement(
|
|
1282
1711
|
"div",
|
|
1283
1712
|
{ className: "dbt-row" },
|
|
1284
|
-
|
|
1713
|
+
// 复制反馈三态附语义色(DESIGN.md:绿=成功、红=错误),静态内联色值走 --dbt-* token + 回退
|
|
1714
|
+
React.createElement("button", {
|
|
1715
|
+
className: "dbt-btn", onClick: copyCell,
|
|
1716
|
+
style: copied === "ok" ? { color: "var(--dbt-success,#30d158)" } : copied === "fail" ? { color: "var(--dbt-danger,#ff453a)" } : undefined,
|
|
1717
|
+
}, copied === "ok" ? t("copied") : copied === "fail" ? t("copyFailed") : t("copyBtn")),
|
|
1285
1718
|
selReason === null ? React.createElement("button", { className: "dbt-btn primary", disabled: busy, onClick: saveCell }, busy ? t("cellSaving") : t("save")) : null,
|
|
1286
1719
|
React.createElement("button", { className: "dbt-btn", onClick: () => setSel(null) }, t("close")),
|
|
1287
1720
|
),
|
|
@@ -1291,7 +1724,8 @@ window.__ModuleLoader__.load({
|
|
|
1291
1724
|
|
|
1292
1725
|
// Navicat 式浏览面板(内嵌侧边栏):上半对象树选库-表,下半结构/数据预览,点表即看,不再弹窗
|
|
1293
1726
|
function BrowsePane(props) {
|
|
1294
|
-
const { conns, projectPath, grants, askConfirm } = props;
|
|
1727
|
+
const { conns, projectPath, grants, askConfirm, active } = props;
|
|
1728
|
+
// active:所属 tab 是否激活(Panel 常驻挂载后传入);首次激活才展开树并拉取,隐藏不发请求
|
|
1295
1729
|
// grants(editable 判定)与 askConfirm(单元格写回确认)下传 PreviewGrid
|
|
1296
1730
|
const [sel, setSel] = React.useState(null); // 面板内当前选中(null=未选表,内容区显示引导空态)
|
|
1297
1731
|
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
@@ -1326,8 +1760,8 @@ window.__ModuleLoader__.load({
|
|
|
1326
1760
|
// 内容区互斥状态:未选中引导 / 加载骨架 / 错误重试 / 空态 / 表格
|
|
1327
1761
|
function rightPane() {
|
|
1328
1762
|
if (!sel && busy !== "open" && !loadErr) {
|
|
1329
|
-
//
|
|
1330
|
-
return React.createElement("div", { className: "dbt-browse-empty" }, t("selectTableHint"));
|
|
1763
|
+
// 未选表:显示引导空态(内嵌面板可直接浏览,不强制先选表);flex:1 撑满内容区使类内居中生效
|
|
1764
|
+
return React.createElement("div", { className: "dbt-browse-empty", style: { flex: 1 } }, t("selectTableHint"));
|
|
1331
1765
|
}
|
|
1332
1766
|
if (busy === "open") {
|
|
1333
1767
|
// 表形状骨架:6 行 6 列静态色块(不引入新动画,reduced-motion 天然安全)
|
|
@@ -1336,24 +1770,34 @@ window.__ModuleLoader__.load({
|
|
|
1336
1770
|
[0, 1, 2, 3, 4, 5].map((c) => React.createElement("div", { className: "dbt-browse-skelcell", key: c })))));
|
|
1337
1771
|
}
|
|
1338
1772
|
if (loadErr) {
|
|
1339
|
-
// 错误就地显示 + 重试(内嵌面板保持就地呈现,不走 props.onError)
|
|
1340
|
-
return React.createElement("div", { className: "dbt-browse-errorbox" },
|
|
1773
|
+
// 错误就地显示 + 重试(内嵌面板保持就地呈现,不走 props.onError);role=alert 使异步失败被读屏播报(对齐 Panel 顶层 aria-live)
|
|
1774
|
+
return React.createElement("div", { className: "dbt-browse-errorbox", role: "alert" },
|
|
1341
1775
|
React.createElement("div", { className: "dbt-err" }, t("error") + ": " + loadErr),
|
|
1342
1776
|
React.createElement("button", { className: "dbt-btn", onClick: () => openTable(sel, 1) }, t("retry")));
|
|
1343
1777
|
}
|
|
1344
1778
|
if (view === "structure") {
|
|
1345
|
-
if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyStructure"));
|
|
1779
|
+
if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty", style: { flex: 1 } }, t("emptyStructure"));
|
|
1346
1780
|
return resultTable(
|
|
1347
1781
|
[t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
|
|
1348
1782
|
schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
|
|
1349
1783
|
undefined, "dbt-browse-tablewrap");
|
|
1350
1784
|
}
|
|
1351
1785
|
if (!preview || !preview.rows || preview.rows.length === 0) {
|
|
1352
|
-
return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData"));
|
|
1786
|
+
return React.createElement("div", { className: "dbt-browse-empty", style: { flex: 1 } }, t("emptyData"));
|
|
1353
1787
|
}
|
|
1354
1788
|
// Navicat 式单元格网格:kind/可编辑性由当前连接与授权决定(rw=可编辑),保存后重拉当前页
|
|
1355
1789
|
const conn = conns.find((c) => c.id === sel.connId);
|
|
1356
1790
|
const grant = (grants || []).find((g) => g.connId === sel.connId);
|
|
1791
|
+
// 分页条作为 children 传入:渲染顺序 表格 → 分页 → 详情栏(详情栏钉在面板最底部)
|
|
1792
|
+
const footer = sel && view === "preview" && !loadErr ? React.createElement(
|
|
1793
|
+
"div",
|
|
1794
|
+
{ className: "dbt-browse-footer" },
|
|
1795
|
+
React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
|
|
1796
|
+
React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
|
|
1797
|
+
React.createElement("span", { style: { flex: 1 } }),
|
|
1798
|
+
preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
|
|
1799
|
+
React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
|
|
1800
|
+
) : null;
|
|
1357
1801
|
return React.createElement(PreviewGrid, {
|
|
1358
1802
|
preview, schema,
|
|
1359
1803
|
kind: conn ? conn.kind : undefined,
|
|
@@ -1363,20 +1807,27 @@ window.__ModuleLoader__.load({
|
|
|
1363
1807
|
dbRef: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
|
|
1364
1808
|
schemaName: sel.schemaName || null,
|
|
1365
1809
|
connId: sel.connId, projectPath,
|
|
1810
|
+
connName: conn ? (conn.name || sel.connId) : sel.connId,
|
|
1811
|
+
grantMode: grant ? grant.mode : "",
|
|
1366
1812
|
onSaved: () => openTable(sel, page),
|
|
1367
1813
|
askConfirm,
|
|
1368
|
-
});
|
|
1814
|
+
}, footer);
|
|
1369
1815
|
}
|
|
1370
1816
|
|
|
1371
|
-
//
|
|
1817
|
+
// 内嵌面板纵向布局:撑满面板高度(.dbt-panel 为 height:100% 的 flex column),
|
|
1818
|
+
// 树限高 / main flex:1 内滚 / 分页与详情栏依次钉底。
|
|
1819
|
+
// 根不再挂 .dbt-view:该类自带 dbt-in 入场动画,会与 Panel browse 容器同类叠加双播(4px+4px 位移),
|
|
1820
|
+
// 与 Panel 层「入场动画只在面板首次打开播一次」意图相悖;布局职责(flex column + 10px 分区 gap)内联承担
|
|
1372
1821
|
return React.createElement(
|
|
1373
1822
|
"div",
|
|
1374
|
-
{
|
|
1823
|
+
{ style: { display: "flex", flexDirection: "column", gap: 10, flex: "1 1 auto", minHeight: 0 } },
|
|
1375
1824
|
React.createElement(
|
|
1376
1825
|
"div",
|
|
1377
|
-
|
|
1826
|
+
// flex:none:树卡固定限高不被压缩(面板过矮时收缩的是主区),对齐 DESIGN.md 分区规格
|
|
1827
|
+
{ style: { flex: "none", maxHeight: 280, overflowY: "auto" } },
|
|
1378
1828
|
React.createElement(BrowseTree, {
|
|
1379
|
-
conns, projectPath, sel,
|
|
1829
|
+
conns, projectPath, sel,
|
|
1830
|
+
autoExpand: !!active, // 首次激活才自动展开连接层级(常驻挂载下隐藏不发请求)
|
|
1380
1831
|
onSelect: (s) => setSel(s), // 树内切表
|
|
1381
1832
|
}),
|
|
1382
1833
|
),
|
|
@@ -1389,138 +1840,613 @@ window.__ModuleLoader__.load({
|
|
|
1389
1840
|
{ className: "dbt-browse-tabs" },
|
|
1390
1841
|
React.createElement(
|
|
1391
1842
|
"div",
|
|
1392
|
-
|
|
1843
|
+
// role 模式对齐 Panel 全局 tabs(tablist/tab/aria-selected),seg 交互语义一致
|
|
1844
|
+
{ className: "dbt-seg", role: "tablist" },
|
|
1393
1845
|
["structure", "preview"].map((v) =>
|
|
1394
|
-
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
|
|
1846
|
+
React.createElement("button", { key: v, className: view === v ? "active" : "", role: "tab", "aria-selected": view === v, onClick: () => setView(v) },
|
|
1395
1847
|
v === "structure" ? t("structure") : t("preview"))),
|
|
1396
1848
|
),
|
|
1397
1849
|
),
|
|
1398
|
-
//
|
|
1399
|
-
React.createElement("div", { className: "dbt-browse-content"
|
|
1850
|
+
// 内容区由 flex 吃满 main 剩余高度(表格外滚),不再用固定 maxHeight 兜底
|
|
1851
|
+
React.createElement("div", { className: "dbt-browse-content" }, rightPane()),
|
|
1400
1852
|
),
|
|
1401
|
-
|
|
1402
|
-
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/* ---------------- 结果网格(SQL 控制台查询结果:客户端分页/排序/复制/导出) ---------------- */
|
|
1857
|
+
// 单元格 → 数值:number 直接用;非空数字字符串可转;NaN/Infinity/其余 → null(走字符串比较)
|
|
1858
|
+
function gridNum(v) {
|
|
1859
|
+
if (typeof v === "number") return isFinite(v) ? v : null;
|
|
1860
|
+
if (typeof v === "string" && v.trim() !== "") { const n = Number(v); if (isFinite(n)) return n; }
|
|
1861
|
+
return null;
|
|
1862
|
+
}
|
|
1863
|
+
// 排序:dir="none" 或 colIndex<0 原样返回;null/undefined 恒最大(SQL NULL 语义:asc 尾部/desc 头部);
|
|
1864
|
+
// 两值均可转有限数字时按数值比较(防 "9">"10" 字典序),否则字符串比较;相等按原序(稳定)
|
|
1865
|
+
function sortRows(rows, colIndex, dir) {
|
|
1866
|
+
if (!Array.isArray(rows) || (dir !== "asc" && dir !== "desc")) return rows;
|
|
1867
|
+
if (!Number.isInteger(colIndex) || colIndex < 0) return rows;
|
|
1868
|
+
const pairs = rows.map((row, i) => ({ row, i }));
|
|
1869
|
+
pairs.sort((a, b) => {
|
|
1870
|
+
const va = Array.isArray(a.row) ? a.row[colIndex] : undefined;
|
|
1871
|
+
const vb = Array.isArray(b.row) ? b.row[colIndex] : undefined;
|
|
1872
|
+
let c;
|
|
1873
|
+
const na = va === null || va === undefined, nb = vb === null || vb === undefined;
|
|
1874
|
+
if (na || nb) c = na && nb ? 0 : na ? 1 : -1;
|
|
1875
|
+
else {
|
|
1876
|
+
const fa = gridNum(va), fb = gridNum(vb);
|
|
1877
|
+
if (fa !== null && fb !== null) c = fa === fb ? 0 : fa < fb ? -1 : 1;
|
|
1878
|
+
else { const sa = String(va), sb = String(vb); c = sa < sb ? -1 : sa > sb ? 1 : 0; }
|
|
1879
|
+
}
|
|
1880
|
+
return c !== 0 ? (dir === "desc" ? -c : c) : a.i - b.i;
|
|
1881
|
+
});
|
|
1882
|
+
return pairs.map((p) => p.row);
|
|
1883
|
+
}
|
|
1884
|
+
// 分页切片:page 从 1 起;page≤0/NaN 返回空页(不触发 slice 负索引从尾部计数)
|
|
1885
|
+
function slicePage(rows, page, pageSize) {
|
|
1886
|
+
if (!Array.isArray(rows) || !(page >= 1)) return [];
|
|
1887
|
+
const size = pageSize || PAGE_SIZE;
|
|
1888
|
+
const start = (page - 1) * size;
|
|
1889
|
+
return rows.slice(start, start + size);
|
|
1890
|
+
}
|
|
1891
|
+
// 结果网格组件:exportHandlers { toCsv, toJson, download } 由集成层注入,缺省隐藏导出按钮
|
|
1892
|
+
function ResultSetGrid(props) {
|
|
1893
|
+
const { columns, rows, truncated, elapsedMs, exportHandlers } = props;
|
|
1894
|
+
const [sortCol, setSortCol] = React.useState(-1);
|
|
1895
|
+
const [dir, setDir] = React.useState("none");
|
|
1896
|
+
const [page, setPage] = React.useState(1);
|
|
1897
|
+
const [copyState, setCopyState] = React.useState(""); // ""|"ok"|"fail"(复制反馈 1.5s 还原,对齐 PreviewGrid)
|
|
1898
|
+
const allRows = rows || [];
|
|
1899
|
+
const sorted = sortRows(allRows, sortCol, dir);
|
|
1900
|
+
const pageCount = Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));
|
|
1901
|
+
const curPage = Math.min(page, pageCount); // 数据重查变短时钳制显示页
|
|
1902
|
+
const pageRows = slicePage(sorted, curPage);
|
|
1903
|
+
// 表头三态轮换:none→asc→desc→none;换列直接 asc;重排后回第一页
|
|
1904
|
+
function toggleSort(i) {
|
|
1905
|
+
setPage(1);
|
|
1906
|
+
if (sortCol !== i) { setSortCol(i); setDir("asc"); return; }
|
|
1907
|
+
setDir(dir === "asc" ? "desc" : dir === "desc" ? "none" : "asc");
|
|
1908
|
+
}
|
|
1909
|
+
// 单元格点击复制:null 复制空串;反馈样式对齐 PreviewGrid(成功/失败各 1.5s 后还原)
|
|
1910
|
+
function copyCell(cell) {
|
|
1911
|
+
navigator.clipboard.writeText(cell === null ? "" : String(cell)).then(() => {
|
|
1912
|
+
setCopyState("ok");
|
|
1913
|
+
window.setTimeout(() => setCopyState(""), 1500);
|
|
1914
|
+
}, () => {
|
|
1915
|
+
setCopyState("fail");
|
|
1916
|
+
window.setTimeout(() => setCopyState(""), 1500);
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
function exportAs(ext, pack) {
|
|
1920
|
+
if (!exportHandlers) return;
|
|
1921
|
+
exportHandlers.download("result." + ext, pack(columns, sorted));
|
|
1922
|
+
}
|
|
1923
|
+
const arrow = dir === "asc" ? " ↑" : dir === "desc" ? " ↓" : "";
|
|
1924
|
+
return React.createElement(
|
|
1925
|
+
"div",
|
|
1926
|
+
{ className: "dbt-col" },
|
|
1927
|
+
allRows.length === 0 ? React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData")) : React.createElement(
|
|
1928
|
+
"div",
|
|
1929
|
+
{ className: "dbt-tablewrap" },
|
|
1930
|
+
React.createElement(
|
|
1931
|
+
"table",
|
|
1932
|
+
{ className: "dbt-table" },
|
|
1933
|
+
React.createElement("thead", null,
|
|
1934
|
+
React.createElement("tr", null, (columns || []).map((c, i) =>
|
|
1935
|
+
React.createElement("th", { key: i },
|
|
1936
|
+
React.createElement("button", { className: "dbt-grid-sort", onClick: () => toggleSort(i), title: String(c) },
|
|
1937
|
+
String(c) + (sortCol === i ? arrow : "")))))),
|
|
1938
|
+
React.createElement("tbody", null, pageRows.map((row, ri) =>
|
|
1939
|
+
React.createElement("tr", { key: ri }, (row || []).map((cell, ci) =>
|
|
1940
|
+
React.createElement("td", { key: ci },
|
|
1941
|
+
React.createElement("button", {
|
|
1942
|
+
className: "dbt-cellbtn",
|
|
1943
|
+
title: cell === null ? "NULL" : String(cell),
|
|
1944
|
+
onClick: () => copyCell(cell),
|
|
1945
|
+
}, cell === null ? React.createElement("span", { className: "dbt-muted" }, "NULL") : String(cell))))))),
|
|
1946
|
+
),
|
|
1947
|
+
),
|
|
1948
|
+
// 状态条 + 导出 + 分页条(复用 BrowsePane footer 模式)
|
|
1949
|
+
React.createElement(
|
|
1403
1950
|
"div",
|
|
1404
1951
|
{ className: "dbt-browse-footer" },
|
|
1405
|
-
React.createElement("
|
|
1406
|
-
|
|
1952
|
+
React.createElement("span", { className: "dbt-muted" },
|
|
1953
|
+
t("rowsResult", { n: allRows.length })
|
|
1954
|
+
+ (truncated ? " · " + t("previewTruncated") : "")
|
|
1955
|
+
+ (typeof elapsedMs === "number" && isFinite(elapsedMs) ? " · " + fmtMs(elapsedMs) : "")),
|
|
1956
|
+
copyState ? React.createElement("span", {
|
|
1957
|
+
className: "dbt-muted",
|
|
1958
|
+
style: { color: copyState === "ok" ? "var(--dbt-success,#30d158)" : "var(--dbt-danger,#ff453a)" },
|
|
1959
|
+
}, copyState === "ok" ? t("copied") : t("copyFailed")) : null,
|
|
1407
1960
|
React.createElement("span", { style: { flex: 1 } }),
|
|
1408
|
-
|
|
1409
|
-
React.createElement("button", { className: "dbt-btn",
|
|
1410
|
-
|
|
1961
|
+
exportHandlers ? React.createElement("button", { className: "dbt-btn", onClick: () => exportAs("csv", exportHandlers.toCsv) }, "CSV") : null,
|
|
1962
|
+
exportHandlers ? React.createElement("button", { className: "dbt-btn", onClick: () => exportAs("json", exportHandlers.toJson) }, "JSON") : null,
|
|
1963
|
+
React.createElement("button", { className: "dbt-btn", disabled: curPage <= 1, onClick: () => setPage(curPage - 1) }, "‹ " + t("prevPage")),
|
|
1964
|
+
React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page: curPage })),
|
|
1965
|
+
React.createElement("button", { className: "dbt-btn", disabled: curPage >= pageCount, onClick: () => setPage(curPage + 1) }, t("nextPage") + " ›"),
|
|
1966
|
+
),
|
|
1411
1967
|
);
|
|
1412
1968
|
}
|
|
1413
1969
|
|
|
1414
|
-
/* ---------------- SQL
|
|
1970
|
+
/* ---------------- SQL 控制台(多标签 + CodeMirror + 逐条预分类 + 事务会话) ---------------- */
|
|
1415
1971
|
// Apple 等宽字体栈(规范 §1):优先样式层 --dbt-mono token,fallback 内联栈
|
|
1416
1972
|
const MONO_FONT = "var(--dbt-mono, ui-monospace, \"SF Mono\", Menlo, Consolas, monospace)";
|
|
1973
|
+
|
|
1974
|
+
// 逐条预分类(纯函数,挂 __testables):SQL 读首词为契约清单(SELECT/WITH/SHOW/
|
|
1975
|
+
// EXPLAIN/DESC/DESCRIBE → /api/query,其余 → /api/execute);redis/mongo 语句为 JSON
|
|
1976
|
+
// 命令形态(数组首元素 / 对象唯一键),比对客户端读命令表——完全镜像服务端
|
|
1977
|
+
// READ_COMMANDS(lib/adapters/redis/index.ts:34)与 READ_OPS(lib/adapters/mongodb/
|
|
1978
|
+
// index.ts:76)单一事实来源,避免客户端放行服务端拒绝(或反之)的读命令。
|
|
1979
|
+
const CONSOLE_SQL_READ_HEADS = new Set(["SELECT", "WITH", "SHOW", "EXPLAIN", "DESC", "DESCRIBE"]);
|
|
1980
|
+
const CONSOLE_REDIS_READ = new Set([
|
|
1981
|
+
"GET", "MGET", "EXISTS", "TYPE", "HGET", "HGETALL", "HKEYS", "HVALS", "HLEN", "LRANGE",
|
|
1982
|
+
"LLEN", "SMEMBERS", "SCARD", "SISMEMBER", "ZRANGE", "ZSCORE", "ZCARD", "ZRANK", "XLEN",
|
|
1983
|
+
"XRANGE", "SCAN", "DBSIZE", "INFO", "SELECT", "TTL", "PTTL", "GETRANGE", "STRLEN", "OBJECT", "MEMORY",
|
|
1984
|
+
]);
|
|
1985
|
+
const CONSOLE_MONGO_READ = new Set([
|
|
1986
|
+
"find", "aggregate", "count", "countDocuments", "estimatedDocumentCount",
|
|
1987
|
+
"distinct", "listCollections", "dbStats", "collStats", "indexes",
|
|
1988
|
+
]);
|
|
1989
|
+
function classifyHead(kind, statement) {
|
|
1990
|
+
const s = String(statement == null ? "" : statement).trim();
|
|
1991
|
+
if (kind === "redis" || kind === "mongodb") {
|
|
1992
|
+
let head = null;
|
|
1993
|
+
try {
|
|
1994
|
+
const v = JSON.parse(s);
|
|
1995
|
+
if (Array.isArray(v)) head = v[0];
|
|
1996
|
+
else if (v && typeof v === "object") head = Object.keys(v)[0];
|
|
1997
|
+
} catch (e) { /* 非 JSON 命令落回首词分类 */ }
|
|
1998
|
+
if (typeof head === "string") {
|
|
1999
|
+
return (kind === "redis" ? CONSOLE_REDIS_READ : CONSOLE_MONGO_READ).has(head) ? "query" : "execute";
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
const head = (s.match(/^[A-Za-z]+/) || [""])[0].toUpperCase();
|
|
2003
|
+
return CONSOLE_SQL_READ_HEADS.has(head) ? "query" : "execute";
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// vendor bundle 加载(单例 promise):宿主 require 相对文件路径(Node/e2e 语境,与
|
|
2007
|
+
// vendor/mongodb-driver.cjs 同款通道)。require 失败(web 宿主 require 无文件模块)即
|
|
2008
|
+
// reject,调用方降级 textarea(失败后置空单例允许重试,如宿主后续就绪)。
|
|
2009
|
+
// 不做同源 fetch bundle + Function 动态执行:生产无 /vendor 静态路由(lib/http 仅 JSON
|
|
2010
|
+
// API),该分支必死;且 Function 构造器等价 eval,fetched 文本响应投毒即代码注入。
|
|
2011
|
+
let cmModulePromise = null;
|
|
2012
|
+
function loadConsoleEditorModule() {
|
|
2013
|
+
if (cmModulePromise) return cmModulePromise;
|
|
2014
|
+
cmModulePromise = (async () => {
|
|
2015
|
+
const m = require("../vendor/codemirror-sql.cjs");
|
|
2016
|
+
if (!(m && typeof m.createConsoleEditor === "function")) throw new Error("bundle 缺 createConsoleEditor");
|
|
2017
|
+
return m;
|
|
2018
|
+
})();
|
|
2019
|
+
cmModulePromise.catch(() => { cmModulePromise = null; });
|
|
2020
|
+
return cmModulePromise;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// 结果导出 download:Blob + a.download(点击后延时 revoke,防过早回收);渲染期只建闭包不触 DOM
|
|
2024
|
+
function makeExportHandlers() {
|
|
2025
|
+
return {
|
|
2026
|
+
toCsv, toJson,
|
|
2027
|
+
download(name, text) {
|
|
2028
|
+
const blob = new Blob([String(text == null ? "" : text)], { type: "text/plain;charset=utf-8" });
|
|
2029
|
+
const url = URL.createObjectURL(blob);
|
|
2030
|
+
const a = document.createElement("a");
|
|
2031
|
+
a.href = url;
|
|
2032
|
+
a.download = name || "result.txt";
|
|
2033
|
+
document.body.appendChild(a);
|
|
2034
|
+
a.click();
|
|
2035
|
+
a.remove();
|
|
2036
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
2037
|
+
},
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const CONSOLE_SQL_KINDS = { mysql: true, postgresql: true, gaussdb: true, sqlite: true, oracle: true, dmdb: true };
|
|
2042
|
+
|
|
1417
2043
|
function ConsoleView(props) {
|
|
1418
|
-
const { conns, projectPath, askConfirm } = props;
|
|
2044
|
+
const { conns, grants, projectPath, askConfirm, onError } = props;
|
|
1419
2045
|
const [connId, setConnId] = React.useState("");
|
|
1420
|
-
const [mode, setMode] = React.useState("
|
|
1421
|
-
const [sql, setSql] = React.useState("");
|
|
2046
|
+
const [mode, setMode] = React.useState("sql"); // sql(逐条预分类)| script(整段 /api/script)
|
|
1422
2047
|
const [params, setParams] = React.useState("");
|
|
1423
|
-
const [result, setResult] = React.useState(null); // {kind:'query',...}|{kind:'exec',...}
|
|
1424
2048
|
const [busy, setBusy] = React.useState(false);
|
|
1425
2049
|
// Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
|
|
1426
2050
|
const [dbList, setDbList] = React.useState([]);
|
|
1427
2051
|
const [db, setDb] = React.useState("");
|
|
2052
|
+
// 多标签(内存态,不落盘):tabs[{id,title,sql,result,history}]
|
|
2053
|
+
const tabSeq = React.useRef(1);
|
|
2054
|
+
const [tabs, setTabs] = React.useState(() => [{ id: "t1", title: t("queryTab", { n: 1 }), sql: "", result: null, history: [] }]);
|
|
2055
|
+
const [activeIdx, setActiveIdx] = React.useState(0);
|
|
2056
|
+
// CodeMirror 模块与事务会话状态
|
|
2057
|
+
const [cm, setCm] = React.useState(null); // null(加载中)| module | "failed"(降级 textarea)
|
|
2058
|
+
const [txToken, setTxToken] = React.useState(null);
|
|
2059
|
+
const [txBusy, setTxBusy] = React.useState(false);
|
|
2060
|
+
const [historyOpen, setHistoryOpen] = React.useState(false);
|
|
2061
|
+
|
|
2062
|
+
const editorHostRef = React.useRef(null);
|
|
2063
|
+
const editorRef = React.useRef(null); // { ed, kind }
|
|
2064
|
+
const schemaRef = React.useRef({}); // 最近一次拉取的 SQLNamespace(表 → 列名数组)
|
|
2065
|
+
const activeIdxRef = React.useRef(0);
|
|
2066
|
+
React.useLayoutEffect(() => { activeIdxRef.current = activeIdx; }); // commit 后写回,不污染渲染期
|
|
2067
|
+
const exportRef = React.useRef(null);
|
|
2068
|
+
if (!exportRef.current) exportRef.current = makeExportHandlers();
|
|
2069
|
+
|
|
2070
|
+
const conn = conns.find((c) => c.id === connId);
|
|
2071
|
+
const kind = conn ? conn.kind : "";
|
|
2072
|
+
const grant = (grants || []).find((g) => g.connId === connId);
|
|
2073
|
+
const rw = !!grant && grant.mode === "rw";
|
|
2074
|
+
const activeTab = tabs[activeIdx] || tabs[0];
|
|
2075
|
+
const activeTabId = activeTab ? activeTab.id : "";
|
|
2076
|
+
|
|
2077
|
+
// 跨库下拉(保留既有行为:换连接重置)
|
|
1428
2078
|
React.useEffect(() => {
|
|
1429
2079
|
setDbList([]); setDb("");
|
|
1430
2080
|
if (!connId || !projectPath) return;
|
|
1431
2081
|
api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
|
|
1432
2082
|
}, [connId, projectPath]);
|
|
1433
2083
|
|
|
1434
|
-
|
|
2084
|
+
// vendor bundle 异步加载(失败降级 textarea,静默)
|
|
2085
|
+
React.useEffect(() => {
|
|
2086
|
+
let dead = false;
|
|
2087
|
+
loadConsoleEditorModule().then(
|
|
2088
|
+
(m) => { if (!dead) setCm(m); },
|
|
2089
|
+
() => { if (!dead) setCm("failed"); },
|
|
2090
|
+
);
|
|
2091
|
+
return () => { dead = true; };
|
|
2092
|
+
}, []);
|
|
2093
|
+
|
|
2094
|
+
// 编辑器挂载(CM6 生命周期:cleanup 调 destroy);kind 变化(换连接)整体重建以切方言
|
|
2095
|
+
React.useEffect(() => {
|
|
2096
|
+
if (!cm || cm === "failed" || !editorHostRef.current) return undefined;
|
|
2097
|
+
let ed = null;
|
|
2098
|
+
try {
|
|
2099
|
+
ed = cm.createConsoleEditor(editorHostRef.current, {
|
|
2100
|
+
kind,
|
|
2101
|
+
onRunAll: () => runRef.current("all"),
|
|
2102
|
+
onRunSelection: () => runRef.current("selection"),
|
|
2103
|
+
onChange: (text) => patchActive({ sql: text }),
|
|
2104
|
+
});
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
setCm("failed");
|
|
2107
|
+
return undefined;
|
|
2108
|
+
}
|
|
2109
|
+
editorRef.current = { ed, kind };
|
|
2110
|
+
ed.setDoc((tabs[activeIdxRef.current] && tabs[activeIdxRef.current].sql) || "");
|
|
2111
|
+
if (Object.keys(schemaRef.current).length) ed.setSchema(schemaRef.current);
|
|
2112
|
+
return () => {
|
|
2113
|
+
ed.destroy();
|
|
2114
|
+
editorRef.current = null;
|
|
2115
|
+
};
|
|
2116
|
+
}, [cm, kind]);
|
|
2117
|
+
|
|
2118
|
+
// 切换标签:编辑器文档同步到目标标签(不重建,保 undo 历史)
|
|
2119
|
+
React.useEffect(() => {
|
|
2120
|
+
const rec = editorRef.current;
|
|
2121
|
+
if (rec && rec.ed) rec.ed.setDoc((tabs[activeIdx] && tabs[activeIdx].sql) || "");
|
|
2122
|
+
}, [activeTabId]);
|
|
2123
|
+
|
|
2124
|
+
// 表/列元数据异步拉取(tables + 逐表 schema → SQLNamespace)供编辑器 setSchema;
|
|
2125
|
+
// 失败静默降级(无补全但不影响编辑),单表失败跳过该表
|
|
2126
|
+
React.useEffect(() => {
|
|
2127
|
+
let dead = false;
|
|
2128
|
+
schemaRef.current = {};
|
|
2129
|
+
const rec = editorRef.current;
|
|
2130
|
+
if (rec && rec.ed) rec.ed.setSchema({});
|
|
2131
|
+
if (!connId || !projectPath || !CONSOLE_SQL_KINDS[kind]) return undefined;
|
|
2132
|
+
const dbRef = db || undefined;
|
|
2133
|
+
api("tables" + qs({ project: projectPath, connId, database: dbRef }))
|
|
2134
|
+
.then(async (list) => {
|
|
2135
|
+
const names = (Array.isArray(list) ? list : []).map((x) => x && x.name).filter(Boolean).slice(0, 50);
|
|
2136
|
+
const entries = await Promise.all(names.map((name) =>
|
|
2137
|
+
api("schema" + qs({ project: projectPath, connId, table: name, database: dbRef }))
|
|
2138
|
+
.then((cols) => [name, (Array.isArray(cols) ? cols : []).map((c) => c && c.name).filter(Boolean)])
|
|
2139
|
+
.catch(() => [name, []])));
|
|
2140
|
+
if (dead) return undefined;
|
|
2141
|
+
const ns = {};
|
|
2142
|
+
for (const [name, cols] of entries) ns[name] = cols;
|
|
2143
|
+
schemaRef.current = ns;
|
|
2144
|
+
const rec2 = editorRef.current;
|
|
2145
|
+
if (rec2 && rec2.ed) rec2.ed.setSchema(ns);
|
|
2146
|
+
return undefined;
|
|
2147
|
+
})
|
|
2148
|
+
.catch(() => { /* 静默降级 */ });
|
|
2149
|
+
return () => { dead = true; };
|
|
2150
|
+
}, [connId, db, projectPath, kind]);
|
|
2151
|
+
|
|
2152
|
+
function patchActive(patch) {
|
|
2153
|
+
setTabs((prev) => prev.map((tb, i) => (i === activeIdxRef.current ? Object.assign({}, tb, patch) : tb)));
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// 确认对话框目标信息:连接名 / kind / 当前库(空=默认库)/ 该连接的授权模式(无授权留空)
|
|
2157
|
+
function confirmTarget() {
|
|
2158
|
+
return { conn: conn ? (conn.name || connId) : connId, kind, db: db || "", mode: grant ? grant.mode : "" };
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// 执行入口(scope:"all" 全量 | "selection" 选中,无选区回退全量)。逐条分割后按
|
|
2162
|
+
// classifyHead 预分类路由;事务会话存在时全部语句走 /api/console/exec(粘性连接,
|
|
2163
|
+
// 能看到未提交数据);ro 授权下写类语句跳过并标注。结果逐条就地渲染,错误带
|
|
2164
|
+
// start 偏移可定位光标。
|
|
2165
|
+
async function runBatch(scope) {
|
|
2166
|
+
if (busy || !connId) return;
|
|
2167
|
+
const rec = editorRef.current;
|
|
2168
|
+
let text = rec && rec.ed ? rec.ed.getDoc() : (activeTab ? activeTab.sql : "");
|
|
2169
|
+
if (scope === "selection") {
|
|
2170
|
+
const sel = rec && rec.ed ? rec.ed.getSelection() : textareaSelection();
|
|
2171
|
+
if (String(sel || "").trim()) text = sel;
|
|
2172
|
+
}
|
|
2173
|
+
if (!String(text || "").trim()) return;
|
|
2174
|
+
const tIdx = activeIdxRef.current; // 执行期间切标签不串写结果:锁定目标标签
|
|
2175
|
+
const targetTab = tabs[tIdx];
|
|
2176
|
+
let parsedParams;
|
|
2177
|
+
if (params.trim()) {
|
|
2178
|
+
try { parsedParams = JSON.parse(params); } catch (e) { onError(new Error(t("paramsJson") + ": " + e.message)); return; }
|
|
2179
|
+
}
|
|
1435
2180
|
setBusy(true);
|
|
1436
|
-
|
|
2181
|
+
const started = Date.now();
|
|
2182
|
+
const items = [];
|
|
1437
2183
|
try {
|
|
1438
2184
|
if (mode === "script") {
|
|
1439
|
-
const
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
(
|
|
1449
|
-
|
|
1450
|
-
);
|
|
1451
|
-
setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
2185
|
+
const st = Date.now();
|
|
2186
|
+
try {
|
|
2187
|
+
const data = await runGuarded(
|
|
2188
|
+
(challengeId) => api("script", { method: "POST", body: { projectPath, connId, code: text, challengeId } }),
|
|
2189
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget() })),
|
|
2190
|
+
);
|
|
2191
|
+
items.push({ text, start: 0, route: "script", status: "ok", elapsedMs: Date.now() - st, message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
2192
|
+
} catch (e) {
|
|
2193
|
+
if (!e.cancelled) onError(e);
|
|
2194
|
+
items.push({ text, start: 0, route: "script", status: e.cancelled ? "cancelled" : "error", error: String(e && e.message ? e.message : e), elapsedMs: Date.now() - st });
|
|
2195
|
+
}
|
|
1452
2196
|
} else {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2197
|
+
const stmts = CONSOLE_SQL_KINDS[kind] ? splitSqlStatements(text, kind) : [{ text: String(text).trim(), start: 0 }];
|
|
2198
|
+
for (const s of stmts) {
|
|
2199
|
+
if (!String(s.text).trim()) continue;
|
|
2200
|
+
const route = classifyHead(kind, s.text);
|
|
2201
|
+
// ro 授权:写类语句(execute 路由)跳过并标注;事务会话仅 rw 可开,无需复查
|
|
2202
|
+
if (!rw && !txToken && route === "execute") {
|
|
2203
|
+
items.push({ text: s.text, start: s.start, route, status: "skipped" });
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
const st = Date.now();
|
|
2207
|
+
try {
|
|
2208
|
+
let data;
|
|
2209
|
+
if (txToken) {
|
|
2210
|
+
data = await runGuarded(
|
|
2211
|
+
(challengeId) => api("console/exec", { method: "POST", body: { sessionToken: txToken, statement: s.text, params: parsedParams, challengeId } }),
|
|
2212
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget(), params: parsedParams })),
|
|
2213
|
+
);
|
|
2214
|
+
} else if (route === "query") {
|
|
2215
|
+
data = await runGuarded(
|
|
2216
|
+
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql: s.text, params: parsedParams, database: db || undefined, challengeId } }),
|
|
2217
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget(), params: parsedParams })),
|
|
2218
|
+
);
|
|
2219
|
+
} else {
|
|
2220
|
+
data = await runGuarded(
|
|
2221
|
+
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: s.text, params: parsedParams, database: db || undefined, challengeId } }),
|
|
2222
|
+
(info) => askConfirm(Object.assign({}, info, { target: confirmTarget(), params: parsedParams })),
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
const item = { text: s.text, start: s.start, route, status: "ok", elapsedMs: Date.now() - st, inTx: !!txToken };
|
|
2226
|
+
if (data && Array.isArray(data.columns)) item.data = data; // QueryResult(结果集)
|
|
2227
|
+
else { item.message = (data && data.message) || ""; item.affectedRows = data && data.affectedRows; }
|
|
2228
|
+
items.push(item);
|
|
2229
|
+
} catch (e) {
|
|
2230
|
+
if (e.cancelled) items.push({ text: s.text, start: s.start, route, status: "cancelled" });
|
|
2231
|
+
else items.push({ text: s.text, start: s.start, route, status: "error", error: String(e && e.message ? e.message : e), elapsedMs: Date.now() - st });
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
1460
2234
|
}
|
|
1461
|
-
} catch (e) {
|
|
1462
|
-
if (!e.cancelled) props.onError(e);
|
|
1463
2235
|
} finally {
|
|
1464
2236
|
setBusy(false);
|
|
1465
2237
|
}
|
|
2238
|
+
const elapsedMs = Date.now() - started;
|
|
2239
|
+
const ok = items.length > 0 && items.every((it) => it.status === "ok");
|
|
2240
|
+
setTabs((prev) => prev.map((tb, i) => (i === tIdx ? Object.assign({}, tb, {
|
|
2241
|
+
result: { items, elapsedMs },
|
|
2242
|
+
history: [{ sql: text, ts: started, elapsedMs, ok }].concat(targetTab ? targetTab.history : []).slice(0, 50),
|
|
2243
|
+
}) : tb)));
|
|
1466
2244
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
2245
|
+
const runRef = React.useRef(() => {});
|
|
2246
|
+
// 每轮 commit 后刷新闭包,按钮/编辑器回调恒拿最新 state(回调仅在 commit 后触发,时序不变)
|
|
2247
|
+
React.useLayoutEffect(() => { runRef.current = runBatch; });
|
|
2248
|
+
|
|
2249
|
+
function textareaSelection() {
|
|
2250
|
+
const ta = editorHostRef.current && editorHostRef.current.querySelector("textarea");
|
|
2251
|
+
if (!ta || ta.selectionStart === ta.selectionEnd) return "";
|
|
2252
|
+
return ta.value.slice(ta.selectionStart, ta.selectionEnd);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// 错误定位:按语句 start 偏移移动编辑器光标(CM dispatch / textarea setSelectionRange)
|
|
2256
|
+
function locateOffset(start) {
|
|
2257
|
+
const rec = editorRef.current;
|
|
2258
|
+
if (rec && rec.ed && rec.ed.view && rec.ed.view.state) {
|
|
2259
|
+
try {
|
|
2260
|
+
rec.ed.view.dispatch({ selection: { anchor: Math.max(0, Math.min(start || 0, rec.ed.view.state.doc.length)) }, scrollIntoView: true });
|
|
2261
|
+
rec.ed.view.focus();
|
|
2262
|
+
return;
|
|
2263
|
+
} catch (e) { /* 落 textarea 降级 */ }
|
|
1472
2264
|
}
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
2265
|
+
const ta = editorHostRef.current && editorHostRef.current.querySelector("textarea");
|
|
2266
|
+
if (ta) { ta.focus(); ta.setSelectionRange(start || 0, start || 0); }
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function formatSql() {
|
|
2270
|
+
const rec = editorRef.current;
|
|
2271
|
+
if (rec && rec.ed && typeof rec.ed.format === "function") rec.ed.format();
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
async function txBegin() {
|
|
2275
|
+
// 服务端对非默认库 begin 显式拒绝(粘性事务无法跨库),客户端同等守卫避免无效请求
|
|
2276
|
+
if (!rw || txBusy || txToken || !connId || db) return;
|
|
2277
|
+
setTxBusy(true);
|
|
2278
|
+
try {
|
|
2279
|
+
const r = await api("console/begin", { method: "POST", body: { projectPath, connId, database: db || undefined } });
|
|
2280
|
+
setTxToken((r && r.sessionToken) || "");
|
|
2281
|
+
} catch (e) { onError(e); } finally { setTxBusy(false); }
|
|
2282
|
+
}
|
|
2283
|
+
async function txEnd(commit) {
|
|
2284
|
+
if (!txToken || txBusy) return;
|
|
2285
|
+
setTxBusy(true);
|
|
2286
|
+
try {
|
|
2287
|
+
const r = await api(commit ? "console/commit" : "console/rollback", { method: "POST", body: { sessionToken: txToken } });
|
|
2288
|
+
setTxToken(null);
|
|
2289
|
+
patchActive({
|
|
2290
|
+
result: { items: [{ text: commit ? "COMMIT" : "ROLLBACK", start: 0, route: "script", status: "ok", message: (r && r.message) || t("ok") }], elapsedMs: 0 },
|
|
2291
|
+
});
|
|
2292
|
+
} catch (e) { onError(e); } finally { setTxBusy(false); }
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function addTab() {
|
|
2296
|
+
tabSeq.current += 1;
|
|
2297
|
+
const n = tabSeq.current;
|
|
2298
|
+
setTabs((prev) => prev.concat([{ id: "t" + n, title: t("queryTab", { n }), sql: "", result: null, history: [] }]));
|
|
2299
|
+
setActiveIdx(tabs.length);
|
|
2300
|
+
}
|
|
2301
|
+
function closeTab(i) {
|
|
2302
|
+
if (tabs.length <= 1) return;
|
|
2303
|
+
setTabs((prev) => prev.filter((_, k) => k !== i));
|
|
2304
|
+
setActiveIdx((cur) => (i < cur ? cur - 1 : Math.min(cur, tabs.length - 2)));
|
|
2305
|
+
}
|
|
2306
|
+
function restoreHistory(h) {
|
|
2307
|
+
const rec = editorRef.current;
|
|
2308
|
+
if (rec && rec.ed) rec.ed.setDoc(h.sql);
|
|
2309
|
+
patchActive({ sql: h.sql });
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// 单条结果行:路由徽标 + 语句摘要 + 耗时 + 行数/状态;查询结果接 ResultSetGrid
|
|
2313
|
+
//(exportHandlers 由组件注入:toCsv/toJson 纯函数 + Blob download)
|
|
2314
|
+
function renderResultItem(it, i) {
|
|
2315
|
+
const routeLabel = it.route === "query" ? t("routeQuery") : it.route === "script" ? t("routeScript") : t("routeExec");
|
|
2316
|
+
const head = React.createElement(
|
|
2317
|
+
"div",
|
|
2318
|
+
{ className: "dbt-listrow", key: "h" + i },
|
|
2319
|
+
React.createElement("span", { className: "dbt-routechip" }, routeLabel),
|
|
2320
|
+
// 事务内执行的查询语句加「事务内」徽标;写语句维持路由徽标不变
|
|
2321
|
+
it.inTx && it.route === "query" ? React.createElement("span", { className: "dbt-routechip" }, t("txInline")) : null,
|
|
2322
|
+
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)),
|
|
2323
|
+
it.elapsedMs != null ? React.createElement("span", { className: "dbt-muted", style: { flex: "none" } }, fmtMs(it.elapsedMs)) : null,
|
|
2324
|
+
it.status === "ok" && it.data ? React.createElement("span", { className: "dbt-muted", style: { flex: "none" } }, t("rowsResult", { n: it.data.rowCount })) : null,
|
|
2325
|
+
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,
|
|
2326
|
+
it.status === "skipped" ? React.createElement("span", { className: "dbt-readhint", style: { flex: "none" } }, t("skippedRo")) : null,
|
|
2327
|
+
it.status === "cancelled" ? React.createElement("span", { className: "dbt-muted", style: { flex: "none" } }, t("cancelled")) : null,
|
|
2328
|
+
it.status === "error" ? React.createElement("span", { className: "dbt-err", style: { flex: "none", maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis" } }, t("error") + ": " + it.error) : null,
|
|
2329
|
+
it.status === "error" ? React.createElement("button", { className: "dbt-btn", style: { flex: "none" }, "aria-label": t("locate"), onClick: () => locateOffset(it.start) }, t("locate")) : null,
|
|
1480
2330
|
);
|
|
2331
|
+
const grid = it.status === "ok" && it.data ? React.createElement(ResultSetGrid, {
|
|
2332
|
+
key: "g" + i,
|
|
2333
|
+
columns: it.data.columns || [],
|
|
2334
|
+
rows: it.data.rows || [],
|
|
2335
|
+
truncated: it.data.truncated,
|
|
2336
|
+
elapsedMs: it.elapsedMs,
|
|
2337
|
+
exportHandlers: exportRef.current,
|
|
2338
|
+
}) : null;
|
|
2339
|
+
return grid ? [head, grid] : head;
|
|
1481
2340
|
}
|
|
2341
|
+
|
|
2342
|
+
const segBtn = (m, label) => React.createElement("button", { key: m, className: mode === m ? "active" : "", onClick: () => setMode(m) }, label);
|
|
1482
2343
|
return React.createElement(
|
|
1483
2344
|
"div",
|
|
1484
2345
|
{ className: "dbt-card" },
|
|
1485
|
-
//
|
|
2346
|
+
// 选择器:连接(事务进行中锁定,防语句写到另一连接)+ 跨库下拉
|
|
1486
2347
|
React.createElement(
|
|
1487
2348
|
"div",
|
|
1488
2349
|
{ className: "dbt-group" },
|
|
1489
2350
|
React.createElement(
|
|
1490
2351
|
"div",
|
|
1491
2352
|
{ className: "dbt-listrow" },
|
|
1492
|
-
React.createElement("select", { value: connId, onChange: (e) => setConnId(e.target.value) },
|
|
1493
|
-
React.createElement("option", { value: "" }, t("
|
|
1494
|
-
conns.map((c) =>
|
|
1495
|
-
|
|
1496
|
-
|
|
2353
|
+
React.createElement("select", { value: connId, disabled: !!txToken, onChange: (e) => setConnId(e.target.value) },
|
|
2354
|
+
React.createElement("option", { value: "" }, t("selectConn")),
|
|
2355
|
+
conns.map((c) => {
|
|
2356
|
+
const g = (grants || []).find((x) => x.connId === c.id);
|
|
2357
|
+
const suffix = g && g.mode ? " · " + g.mode : "";
|
|
2358
|
+
return React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + suffix + ")");
|
|
2359
|
+
})),
|
|
2360
|
+
dbList.length > 1 ? React.createElement("select", { value: db, disabled: !!txToken, onChange: (e) => setDb(e.target.value) },
|
|
1497
2361
|
React.createElement("option", { value: "" }, t("defaultDb")),
|
|
1498
2362
|
dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
|
|
1499
2363
|
),
|
|
1500
|
-
|
|
2364
|
+
),
|
|
2365
|
+
// 多标签条(内存态):标签 + 关闭 + 新建
|
|
2366
|
+
React.createElement(
|
|
2367
|
+
"div",
|
|
2368
|
+
{ className: "dbt-cm-tabbar" },
|
|
2369
|
+
tabs.map((tb, i) => React.createElement(
|
|
2370
|
+
"button",
|
|
2371
|
+
{
|
|
2372
|
+
key: tb.id, className: "dbt-cm-tab" + (i === activeIdx ? " active" : ""),
|
|
2373
|
+
onClick: () => setActiveIdx(i),
|
|
2374
|
+
},
|
|
2375
|
+
tb.title,
|
|
2376
|
+
tabs.length > 1 ? React.createElement("span", {
|
|
2377
|
+
className: "dbt-cm-tabclose", role: "button", "aria-label": t("closeTab"), tabIndex: 0,
|
|
2378
|
+
onClick: (e) => { e.stopPropagation(); closeTab(i); },
|
|
2379
|
+
}, "×") : null,
|
|
2380
|
+
)),
|
|
2381
|
+
React.createElement("button", { className: "dbt-cm-tab", "aria-label": t("newTab"), title: t("newTab"), onClick: addTab }, "+"),
|
|
2382
|
+
),
|
|
2383
|
+
// 工具行 1:SQL/脚本 seg + 格式化 + 事务按钮组(仅 rw 授权显示)
|
|
2384
|
+
React.createElement(
|
|
2385
|
+
"div",
|
|
2386
|
+
{ className: "dbt-row" },
|
|
2387
|
+
React.createElement("div", { className: "dbt-seg" }, [segBtn("sql", t("modeSql")), segBtn("script", t("modeScript"))]),
|
|
2388
|
+
mode === "sql" ? React.createElement("button", { className: "dbt-btn", disabled: !connId, onClick: formatSql }, t("formatSql")) : null,
|
|
2389
|
+
React.createElement("span", { style: { flex: 1 } }),
|
|
2390
|
+
rw ? React.createElement(
|
|
1501
2391
|
"div",
|
|
1502
|
-
{ className: "dbt-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
2392
|
+
{ className: "dbt-row", style: { flex: "none" } },
|
|
2393
|
+
txToken
|
|
2394
|
+
? [
|
|
2395
|
+
React.createElement("span", { className: "dbt-muted", key: "txs" }, t("txActive")),
|
|
2396
|
+
React.createElement("button", { className: "dbt-btn primary", key: "c", disabled: txBusy, onClick: () => txEnd(true) }, t("txCommit")),
|
|
2397
|
+
React.createElement("button", { className: "dbt-btn danger", key: "r", disabled: txBusy, onClick: () => txEnd(false) }, t("txRollback")),
|
|
2398
|
+
]
|
|
2399
|
+
: React.createElement("button", { className: "dbt-btn", disabled: txBusy || !connId || !!db, title: db ? t("txDefaultDbOnly") : undefined, onClick: txBegin }, t("txBegin")),
|
|
2400
|
+
) : null,
|
|
1508
2401
|
),
|
|
1509
|
-
//
|
|
1510
|
-
React.createElement(
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
2402
|
+
// 工具行 2:执行(全量)/ 执行选中 + params(script 模式无绑定参数)
|
|
2403
|
+
React.createElement(
|
|
2404
|
+
"div",
|
|
2405
|
+
{ className: "dbt-row" },
|
|
2406
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: busy || !connId, onClick: () => runRef.current("all") }, busy ? t("running") : t("run")),
|
|
2407
|
+
mode === "sql" ? React.createElement("button", { className: "dbt-btn", disabled: busy || !connId, onClick: () => runRef.current("selection") }, t("runSelection")) : null,
|
|
2408
|
+
mode !== "script" ? React.createElement("input", { placeholder: t("paramsJson"), value: params, onChange: (e) => setParams(e.target.value), style: { fontFamily: MONO_FONT } }) : null,
|
|
2409
|
+
),
|
|
2410
|
+
// 编辑器:CM 挂载(div ref)或降级 textarea(bundle 加载中/失败);脚本模式提示 JS 占位
|
|
2411
|
+
React.createElement(
|
|
2412
|
+
"div",
|
|
2413
|
+
{ className: "dbt-editor-host", ref: editorHostRef },
|
|
2414
|
+
cm && cm !== "failed"
|
|
2415
|
+
? null // CM 自建 .cm-editor 填充宿主(bundle 主题 height:100%)
|
|
2416
|
+
: React.createElement("textarea", {
|
|
2417
|
+
placeholder: mode === "script" ? t("scriptPlaceholder") : t("sqlPlaceholder"),
|
|
2418
|
+
value: (activeTab && activeTab.sql) || "",
|
|
2419
|
+
onChange: (e) => patchActive({ sql: e.target.value }),
|
|
2420
|
+
style: Object.assign({ fontFamily: MONO_FONT },
|
|
2421
|
+
mode !== "sql" ? { outline: "1px solid var(--dbt-danger, #ff453a)" } : null),
|
|
2422
|
+
}),
|
|
2423
|
+
),
|
|
2424
|
+
// 结果区:逐条结果行 + 查询结果网格
|
|
2425
|
+
activeTab && activeTab.result && Array.isArray(activeTab.result.items)
|
|
2426
|
+
? React.createElement("div", { className: "dbt-col" }, activeTab.result.items.map(renderResultItem))
|
|
1517
2427
|
: null,
|
|
2428
|
+
// 历史卡(每 tab 内存态):可展开、点击回填,不落盘
|
|
1518
2429
|
React.createElement(
|
|
1519
2430
|
"div",
|
|
1520
2431
|
{ className: "dbt-row" },
|
|
1521
|
-
React.createElement("button", { className: "dbt-btn
|
|
2432
|
+
React.createElement("button", { className: "dbt-btn", "aria-expanded": !!historyOpen, onClick: () => setHistoryOpen(!historyOpen) }, t("historyTitle")),
|
|
1522
2433
|
),
|
|
1523
|
-
|
|
2434
|
+
historyOpen ? (
|
|
2435
|
+
!activeTab || activeTab.history.length === 0
|
|
2436
|
+
? React.createElement("div", { className: "dbt-muted" }, t("historyEmpty"))
|
|
2437
|
+
: React.createElement(
|
|
2438
|
+
"div",
|
|
2439
|
+
{ className: "dbt-group" },
|
|
2440
|
+
activeTab.history.map((h, i) => React.createElement(
|
|
2441
|
+
"div",
|
|
2442
|
+
{ className: "dbt-listrow", key: i },
|
|
2443
|
+
React.createElement("span", { className: h.ok ? "dbt-msg" : "dbt-err", style: { flex: "none" } }, h.ok ? "✓" : "✗"),
|
|
2444
|
+
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)),
|
|
2445
|
+
React.createElement("span", { className: "dbt-muted", style: { flex: "none" } },
|
|
2446
|
+
new Date(h.ts).toLocaleTimeString() + (typeof h.elapsedMs === "number" ? " · " + fmtMs(h.elapsedMs) : "")),
|
|
2447
|
+
)),
|
|
2448
|
+
)
|
|
2449
|
+
) : null,
|
|
1524
2450
|
);
|
|
1525
2451
|
}
|
|
1526
2452
|
|
|
@@ -1583,16 +2509,23 @@ window.__ModuleLoader__.load({
|
|
|
1583
2509
|
}
|
|
1584
2510
|
function onError(e) { setError(String(e && e.message ? e.message : e)); }
|
|
1585
2511
|
|
|
1586
|
-
const shared = { conns, grants, projectPath, busy, run, reload, onError };
|
|
2512
|
+
const shared = { conns, grants, projectPath, busy, run, reload, onError, askConfirm };
|
|
2513
|
+
// 四视图常驻元素:Panel 渲染期构建一次,切 tab 不重建(状态保留),ManageView/GrantsView/ConsoleView 复用 shared
|
|
2514
|
+
const viewEls = {
|
|
2515
|
+
manage: React.createElement(ManageView, shared),
|
|
2516
|
+
grants: React.createElement(GrantsView, shared),
|
|
2517
|
+
console: React.createElement(ConsoleView, shared),
|
|
2518
|
+
};
|
|
1587
2519
|
return React.createElement(
|
|
1588
2520
|
"div",
|
|
1589
2521
|
{ className: "dbt-panel" },
|
|
1590
2522
|
React.createElement(
|
|
1591
2523
|
"div",
|
|
1592
|
-
{ className: "dbt-tabs" },
|
|
2524
|
+
{ className: "dbt-tabs", role: "tablist" },
|
|
1593
2525
|
[["manage", t("viewManage")], ["grants", t("viewGrants")], ["browse", t("viewBrowse")], ["console", t("viewConsole")]].map(([v, label]) =>
|
|
1594
2526
|
React.createElement("button", {
|
|
1595
2527
|
key: v, className: view === v ? "active" : "",
|
|
2528
|
+
role: "tab", "aria-selected": view === v,
|
|
1596
2529
|
onClick: () => setView(v),
|
|
1597
2530
|
}, label)),
|
|
1598
2531
|
),
|
|
@@ -1615,7 +2548,7 @@ window.__ModuleLoader__.load({
|
|
|
1615
2548
|
React.createElement("span", { className: "dbt-muted" }, t("projectLabel", { path: projectPath })),
|
|
1616
2549
|
// 编辑图标:极简铅笔 SVG(验收要求无 emoji;svg aria-hidden,按钮 aria-label 提供语义)
|
|
1617
2550
|
React.createElement("button", {
|
|
1618
|
-
className: "dbt-btn", onClick: () => setProjectEdited(true), "aria-label": "edit",
|
|
2551
|
+
className: "dbt-btn", onClick: () => setProjectEdited(true), "aria-label": t("edit"),
|
|
1619
2552
|
},
|
|
1620
2553
|
React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
|
|
1621
2554
|
React.createElement("path", {
|
|
@@ -1623,18 +2556,22 @@ window.__ModuleLoader__.load({
|
|
|
1623
2556
|
stroke: "currentColor", strokeWidth: 1.2, strokeLinejoin: "round",
|
|
1624
2557
|
})),
|
|
1625
2558
|
)),
|
|
1626
|
-
error ? React.createElement("div", { className: "dbt-err" }, t("error") + ": " + error) : null,
|
|
1627
|
-
message ? React.createElement("div", { className: "dbt-msg" }, message) : null,
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
2559
|
+
error ? React.createElement("div", { className: "dbt-err", "aria-live": "polite" }, t("error") + ": " + error) : null,
|
|
2560
|
+
message ? React.createElement("div", { className: "dbt-msg", "aria-live": "polite" }, message) : null,
|
|
2561
|
+
// 视图常驻挂载 + hidden 切换:切 tab 保留各视图内部状态(控制台 SQL、浏览选中不丢);
|
|
2562
|
+
// dbt-in 入场动画只在面板首次打开播一次(.dbt-view 类自带,常驻后不随 tab 重播);
|
|
2563
|
+
// browse 撑满面板剩余高度(BrowsePane 内详情栏才能钉在面板最底部),active 控制首次激活才拉树。
|
|
2564
|
+
// .dbt-view 类的 display:flex 会盖过 hidden 的 UA 样式,故内联 display 同步切换
|
|
2565
|
+
["manage", "grants", "browse", "console"].map((v) =>
|
|
2566
|
+
React.createElement("div", {
|
|
2567
|
+
key: v, className: "dbt-view", hidden: view !== v,
|
|
2568
|
+
style: v === "browse"
|
|
2569
|
+
? (view === "browse" ? { flex: "1 1 auto", minHeight: 0 } : { display: "none" })
|
|
2570
|
+
: (view === v ? undefined : { display: "none" }),
|
|
2571
|
+
},
|
|
2572
|
+
v === "browse"
|
|
2573
|
+
? React.createElement(BrowsePane, { ctx: props.ctx, conns, projectPath, grants, askConfirm, active: view === "browse" })
|
|
2574
|
+
: viewEls[v])),
|
|
1638
2575
|
React.createElement("div", { className: "dbt-muted" }, t("footerHint")),
|
|
1639
2576
|
confirmReq ? React.createElement(DangerDialog, { challenge: confirmReq, onClose: () => setConfirmReq(null) }) : null,
|
|
1640
2577
|
);
|
|
@@ -1697,10 +2634,13 @@ window.__ModuleLoader__.load({
|
|
|
1697
2634
|
}
|
|
1698
2635
|
exports.apply = apply;
|
|
1699
2636
|
exports.inject = inject;
|
|
1700
|
-
//
|
|
2637
|
+
// 测试面:单元格写回 + 控制台工具纯函数 + ConsoleView(集成测试浅渲染元素树)
|
|
1701
2638
|
exports.__testables = {
|
|
1702
2639
|
dialectOf, quoteIdent, parseCellText, isTruncatedCell, isBlobCell,
|
|
1703
|
-
buildUpdate, buildRedisOp, buildMongoOp,
|
|
2640
|
+
buildUpdate, buildRedisOp, buildMongoOp, renderStatementWithParams,
|
|
2641
|
+
splitSqlStatements, fmtDialectOf, toCsv, toJson, fmtMs,
|
|
2642
|
+
sortRows, slicePage, ResultSetGrid,
|
|
2643
|
+
classifyHead, ConsoleView,
|
|
1704
2644
|
};
|
|
1705
2645
|
return module.exports;
|
|
1706
2646
|
},
|