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/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:all .18s var(--dbt-ease);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
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:all .18s var(--dbt-ease);}",
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:all .18s var(--dbt-ease);}",
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-muted);font-size:10px;line-height:1;transition:transform .18s var(--dbt-ease);}",
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:all .18s var(--dbt-ease);}",
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:#f5f5f7;}.dbt-celldetail{background:#f2f2f7;}.dbt-table th,.dbt-browse-root .dbt-table th{background:#f2f2f7;}}",
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
- { className: "dbt-dialog", onClick: (e) => e.stopPropagation() },
434
- // macOS alert 风格:纯文字标题 + danger 色,不用 emoji
435
- React.createElement("strong", { style: { color: "var(--dbt-danger, #ff453a)" } }, t("needConfirmTitle")),
436
- React.createElement("pre", null, ch.statement || "(?)"),
437
- React.createElement(
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: t("connId") + "(" + t("idAutoHint") + ")", value: form.id, disabled: !!props.initial, onChange: (e) => patch({ id: e.target.value }) }),
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: props.initial && props.initial.hasPassword ? t("passwordSaved") : t("fieldsPassword"), value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
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
- if (!window.confirm(t("deleteConnConfirm", { name: c.name || c.id }))) return;
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", onClick: loadAudit }, t("auditTitle", { n: 50 }).split("(")[0].split(" (")[0]),
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 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
856
- // iOS inset grouped:一张卡装全部连接,行间 hairline 分隔
857
- React.createElement(
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
- React.createElement("span", { style: { background: "var(--dbt-surface-strong, rgba(120,120,128,.18))", borderRadius: "6px", padding: "2px 6px", fontFamily: MONO_FONT, fontSize: "11px", lineHeight: 1.45 } }, c.kind),
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
- testInfo[c.id] ? React.createElement("div", { className: testInfo[c.id].startsWith("✓") ? "dbt-msg" : "dbt-err" }, testInfo[c.id]) : null,
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
- { className: "dbt-card" },
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
- // 列表行式:名称 13px semibold 主行 + kind muted 副行,右侧 mini segmented
1291
+ // 主行对齐连接管理:kind 填充 pill + 名称 13px semibold + 模式徽标(seg 为权威状态源,徽标 aria-hidden 免重复播报)
934
1292
  React.createElement(
935
1293
  "div",
936
- { style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 } },
937
- React.createElement("span", { style: { fontWeight: 600, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
938
- React.createElement("span", { className: "dbt-muted" }, c.kind),
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
- // 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注
1027
- // ponytail: 交互行全量 tabIndex=0 使 Tab 循环较长,升级路径为 APG roving tabindex + 方向键导航
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
- tabIndex: onClick ? 0 : undefined,
1035
- onClick: onClick || undefined,
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:保存成功回调, askConfirm:runGuarded 确认回调 }
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(false);
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(true);
1153
- window.setTimeout(() => setCopied(false), 1500);
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
- React.createElement("tr", { key: i },
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
- null,
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
- React.createElement("button", { className: "dbt-btn", onClick: copyCell }, copied ? t("copied") : t("copyBtn")),
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
- { className: "dbt-view" },
1823
+ { style: { display: "flex", flexDirection: "column", gap: 10, flex: "1 1 auto", minHeight: 0 } },
1375
1824
  React.createElement(
1376
1825
  "div",
1377
- { style: { maxHeight: 280, overflowY: "auto" } },
1826
+ // flex:none:树卡固定限高不被压缩(面板过矮时收缩的是主区),对齐 DESIGN.md 分区规格
1827
+ { style: { flex: "none", maxHeight: 280, overflowY: "auto" } },
1378
1828
  React.createElement(BrowseTree, {
1379
- conns, projectPath, sel, autoExpand: true, // 内嵌后此树是唯一树实例,挂载即展开第一层级
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
- { className: "dbt-seg" },
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", style: { maxHeight: 420 } }, rightPane()),
1850
+ // 内容区由 flex 吃满 main 剩余高度(表格外滚),不再用固定 maxHeight 兜底
1851
+ React.createElement("div", { className: "dbt-browse-content" }, rightPane()),
1400
1852
  ),
1401
- // 分页条(仅已选表的数据预览视图;空选中态不显示)
1402
- sel && view === "preview" && !loadErr ? React.createElement(
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("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
1406
- React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
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
- preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
1409
- React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
1410
- ) : null,
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("query"); // query | execute | script
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
- async function run() {
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
- setResult(null);
2181
+ const started = Date.now();
2182
+ const items = [];
1437
2183
  try {
1438
2184
  if (mode === "script") {
1439
- const data = await runGuarded(
1440
- (challengeId) => api("script", { method: "POST", body: { projectPath, connId, code: sql, challengeId } }),
1441
- askConfirm,
1442
- );
1443
- setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
1444
- } else if (mode === "execute") {
1445
- let parsedParams;
1446
- if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
1447
- const data = await runGuarded(
1448
- (challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, database: db || undefined, challengeId } }),
1449
- askConfirm,
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
- let parsedParams;
1454
- if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
1455
- const data = await runGuarded(
1456
- (challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, database: db || undefined, challengeId } }),
1457
- askConfirm,
1458
- );
1459
- setResult({ kind: "query", data });
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
- function renderResult() {
1468
- if (!result) return null;
1469
- if (result.kind === "exec") {
1470
- return React.createElement("div", { className: "dbt-msg" },
1471
- result.message + (result.affectedRows !== undefined ? " · " + t("execResult", { n: result.affectedRows }) : ""));
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 r = result.data;
1474
- return React.createElement(
1475
- React.Fragment,
1476
- null,
1477
- React.createElement("span", { className: "dbt-muted" }, t("rowsResult", { n: r.rowCount }) + (r.truncated ? " · " + t("previewTruncated") : "")),
1478
- // 公共 resultTable:新 table 契约(hairline 行/sticky 表头/等宽数据列)由样式层承担
1479
- resultTable(r.columns || [], r.rows || []),
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
- // 选择器:inset grouped 每行一个控件(连接 / 模式)
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("viewManage") + "…"),
1494
- conns.map((c) => React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + ")"))),
1495
- // 当前库(Navicat 式跨库:选中非默认库后 SQL 在该库执行,pg/gaussdb 按库路由)
1496
- dbList.length > 1 ? React.createElement("select", { value: db, onChange: (e) => setDb(e.target.value) },
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
- React.createElement(
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-listrow" },
1503
- React.createElement("select", { value: mode, onChange: (e) => setMode(e.target.value) },
1504
- React.createElement("option", { value: "query" }, t("modeQuery")),
1505
- React.createElement("option", { value: "execute" }, t("modeExecute")),
1506
- React.createElement("option", { value: "script" }, t("modeScript"))),
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
- // SQL 输入:等宽字体 + 填充式输入(背景/focus ring 由样式层承担),min-height 加大
1510
- React.createElement("textarea", {
1511
- placeholder: mode === "script" ? t("scriptPlaceholder") : t("sqlPlaceholder"),
1512
- value: sql, onChange: (e) => setSql(e.target.value),
1513
- style: { fontFamily: MONO_FONT, minHeight: mode === "script" ? 160 : 96 },
1514
- }),
1515
- mode !== "script"
1516
- ? React.createElement("input", { placeholder: t("paramsJson"), value: params, onChange: (e) => setParams(e.target.value), style: { fontFamily: MONO_FONT } })
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 primary", disabled: busy || !connId || !sql.trim(), onClick: run }, busy ? t("running") : t("run")),
2432
+ React.createElement("button", { className: "dbt-btn", "aria-expanded": !!historyOpen, onClick: () => setHistoryOpen(!historyOpen) }, t("historyTitle")),
1522
2433
  ),
1523
- renderResult(),
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
- // 视图切换:key=view 触发 dbt-in 进入动画(opacity + 4px 上移,.22s)
1629
- React.createElement("div", { className: "dbt-view", key: view },
1630
- view === "manage" ? React.createElement(ManageView, shared) : null,
1631
- view === "grants" ? React.createElement(GrantsView, shared) : null,
1632
- // browse:内嵌浏览面板(对象树 + 结构/数据预览,点表即看)
1633
- view === "browse" ? React.createElement(BrowsePane, {
1634
- ctx: props.ctx, conns, projectPath, grants, askConfirm,
1635
- }) : null,
1636
- view === "console" ? React.createElement(ConsoleView, Object.assign({}, shared, { askConfirm })) : null,
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
- // 测试面:单元格写回纯函数(单测直接断言命令构造,无需起 React/HTTP)
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
  },