dsh-db-tool 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/README.md CHANGED
@@ -8,8 +8,8 @@
8
8
 
9
9
  ## 文件
10
10
 
11
- - `client.js` — 全部前端逻辑(约 1000 行),内含:
12
- - 4 个面板:管理(连接 CRUD + 测试 + 审计查看)、授权(项目路径 → 连接 ro/rw/撤销)、浏览(库 → 表 → 结构 → 预览分页)、SQL 控制台(连接/库选择、query/execute/script、危险确认对话框 → challengeId 重发)
11
+ - `client.js` — 全部前端逻辑(单文件,约 1400 行),内含:
12
+ - 4 个面板:管理(连接 CRUD + 测试 + 审计查看)、授权(项目路径 → 连接 ro/rw/撤销)、浏览(树选库/表 → 点表打开 Navicat 式弹窗:左树 + 结构/数据 Tabs + 分页;portal 挂 body,Esc/遮罩/✕ 关闭)、SQL 控制台(连接/库选择、query/execute/script、危险确认对话框 → challengeId 重发)
13
13
  - zh/en 双语词典(zh 默认),经 `ctx.locale.register(NS, ...)` 注册,`useSyncExternalStore` 订阅语言切换
14
14
  - 危险确认:`runGuarded()` 先发请求,收到 `code:"NEEDS_CONFIRMATION"` 且有 `challengeId` 时弹 `DangerDialog`,用户确认后携 challengeId 重发(challenge 一次性、绑语句、5 分钟有效)
15
15
  - REST API 调用走同源前缀 **`/dsh-db-tool/api`**(须与 host 端 `webServer.register` 的 prefix 一致)
