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 +2 -2
- package/client/client.js +258 -71
- package/package.json +1 -1
package/client/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
## 文件
|
|
10
10
|
|
|
11
|
-
- `client.js` —
|
|
12
|
-
- 4 个面板:管理(连接 CRUD + 测试 + 审计查看)、授权(项目路径 → 连接 ro/rw
|
|
11
|
+
- `client.js` — 全部前端逻辑(单文件,约 1400 行),内含:
|
|
12
|
+
- 4 个面板:管理(连接 CRUD + 测试 + 审计查看)、授权(项目路径 → 连接 ro/rw/撤销)、浏览(树选库/表 → 点表打开 Navicat 式弹窗:左树 + 结构/数据 Tabs + 分页;portal 挂 body,Esc/遮罩/✕ 关闭)、SQL 控制台(连接/库选择、query/execute/script、危险确认对话框 → challengeId 重发)
|
|
13
13
|
- zh/en 双语词典(zh 默认),经 `ctx.locale.register(NS, ...)` 注册,`useSyncExternalStore` 订阅语言切换
|
|
14
14
|
- 危险确认:`runGuarded()` 先发请求,收到 `code:"NEEDS_CONFIRMATION"` 且有 `challengeId` 时弹 `DangerDialog`,用户确认后携 challengeId 重发(challenge 一次性、绑语句、5 分钟有效)
|
|
15
15
|
- REST API 调用走同源前缀 **`/dsh-db-tool/api`**(须与 host 端 `webServer.register` 的 prefix 一致)
|
package/client/client.js
CHANGED
|
@@ -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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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({});
|
|
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
|
-
{
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
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
|
-
|
|
915
|
-
|
|
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
|
-
{
|
|
918
|
-
// 结构/预览:mini segmented 切换(复用现有 i18n key,不新增)
|
|
1095
|
+
{ className: "dbt-browse-body" },
|
|
919
1096
|
React.createElement(
|
|
920
1097
|
"div",
|
|
921
|
-
{ className: "dbt-
|
|
922
|
-
|
|
923
|
-
|
|
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(
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|