package/client/client.js CHANGED
@@ -85,6 +85,11 @@ window.__ModuleLoader__.load({
85
85
  prevPage: "上一页",
86
86
  pageInfo: "第 {page} 页(每页 50 行)",
87
87
  previewTruncated: "结果已截断",
88
+ browseDialogTitle: "{conn} / {db} / {table}",
89
+ close: "关闭",
90
+ emptyStructure: "暂无字段信息",
91
+ emptyData: "暂无数据",
92
+ retry: "重试",
88
93
  // SQL 控制台
89
94
  modeQuery: "只读查询",
90
95
  modeExecute: "写入执行",
@@ -174,6 +179,11 @@ window.__ModuleLoader__.load({
174
179
  prevPage: "Prev",
175
180
  pageInfo: "Page {page} (50 rows/page)",
176
181
  previewTruncated: "Result truncated",
182
+ browseDialogTitle: "{conn} / {db} / {table}",
183
+ close: "Close",
184
+ emptyStructure: "No columns",
185
+ emptyData: "No rows",
186
+ retry: "Retry",
177
187
  modeQuery: "query (read-only)",
178
188
  modeExecute: "execute (write)",
179
189
  modeScript: "script",
@@ -280,9 +290,11 @@ window.__ModuleLoader__.load({
280
290
  if (styleEl) return;
281
291
  styleEl = document.createElement("style");
282
292
  styleEl.textContent = [
283
- "/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转) ===== */",
284
- ".dbt-panel{--dbt-accent:var(--dsh-accent,#0a84ff);--dbt-bg:transparent;--dbt-surface:rgba(120,120,128,.12);--dbt-surface-strong:rgba(120,120,128,.18);--dbt-separator:rgba(120,120,128,.24);--dbt-text-secondary:rgba(235,235,245,.6);--dbt-danger:#ff453a;--dbt-success:#30d158;--dbt-warning:#ffd60a;--dbt-dialog-bg:rgba(40,40,44,.85);--dbt-th-bg:rgba(30,30,32,.72);--dbt-seg-active:rgba(255,255,255,.14);--dbt-shadow:0 8px 32px rgba(0,0,0,.28);--dbt-radius-card:12px;--dbt-radius-ctrl:8px;--dbt-radius-pill:6px;--dbt-ease:cubic-bezier(.25,.1,.25,1);--dbt-mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif;}",
285
- "@media (prefers-color-scheme: light){.dbt-panel{--dbt-accent:var(--dsh-accent,#007aff);--dbt-surface:rgba(120,120,128,.08);--dbt-surface-strong:rgba(120,120,128,.14);--dbt-separator:rgba(60,60,67,.18);--dbt-text-secondary:rgba(60,60,67,.6);--dbt-danger:#ff3b30;--dbt-success:#34c759;--dbt-warning:#ff9f0a;--dbt-dialog-bg:rgba(252,252,252,.9);--dbt-th-bg:rgba(255,255,255,.72);--dbt-seg-active:rgba(255,255,255,.9);--dbt-shadow:0 8px 32px rgba(0,0,0,.12);}}",
293
+ "/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转;.dbt-browse-root 并入以承载 portal 弹窗) ===== */",
294
+ ".dbt-panel,.dbt-browse-root{--dbt-accent:var(--dsh-accent,#0a84ff);--dbt-bg:transparent;--dbt-surface:rgba(120,120,128,.12);--dbt-surface-strong:rgba(120,120,128,.18);--dbt-separator:rgba(120,120,128,.24);--dbt-text:rgba(235,235,245,.92);--dbt-text-secondary:rgba(235,235,245,.6);--dbt-danger:#ff453a;--dbt-success:#30d158;--dbt-warning:#ffd60a;--dbt-dialog-bg:rgba(40,40,44,.85);--dbt-th-bg:rgba(30,30,32,.72);--dbt-seg-active:rgba(255,255,255,.14);--dbt-shadow:0 8px 32px rgba(0,0,0,.28);--dbt-radius-card:12px;--dbt-radius-ctrl:8px;--dbt-radius-pill:6px;--dbt-ease:cubic-bezier(.25,.1,.25,1);--dbt-mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif;}",
295
+ "@media (prefers-color-scheme: light){.dbt-panel,.dbt-browse-root{--dbt-accent:var(--dsh-accent,#007aff);--dbt-surface:rgba(120,120,128,.08);--dbt-surface-strong:rgba(120,120,128,.14);--dbt-separator:rgba(60,60,67,.18);--dbt-text:rgba(30,30,32,.92);--dbt-text-secondary:rgba(60,60,67,.6);--dbt-danger:#ff3b30;--dbt-success:#34c759;--dbt-warning:#ff9f0a;--dbt-dialog-bg:rgba(252,252,252,.9);--dbt-th-bg:rgba(255,255,255,.72);--dbt-seg-active:rgba(255,255,255,.9);--dbt-shadow:0 8px 32px rgba(0,0,0,.12);}}",
296
+ // 弹窗根自持文字色:portal 挂 document.body 后不再依赖侧栏祖先的 color 继承(ocr medium)
297
+ ".dbt-browse-root{color:var(--dbt-text);}",
286
298
  "/* ===== 根容器 ===== */",
287
299
  ".dbt-panel{font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:12px;padding:16px;height:100%;box-sizing:border-box;overflow-y:auto;}",
288
300
  "/* ===== iOS segmented control(顶部 tab) ===== */",
@@ -335,6 +347,25 @@ window.__ModuleLoader__.load({
335
347
  ".dbt-chev.open{transform:rotate(90deg);}",
336
348
  ".dbt-chev.leaf{visibility:hidden;}",
337
349
  ".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
350
+ /* ===== Navicat 式浏览弹窗 ===== */
351
+ ".dbt-browse-dialog{width:92vw;height:88vh;max-width:1400px;max-height:940px;min-width:min(680px,calc(100vw - 16px));min-height:min(420px,calc(100vh - 16px));background:var(--dbt-dialog-bg);color:inherit;border:1px solid var(--dbt-separator);border-radius:14px;box-shadow:var(--dbt-shadow);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);display:flex;flex-direction:column;overflow:hidden;box-sizing:border-box;font-size:13px;animation:dbt-in .22s var(--dbt-ease);}",
352
+ ".dbt-browse-header{flex:none;display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--dbt-separator);}",
353
+ ".dbt-browse-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:13px;}",
354
+ ".dbt-browse-close{flex:none;width:26px;height:26px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--dbt-text-secondary);border-radius:var(--dbt-radius-pill);cursor:pointer;transition:all .18s var(--dbt-ease);}",
355
+ ".dbt-browse-close:hover{background:var(--dbt-surface-strong);color:inherit;}",
356
+ ".dbt-browse-close:active{transform:scale(.92);}",
357
+ ".dbt-browse-body{flex:1;min-height:0;display:flex;}",
358
+ ".dbt-browse-tree{flex:none;width:260px;overflow-y:auto;border-right:1px solid var(--dbt-separator);}",
359
+ ".dbt-browse-main{flex:1;min-width:0;display:flex;flex-direction:column;}",
360
+ ".dbt-browse-tabs{flex:none;padding:10px 14px 0;}",
361
+ ".dbt-browse-content{flex:1;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px;overflow:hidden;}",
362
+ ".dbt-browse-tablewrap{flex:1;min-height:0;overflow:auto;border:none;border-radius:var(--dbt-radius-card);background:var(--dbt-surface);}",
363
+ ".dbt-browse-footer{flex:none;display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--dbt-separator);}",
364
+ ".dbt-browse-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--dbt-text-secondary);font-size:12px;}",
365
+ ".dbt-browse-errorbox{flex:1;display:flex;flex-direction:column;gap:10px;align-items:flex-start;justify-content:center;}",
366
+ ".dbt-browse-skel{display:flex;flex-direction:column;gap:8px;}",
367
+ ".dbt-browse-skelrow{display:flex;gap:8px;}",
368
+ ".dbt-browse-skelcell{flex:1;height:24px;background:var(--dbt-surface-strong);border-radius:var(--dbt-radius-pill);}",
338
369
  "/* ===== 视图切换入场(仅 transform/opacity;reduced-motion 全局已禁) ===== */",
339
370
  "@keyframes dbt-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}",
340
371
  ".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
@@ -343,9 +374,9 @@ window.__ModuleLoader__.load({
343
374
  ".dbt-seg button{flex:none;border:none;background:transparent;color:inherit;font-size:11px;padding:3px 10px;border-radius:var(--dbt-radius-pill);cursor:pointer;transition:all .18s var(--dbt-ease);}",
344
375
  ".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
345
376
  "/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
346
- "@media (prefers-reduced-transparency: reduce){.dbt-overlay{backdrop-filter:none;-webkit-backdrop-filter:none;background:rgba(0,0,0,.55);}.dbt-dialog{backdrop-filter:none;-webkit-backdrop-filter:none;background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}}",
347
- "@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#f5f5f7;}.dbt-table th{background:#f2f2f7;}}",
348
- "@media (prefers-reduced-motion: reduce){.dbt-panel *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
377
+ "@media (prefers-reduced-transparency: reduce){.dbt-overlay{backdrop-filter:none;-webkit-backdrop-filter:none;background:rgba(0,0,0,.55);}.dbt-dialog,.dbt-browse-dialog{backdrop-filter:none;-webkit-backdrop-filter:none;background:#2c2c2e;}.dbt-table th{backdrop-filter:none;-webkit-backdrop-filter:none;background:#1e1e20;}}",
378
+ "@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog,.dbt-browse-dialog{background:#f5f5f7;}.dbt-table th{background:#f2f2f7;}}",
379
+ "@media (prefers-reduced-motion: reduce){.dbt-panel *,.dbt-browse-root *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
349
380
  ].join("\n");
350
381
  document.head.appendChild(styleEl);
351
382
  }
@@ -378,14 +409,21 @@ window.__ModuleLoader__.load({
378
409
  );
379
410
  }
380
411
 
412
+ // --- 弹窗挂载:优先 createPortal 挂 document.body(避免侧栏容器裁剪);宿主未提供 react-dom 时回退 Panel 内 fixed overlay(DangerDialog 同模式) ---
413
+ let createPortal = null;
414
+ try { createPortal = require("react-dom").createPortal; } catch (e) { /* 留 null,走回退 */ }
415
+ function mountDialog(children) {
416
+ return createPortal ? createPortal(children, document.body) : children;
417
+ }
418
+
381
419
  /* ---------------- 连接管理 ---------------- */
382
420
  const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
383
421
 
384
- // 通用表格渲染:columns + rows(审计列表与查询结果共用)
385
- function resultTable(columns, rows, cellTitles) {
422
+ // 通用表格渲染:columns + rows(审计列表与查询结果共用);wrapClass 可选(浏览弹窗用自适应高度容器)
423
+ function resultTable(columns, rows, cellTitles, wrapClass) {
386
424
  return React.createElement(
387
425
  "div",
388
- { className: "dbt-tablewrap" },
426
+ { className: wrapClass || "dbt-tablewrap" },
389
427
  React.createElement(
390
428
  "table",
391
429
  { className: "dbt-table" },
@@ -751,25 +789,20 @@ window.__ModuleLoader__.load({
751
789
  }
752
790
 
753
791
  /* ---------------- 数据浏览 ---------------- */
754
- function BrowseView(props) {
755
- const { conns, projectPath } = props;
756
- // Navicat 式对象树:连接 ▸ 库/schema ▸ 表,懒加载展开
792
+ // Navicat 式对象树:连接 ▸ 库/[schema] ▸ 表,懒加载展开;面板与弹窗各持一个实例,展开状态互不影响
793
+ function BrowseTree(props) {
794
+ const { conns, projectPath, sel, onSelect } = props;
795
+ // sel 由父组件持有(弹窗内 active 高亮与初始选中都依赖);onSelect(选中记录, 触发元素) 上报
757
796
  const [open, setOpen] = React.useState({}); // "c:<id>" | "d:<id>/<db>" -> bool
758
797
  const [loading, setLoading] = React.useState({});
759
798
  const [error, setError] = React.useState({}); // 树节点加载失败信息(就地显示,可点重试)
760
799
  const [dbs, setDbs] = React.useState({}); // connId -> string[]
761
800
  const [schemasMap, setSchemasMap] = React.useState({}); // "<connId>/<db>" -> string[](PG/GaussDB 库内 schema 层)
762
801
  const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db|schema>" -> TableInfo[]
763
- const [sel, setSel] = React.useState(null); // {connId, db, table: TableInfo}
764
- const [schema, setSchema] = React.useState([]);
765
- const [preview, setPreview] = React.useState(null); // QueryResult
766
- const [page, setPage] = React.useState(1);
767
- const [busy, setBusy] = React.useState("");
768
- const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
769
802
 
770
- // 会话项目切换 / 连接列表变化时清空树缓存,避免陈旧授权下的旧数据
803
+ // 会话项目切换时清空树缓存,避免陈旧授权下的旧数据
771
804
  React.useEffect(() => {
772
- setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({}); setSel(null); setSchema([]); setPreview(null);
805
+ setOpen({}); setLoading({}); setError({}); setDbs({}); setSchemasMap({}); setTablesMap({});
773
806
  }, [projectPath]);
774
807
 
775
808
  function toggle(key, load) {
@@ -815,39 +848,28 @@ window.__ModuleLoader__.load({
815
848
  setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
816
849
  }));
817
850
  }
818
- // 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
819
- const openSeq = React.useRef(0);
820
- const openTable = React.useCallback((s, pg) => {
821
- if (!s || !projectPath) return;
822
- // PG 系跨库:database 传 "库名.schema";其它库传库名
823
- const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
824
- const seq = ++openSeq.current;
825
- setBusy("open");
826
- Promise.all([
827
- api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
828
- api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
829
- ])
830
- .then(([sch, prev]) => {
831
- if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
832
- setSchema(sch || []); setPreview(prev); setPage(pg || 1);
833
- })
834
- .catch((e) => props.onError(e))
835
- .finally(() => { if (seq === openSeq.current) setBusy(""); });
836
- }, [projectPath]);
837
- React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
838
-
839
851
  // 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注
852
+ // ponytail: 交互行全量 tabIndex=0 使 Tab 循环较长,升级路径为 APG roving tabindex + 方向键导航
840
853
  function treerow(key, level, isOpen, leaf, label, onClick, active, extra) {
841
854
  return React.createElement(
842
855
  "div",
843
- { className: "dbt-treerow" + (active ? " active" : ""), key, onClick: onClick || undefined, style: { paddingLeft: 12 + level * 16 } },
856
+ {
857
+ className: "dbt-treerow" + (active ? " active" : ""), key,
858
+ role: onClick ? "treeitem" : undefined,
859
+ tabIndex: onClick ? 0 : undefined,
860
+ onClick: onClick || undefined,
861
+ onKeyDown: onClick ? (e) => {
862
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick({ currentTarget: e.currentTarget }); }
863
+ } : undefined,
864
+ style: { paddingLeft: 12 + level * 16 },
865
+ },
844
866
  React.createElement("span", { className: "dbt-chev" + (isOpen ? " open" : "") + (leaf ? " leaf" : "") }, "▶"),
845
867
  React.createElement("span", { className: "dbt-treename" }, label),
846
868
  extra || null,
847
869
  );
848
870
  }
849
871
 
850
- // 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示 …)
872
+ // 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示 …);表节点点击上报选中记录与触发元素
851
873
  const treeRows = [];
852
874
  for (const c of conns) {
853
875
  const ck = "c:" + c.id;
@@ -887,7 +909,7 @@ window.__ModuleLoader__.load({
887
909
  const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
888
910
  treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
889
911
  tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
890
- () => setSel({ connId: c.id, db: d, schemaName: s, table: tb }), active));
912
+ (e) => onSelect({ connId: c.id, db: d, schemaName: s, table: tb }, e.currentTarget), active));
891
913
  }
892
914
  }
893
915
  continue;
@@ -899,7 +921,7 @@ window.__ModuleLoader__.load({
899
921
  const active = !!sel && sel.connId === c.id && sel.db === d && sel.table.name === tb.name;
900
922
  treeRows.push(treerow("t:" + c.id + "/" + d + "/" + tb.name, 2, false, true,
901
923
  tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
902
- () => setSel({ connId: c.id, db: d, table: tb }), active));
924
+ (e) => onSelect({ connId: c.id, db: d, table: tb }, e.currentTarget), active));
903
925
  }
904
926
  }
905
927
  }
@@ -907,43 +929,208 @@ window.__ModuleLoader__.load({
907
929
  return React.createElement(
908
930
  "div",
909
931
  { style: { display: "flex", flexDirection: "column", gap: 8 } },
910
- // Navicat 式对象树:点击展开连接/库,点击表查看结构/预览
932
+ // Navicat 式对象树:点击展开连接/库,点击表上报选中(由父组件决定打开弹窗)
911
933
  conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
912
934
  React.createElement("div", { className: "dbt-group" },
913
935
  React.createElement("div", { className: "dbt-tree" }, treeRows)),
914
- sel
915
- ? React.createElement(
936
+ );
937
+ }
938
+
939
+ // 面板视图:精简树 + 弹窗编排(sel / triggerRef / 打开与关闭)
940
+ function BrowseView(props) {
941
+ const { conns, projectPath } = props;
942
+ const [sel, setSel] = React.useState(null); // {connId, db, schemaName?, table: TableInfo}
943
+ // 打开弹窗的树行元素:关闭弹窗后焦点返回触发元素(APG Dialog Modal)
944
+ const triggerRef = React.useRef(null);
945
+ // 项目切换时关闭弹窗并清选中(树缓存由 BrowseTree 自清)
946
+ React.useEffect(() => { setSel(null); }, [projectPath]);
947
+ return React.createElement(
948
+ "div",
949
+ { style: { display: "flex", flexDirection: "column", gap: 8 } },
950
+ React.createElement(BrowseTree, {
951
+ conns, projectPath, sel,
952
+ onSelect: (s, el) => {
953
+ if (el) triggerRef.current = el; // 面板树触发的打开才记录焦点返回目标
954
+ setSel(s);
955
+ },
956
+ }),
957
+ sel ? React.createElement(BrowseDialog, {
958
+ initialSel: sel, conns, projectPath,
959
+ onClose: () => {
960
+ setSel(null);
961
+ // APG:对话框关闭后焦点返回触发元素;面板树行常驻 DOM,防御性检查归属
962
+ const el = triggerRef.current;
963
+ if (el && document.contains(el)) el.focus();
964
+ },
965
+ }) : null,
966
+ );
967
+ }
968
+
969
+ // Navicat 式浏览弹窗:左树选库-表,右看字段+数据(近全屏模态)
970
+ function BrowseDialog(props) {
971
+ const { initialSel, conns, projectPath, onClose } = props;
972
+ const [sel, setSel] = React.useState(initialSel); // 弹窗内当前选中(初始为面板点开的表)
973
+ const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
974
+ const [schema, setSchema] = React.useState([]);
975
+ const [preview, setPreview] = React.useState(null); // QueryResult
976
+ const [page, setPage] = React.useState(1);
977
+ const [busy, setBusy] = React.useState("");
978
+ const [loadErr, setLoadErr] = React.useState(""); // 右栏加载失败(就地显示 + 重试)
979
+ const openSeq = React.useRef(0); // 请求序号守卫
980
+ const dialogRef = React.useRef(null);
981
+ const closeRef = React.useRef(null);
982
+
983
+ // 请求序号守卫(沿用面板):快速切表/翻页时丢弃晚到旧响应,防止旧数据覆盖新选中项
984
+ const openTable = React.useCallback((s, pg) => {
985
+ if (!s || !projectPath) return;
986
+ // PG 系跨库:database 传 "库名.schema";其它库传库名
987
+ const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
988
+ const seq = ++openSeq.current;
989
+ setBusy("open"); setLoadErr("");
990
+ Promise.all([
991
+ api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
992
+ api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
993
+ ])
994
+ .then(([sch, prev]) => {
995
+ if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
996
+ setSchema(sch || []); setPreview(prev); setPage(pg || 1);
997
+ })
998
+ // 弹窗内错误就地显示(不走面板顶部错误区)
999
+ .catch((e) => { if (seq === openSeq.current) setLoadErr(String(e && e.message ? e.message : e)); })
1000
+ .finally(() => { if (seq === openSeq.current) setBusy(""); });
1001
+ }, [projectPath]);
1002
+ React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
1003
+
1004
+ // APG/react.dev:键盘处理统一挂 window 订阅(Esc 关闭 + Tab 焦点陷阱同层,
1005
+ // 避免焦点落在 body 时 Tab 事件不经 dialog 冒泡而逃逸出模态);cleanup 移除
1006
+ React.useEffect(() => {
1007
+ const onKey = (e) => {
1008
+ if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
1009
+ if (e.key === "Tab") trapTab(e);
1010
+ };
1011
+ window.addEventListener("keydown", onKey);
1012
+ // 初始焦点=关闭按钮(APG:对话框内首个交互元素;禁止聚焦 role="dialog" 容器本身)
1013
+ if (closeRef.current) closeRef.current.focus();
1014
+ return () => { window.removeEventListener("keydown", onKey); };
1015
+ }, []); // 依赖 []:onClose 语义恒定(setSel(null)+焦点返回),无陈旧闭包危害
1016
+
1017
+ // 轻量焦点陷阱:Tab 到末元素回首元素,Shift+Tab 反向(APG);焦点在弹窗外时拉回弹窗内
1018
+ function trapTab(e) {
1019
+ const root = dialogRef.current;
1020
+ if (!root) return;
1021
+ const found = root.querySelectorAll('button,input,select,textarea,a[href],[tabindex]:not([tabindex="-1"])');
1022
+ const list = Array.prototype.filter.call(found, (el) => !el.disabled && el.offsetParent !== null);
1023
+ if (list.length === 0) return;
1024
+ const first = list[0], last = list[list.length - 1];
1025
+ const active = document.activeElement;
1026
+ if (e.shiftKey && (active === first || !root.contains(active))) { e.preventDefault(); last.focus(); }
1027
+ else if (!e.shiftKey && (active === last || !root.contains(active))) { e.preventDefault(); first.focus(); }
1028
+ }
1029
+
1030
+ // 右栏互斥状态:加载骨架 / 错误重试 / 空态 / 表格
1031
+ function rightPane() {
1032
+ if (busy === "open") {
1033
+ // 表形状骨架:6 行 6 列静态色块(不引入新动画,reduced-motion 天然安全)
1034
+ return React.createElement("div", { className: "dbt-browse-skel", role: "status", "aria-busy": "true" },
1035
+ [0, 1, 2, 3, 4, 5].map((r) => React.createElement("div", { className: "dbt-browse-skelrow", key: r },
1036
+ [0, 1, 2, 3, 4, 5].map((c) => React.createElement("div", { className: "dbt-browse-skelcell", key: c })))));
1037
+ }
1038
+ if (loadErr) {
1039
+ // 错误就地显示 + 重试(弹窗遮罩下面板顶部错误区不可见,不走 props.onError)
1040
+ return React.createElement("div", { className: "dbt-browse-errorbox" },
1041
+ React.createElement("div", { className: "dbt-err" }, t("error") + ": " + loadErr),
1042
+ React.createElement("button", { className: "dbt-btn", onClick: () => openTable(sel, 1) }, t("retry")));
1043
+ }
1044
+ if (view === "structure") {
1045
+ if (!schema.length) return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyStructure"));
1046
+ return resultTable(
1047
+ [t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
1048
+ schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
1049
+ undefined, "dbt-browse-tablewrap");
1050
+ }
1051
+ if (!preview || !preview.rows || preview.rows.length === 0) {
1052
+ return React.createElement("div", { className: "dbt-browse-empty" }, t("emptyData"));
1053
+ }
1054
+ return resultTable(preview.columns, preview.rows, undefined, "dbt-browse-tablewrap");
1055
+ }
1056
+
1057
+ // 遮罩点击关闭需 mousedown 归属:表格内拖选文本滑出弹窗释放时 click 落在遮罩(公共祖先),
1058
+ // 仅当按下与释放都在遮罩自身才视为「点击遮罩关闭」(双审查报告一致命中)
1059
+ const downOnOverlay = React.useRef(false);
1060
+ return mountDialog(React.createElement(
1061
+ "div",
1062
+ {
1063
+ className: "dbt-overlay dbt-browse-root",
1064
+ onMouseDown: (e) => { downOnOverlay.current = e.target === e.currentTarget; },
1065
+ onClick: (e) => { if (downOnOverlay.current && e.target === e.currentTarget) onClose(); },
1066
+ },
1067
+ React.createElement(
1068
+ "div",
1069
+ {
1070
+ className: "dbt-browse-dialog",
1071
+ ref: dialogRef,
1072
+ role: "dialog", "aria-modal": "true", "aria-labelledby": "dbt-browse-title",
1073
+ onClick: (e) => e.stopPropagation(),
1074
+ },
1075
+ // ---- header:面包屑 + 关闭 ----
1076
+ React.createElement(
1077
+ "div",
1078
+ { className: "dbt-browse-header" },
1079
+ React.createElement("div", { className: "dbt-browse-title", id: "dbt-browse-title" },
1080
+ t("browseDialogTitle", {
1081
+ conn: (conns.find((c) => c.id === sel.connId) || {}).name || sel.connId,
1082
+ db: sel.schemaName ? sel.db + "." + sel.schemaName : sel.db,
1083
+ table: sel.table.name,
1084
+ })),
1085
+ React.createElement(
1086
+ "button",
1087
+ { className: "dbt-browse-close", ref: closeRef, "aria-label": t("close"), onClick: onClose },
1088
+ React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
1089
+ React.createElement("path", { d: "M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round" })),
1090
+ ),
1091
+ ),
1092
+ // ---- body:左树 + 右主区 ----
1093
+ React.createElement(
916
1094
  "div",
917
- { style: { display: "flex", flexDirection: "column", gap: 8 } },
918
- // 结构/预览:mini segmented 切换(复用现有 i18n key,不新增)
1095
+ { className: "dbt-browse-body" },
919
1096
  React.createElement(
920
1097
  "div",
921
- { className: "dbt-seg", style: { alignSelf: "flex-start" } },
922
- ["structure", "preview"].map((v) =>
923
- React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
1098
+ { className: "dbt-browse-tree" },
1099
+ React.createElement(BrowseTree, {
1100
+ conns, projectPath, sel,
1101
+ onSelect: (s) => setSel(s), // 弹窗内树切表;触发元素仅面板打开时记录,这里不传 el
1102
+ }),
924
1103
  ),
925
- React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + (sel.schemaName ? "." + sel.schemaName : "") + " / " + sel.table.name),
926
- view === "structure"
927
- ? resultTable(
928
- [t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
929
- schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
930
- )
931
- : React.createElement(
932
- React.Fragment,
933
- null,
934
- preview ? resultTable(preview.columns, preview.rows) : null,
1104
+ React.createElement(
1105
+ "div",
1106
+ { className: "dbt-browse-main" },
1107
+ // 结构/数据:mini segmented(复用面板同款与 i18n key)
1108
+ React.createElement(
1109
+ "div",
1110
+ { className: "dbt-browse-tabs" },
935
1111
  React.createElement(
936
1112
  "div",
937
- { className: "dbt-row", style: { justifyContent: "space-between" } },
938
- React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
939
- React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
940
- React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
1113
+ { className: "dbt-seg" },
1114
+ ["structure", "preview"].map((v) =>
1115
+ React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) },
1116
+ v === "structure" ? t("structure") : t("preview"))),
941
1117
  ),
942
- preview && preview.truncated ? React.createElement("div", { className: "dbt-muted" }, t("previewTruncated")) : null,
943
1118
  ),
944
- )
945
- : null,
946
- );
1119
+ React.createElement("div", { className: "dbt-browse-content" }, rightPane()),
1120
+ ),
1121
+ ),
1122
+ // ---- footer:分页条(仅数据预览视图) ----
1123
+ view === "preview" && !loadErr ? React.createElement(
1124
+ "div",
1125
+ { className: "dbt-browse-footer" },
1126
+ React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
1127
+ React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
1128
+ React.createElement("span", { style: { flex: 1 } }),
1129
+ preview && preview.truncated ? React.createElement("span", { className: "dbt-muted" }, t("previewTruncated")) : null,
1130
+ React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
1131
+ ) : null,
1132
+ ),
1133
+ ));
947
1134
  }
948
1135
 
949
1136
  /* ---------------- SQL 控制台 ---------------- */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
5
5
  "type": "module",
6
6
  "license": "MIT",