dsh-db-tool 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/client/README.md +53 -0
- package/client/client.js +1128 -0
- package/cordis.patch.yml +6 -0
- package/dist/adapters/dmdb/index.js +326 -0
- package/dist/adapters/gaussdb/index.js +32 -0
- package/dist/adapters/index.js +41 -0
- package/dist/adapters/mongodb/index.js +508 -0
- package/dist/adapters/mysql/index.js +222 -0
- package/dist/adapters/oracle/index.js +369 -0
- package/dist/adapters/postgresql/index.js +15 -0
- package/dist/adapters/redis/index.js +492 -0
- package/dist/adapters/sql-shared/common.js +69 -0
- package/dist/adapters/sql-shared/pg-like.js +170 -0
- package/dist/adapters/sqlite/index.js +106 -0
- package/dist/adapters/types.js +1 -0
- package/dist/guard/index.js +221 -0
- package/dist/http/index.js +396 -0
- package/dist/index.js +130 -0
- package/dist/manager.js +469 -0
- package/dist/script/runner.js +147 -0
- package/dist/script/worker.cjs +104 -0
- package/dist/store/audit.js +41 -0
- package/dist/store/connections.js +167 -0
- package/dist/store/grants.js +82 -0
- package/dist/store/index.js +41 -0
- package/dist/store/io.js +66 -0
- package/dist/store/normalize.js +22 -0
- package/dist/store/secrets.js +42 -0
- package/docs/api-contract.md +76 -0
- package/docs/apple-redesign-spec.md +80 -0
- package/docs/dsh-market-submission/mengqi1436__dsh-db-tool.yml +6 -0
- package/docs/install.md +75 -0
- package/docs/review-findings.md +95 -0
- package/docs/skill.md +48 -0
- package/package.json +70 -0
- package/scripts/build-gaussdb.ps1 +72 -0
- package/scripts/build-gaussdb.sh +53 -0
- package/scripts/diag-profile.mjs +21 -0
- package/scripts/verify-host.mjs +53 -0
- package/skills/db-admin/SKILL.md +138 -0
package/client/client.js
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-db-tool",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const React = require("react");
|
|
8
|
+
|
|
9
|
+
/* ============================================================
|
|
10
|
+
* dsh-db-tool 侧边栏客户端
|
|
11
|
+
* 形态与 dsh-ssh-tunnel 一致:单文件 __ModuleLoader__ 模块,
|
|
12
|
+
* exports.inject / exports.apply(ctx) 由 DSH web 宿主加载,
|
|
13
|
+
* 经 dsh-better-sidebar registerTab 注册侧边栏 tab。
|
|
14
|
+
* ============================================================ */
|
|
15
|
+
|
|
16
|
+
// --- i18n(dsh-better-sidebar 同款模式)---
|
|
17
|
+
const LOCALE_NS = "dbTool";
|
|
18
|
+
const zh = {
|
|
19
|
+
tabTitle: "数据库",
|
|
20
|
+
projectLabel: "项目: {path}",
|
|
21
|
+
projectUnbound: "未识别当前项目路径,请在下方填写工作区绝对路径",
|
|
22
|
+
projectPathPlaceholder: "工作区绝对路径(如 E:\\Code\\my-app)",
|
|
23
|
+
viewManage: "连接管理",
|
|
24
|
+
viewGrants: "项目授权",
|
|
25
|
+
viewBrowse: "数据浏览",
|
|
26
|
+
viewConsole: "SQL 控制台",
|
|
27
|
+
footerHint: "管理在此侧栏;业务操作要求当前项目已获得对应连接的授权。",
|
|
28
|
+
// 连接管理
|
|
29
|
+
newConn: "新建连接",
|
|
30
|
+
editConn: "编辑连接",
|
|
31
|
+
noConns: "还没有连接,点击「新建连接」添加。",
|
|
32
|
+
connName: "名称 / 别名",
|
|
33
|
+
connId: "连接 ID",
|
|
34
|
+
urlMode: "URL 方式",
|
|
35
|
+
fieldsMode: "分字段方式",
|
|
36
|
+
url: "连接 URL(密码将自动拆入密钥存储)",
|
|
37
|
+
fieldsHost: "主机地址",
|
|
38
|
+
fieldsPort: "端口",
|
|
39
|
+
fieldsUser: "用户名",
|
|
40
|
+
fieldsPassword: "密码",
|
|
41
|
+
fieldsDatabase: "数据库名",
|
|
42
|
+
ssl: "启用 SSL",
|
|
43
|
+
test: "测试",
|
|
44
|
+
testing: "测试中…",
|
|
45
|
+
testOk: "连接成功",
|
|
46
|
+
testFail: "连接失败",
|
|
47
|
+
edit: "编辑",
|
|
48
|
+
delete: "删除",
|
|
49
|
+
deleteConnConfirm: "确定删除连接「{name}」?将级联删除其密钥与所有项目的授权。",
|
|
50
|
+
save: "保存",
|
|
51
|
+
cancel: "取消",
|
|
52
|
+
saving: "保存中…",
|
|
53
|
+
idAutoHint: "留空则自动生成",
|
|
54
|
+
auditTitle: "操作审计(最近 {n} 条)",
|
|
55
|
+
auditEmpty: "当前项目暂无审计记录",
|
|
56
|
+
auditTime: "时间",
|
|
57
|
+
auditAction: "操作",
|
|
58
|
+
auditConn: "连接",
|
|
59
|
+
auditDetail: "详情",
|
|
60
|
+
auditDenied: "拒绝",
|
|
61
|
+
auditConfirmed: "已确认",
|
|
62
|
+
// 授权
|
|
63
|
+
grantsHint: "为当前项目授权连接。只读=仅查询/浏览,读写=允许执行语句与脚本。未授权的连接在业务操作中一律拒绝。",
|
|
64
|
+
modeNone: "未授权",
|
|
65
|
+
modeRo: "只读",
|
|
66
|
+
modeRw: "读写",
|
|
67
|
+
// 浏览
|
|
68
|
+
noDatabases: "无可用数据库",
|
|
69
|
+
noTables: "无表",
|
|
70
|
+
loadFailed: "加载失败",
|
|
71
|
+
structure: "结构",
|
|
72
|
+
preview: "数据预览",
|
|
73
|
+
column: "列名",
|
|
74
|
+
dataType: "类型",
|
|
75
|
+
nullable: "可空",
|
|
76
|
+
keyCol: "键",
|
|
77
|
+
defaultVal: "默认值",
|
|
78
|
+
comment: "注释",
|
|
79
|
+
nextPage: "下一页",
|
|
80
|
+
prevPage: "上一页",
|
|
81
|
+
pageInfo: "第 {page} 页(每页 50 行)",
|
|
82
|
+
previewTruncated: "结果已截断",
|
|
83
|
+
// SQL 控制台
|
|
84
|
+
modeQuery: "只读查询",
|
|
85
|
+
modeExecute: "写入执行",
|
|
86
|
+
modeScript: "脚本",
|
|
87
|
+
run: "执行",
|
|
88
|
+
running: "执行中…",
|
|
89
|
+
sqlPlaceholder: "输入 SQL / 命令…(Redis: [\"GET\",\"key\"];Mongo: {\"find\":\"users\",\"filter\":{}})",
|
|
90
|
+
scriptPlaceholder: "输入 JS 脚本(vm 沙箱,含 db 助手对象)…",
|
|
91
|
+
paramsJson: "查询参数(JSON 数组,可选)",
|
|
92
|
+
rowsResult: "{n} 行",
|
|
93
|
+
execResult: "受影响 {n} 行",
|
|
94
|
+
needConfirmTitle: "危险操作确认",
|
|
95
|
+
dangerLevel: "风险等级",
|
|
96
|
+
reason: "原因",
|
|
97
|
+
confirmRun: "确认执行",
|
|
98
|
+
confirmCancel: "取消",
|
|
99
|
+
confirmHint: "该语句经服务端判定为危险操作,需人工确认后才执行(确认凭据 5 分钟内有效)。",
|
|
100
|
+
cancelled: "已取消",
|
|
101
|
+
// 通用
|
|
102
|
+
error: "错误",
|
|
103
|
+
ok: "操作完成",
|
|
104
|
+
};
|
|
105
|
+
const en = {
|
|
106
|
+
tabTitle: "Databases",
|
|
107
|
+
projectLabel: "Project: {path}",
|
|
108
|
+
projectUnbound: "Cannot detect current project path; enter the workspace absolute path below",
|
|
109
|
+
projectPathPlaceholder: "workspace absolute path (e.g. /home/me/my-app)",
|
|
110
|
+
viewManage: "Connections",
|
|
111
|
+
viewGrants: "Project Access",
|
|
112
|
+
viewBrowse: "Browse",
|
|
113
|
+
viewConsole: "SQL Console",
|
|
114
|
+
footerHint: "Manage here in the sidebar; business operations require a project grant on the connection.",
|
|
115
|
+
newConn: "New Connection",
|
|
116
|
+
editConn: "Edit Connection",
|
|
117
|
+
noConns: "No connections yet. Click \"New Connection\" to add one.",
|
|
118
|
+
connName: "Name / alias",
|
|
119
|
+
connId: "Connection ID",
|
|
120
|
+
urlMode: "URL",
|
|
121
|
+
fieldsMode: "Fields",
|
|
122
|
+
url: "Connection URL (password moved to secrets automatically)",
|
|
123
|
+
fieldsHost: "Host",
|
|
124
|
+
fieldsPort: "Port",
|
|
125
|
+
fieldsUser: "User",
|
|
126
|
+
fieldsPassword: "Password",
|
|
127
|
+
fieldsDatabase: "Database",
|
|
128
|
+
ssl: "Enable SSL",
|
|
129
|
+
test: "Test",
|
|
130
|
+
testing: "Testing…",
|
|
131
|
+
testOk: "Connection OK",
|
|
132
|
+
testFail: "Connection failed",
|
|
133
|
+
edit: "Edit",
|
|
134
|
+
delete: "Delete",
|
|
135
|
+
deleteConnConfirm: "Delete connection \"{name}\"? Its secrets and all project grants will be removed.",
|
|
136
|
+
save: "Save",
|
|
137
|
+
cancel: "Cancel",
|
|
138
|
+
saving: "Saving…",
|
|
139
|
+
idAutoHint: "leave empty to auto-generate",
|
|
140
|
+
auditTitle: "Audit (last {n})",
|
|
141
|
+
auditEmpty: "No audit entries for this project",
|
|
142
|
+
auditTime: "Time",
|
|
143
|
+
auditAction: "Action",
|
|
144
|
+
auditConn: "Conn",
|
|
145
|
+
auditDetail: "Detail",
|
|
146
|
+
auditDenied: "denied",
|
|
147
|
+
auditConfirmed: "confirmed",
|
|
148
|
+
grantsHint: "Grant connections to the current project. ro = read-only (query/browse), rw = read-write (execute/script allowed). Unauthorized connections are always rejected.",
|
|
149
|
+
modeNone: "none",
|
|
150
|
+
modeRo: "read-only ro",
|
|
151
|
+
modeRw: "read-write rw",
|
|
152
|
+
noDatabases: "No databases available",
|
|
153
|
+
noTables: "No tables",
|
|
154
|
+
loadFailed: "Failed to load",
|
|
155
|
+
structure: "Structure",
|
|
156
|
+
preview: "Preview",
|
|
157
|
+
column: "Column",
|
|
158
|
+
dataType: "Type",
|
|
159
|
+
nullable: "Nullable",
|
|
160
|
+
keyCol: "Key",
|
|
161
|
+
defaultVal: "Default",
|
|
162
|
+
comment: "Comment",
|
|
163
|
+
nextPage: "Next",
|
|
164
|
+
prevPage: "Prev",
|
|
165
|
+
pageInfo: "Page {page} (50 rows/page)",
|
|
166
|
+
previewTruncated: "Result truncated",
|
|
167
|
+
modeQuery: "query (read-only)",
|
|
168
|
+
modeExecute: "execute (write)",
|
|
169
|
+
modeScript: "script",
|
|
170
|
+
run: "Run",
|
|
171
|
+
running: "Running…",
|
|
172
|
+
sqlPlaceholder: "SQL / command… (Redis: [\"GET\",\"key\"]; Mongo: {\"find\":\"users\",\"filter\":{}})",
|
|
173
|
+
scriptPlaceholder: "JS script (vm sandbox, with db helper)…",
|
|
174
|
+
paramsJson: "params (JSON array, optional)",
|
|
175
|
+
rowsResult: "{n} rows",
|
|
176
|
+
execResult: "{n} rows affected",
|
|
177
|
+
needConfirmTitle: "Dangerous operation",
|
|
178
|
+
dangerLevel: "Danger",
|
|
179
|
+
reason: "Reason",
|
|
180
|
+
confirmRun: "Confirm & run",
|
|
181
|
+
confirmCancel: "Cancel",
|
|
182
|
+
confirmHint: "The server flagged this statement as dangerous; it only runs after manual confirmation (challenge valid for 5 minutes).",
|
|
183
|
+
cancelled: "Cancelled",
|
|
184
|
+
error: "Error",
|
|
185
|
+
ok: "Done",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
let activeLocale = "zh";
|
|
189
|
+
function attachLocale(locale) {
|
|
190
|
+
try {
|
|
191
|
+
activeLocale = (locale && locale.getSnapshot && locale.getSnapshot().active) || "zh";
|
|
192
|
+
} catch (e) { /* 保持默认 */ }
|
|
193
|
+
}
|
|
194
|
+
function t(key, params) {
|
|
195
|
+
let text = (activeLocale === "en" ? en[key] : undefined) || zh[key] || en[key] || key;
|
|
196
|
+
if (params !== undefined && params !== null) {
|
|
197
|
+
for (const [name, value] of Object.entries(params)) {
|
|
198
|
+
text = String(text).split("{" + name + "}").join(String(value));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const TAB_ID = "dsh-db-tool";
|
|
205
|
+
const API = "/dsh-db-tool/api";
|
|
206
|
+
const DB_KINDS = ["mysql", "postgresql", "gaussdb", "sqlite", "redis", "mongodb", "oracle", "dmdb"];
|
|
207
|
+
const PAGE_SIZE = 50; // 契约:preview limit≤50 服务端强制
|
|
208
|
+
|
|
209
|
+
// --- HTTP 封装:保留 code / challengeId 供确认通道使用 ---
|
|
210
|
+
async function api(path, opts) {
|
|
211
|
+
opts = opts || {};
|
|
212
|
+
const method = opts.method || "GET";
|
|
213
|
+
const init = { method, headers: {} };
|
|
214
|
+
if (opts.body !== undefined) {
|
|
215
|
+
init.headers["content-type"] = "application/json";
|
|
216
|
+
init.body = JSON.stringify(opts.body);
|
|
217
|
+
}
|
|
218
|
+
const response = await fetch(API + "/" + path, init);
|
|
219
|
+
const text = await response.text();
|
|
220
|
+
let data;
|
|
221
|
+
try { data = text ? JSON.parse(text) : {}; }
|
|
222
|
+
catch (e) { throw new Error("bad JSON (" + response.status + "): " + text.slice(0, 200)); }
|
|
223
|
+
if (data && data.ok) return data.data;
|
|
224
|
+
const err = new Error(String((data && data.error) || ("HTTP " + response.status)));
|
|
225
|
+
err.code = data && data.code;
|
|
226
|
+
if (data && data.challengeId) {
|
|
227
|
+
err.challengeId = data.challengeId;
|
|
228
|
+
err.statement = data.statement;
|
|
229
|
+
err.danger = data.danger;
|
|
230
|
+
err.reason = data.reason;
|
|
231
|
+
}
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
function qs(params) {
|
|
235
|
+
const u = new URLSearchParams();
|
|
236
|
+
for (const [k, v] of Object.entries(params)) {
|
|
237
|
+
if (v !== undefined && v !== null && v !== "") u.set(k, String(v));
|
|
238
|
+
}
|
|
239
|
+
const s = u.toString();
|
|
240
|
+
return s ? "?" + s : "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- 危险操作确认通道:NEEDS_CONFIRMATION → 对话框 → 带 challengeId 重发 ---
|
|
244
|
+
async function runGuarded(send, askConfirm) {
|
|
245
|
+
try {
|
|
246
|
+
return await send(undefined);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
if (e && e.code === "NEEDS_CONFIRMATION" && e.challengeId) {
|
|
249
|
+
const yes = await askConfirm({ statement: e.statement, danger: e.danger, reason: e.reason });
|
|
250
|
+
if (!yes) { const err = new Error(t("cancelled")); err.cancelled = true; throw err; }
|
|
251
|
+
return await send(e.challengeId);
|
|
252
|
+
}
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function icon(size) {
|
|
258
|
+
const s = size || 16;
|
|
259
|
+
return React.createElement(
|
|
260
|
+
"svg",
|
|
261
|
+
{ width: s, height: s, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.8" },
|
|
262
|
+
React.createElement("ellipse", { cx: "12", cy: "5.5", rx: "8", ry: "3" }),
|
|
263
|
+
React.createElement("path", { d: "M4 5.5v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" }),
|
|
264
|
+
React.createElement("path", { d: "M4 11.5v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" }),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let styleEl = null;
|
|
269
|
+
function ensureStyles() {
|
|
270
|
+
if (styleEl) return;
|
|
271
|
+
styleEl = document.createElement("style");
|
|
272
|
+
styleEl.textContent = [
|
|
273
|
+
"/* ===== Apple-style Design Tokens(dark 基线,light 经 media query 反转) ===== */",
|
|
274
|
+
".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;}",
|
|
275
|
+
"@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);}}",
|
|
276
|
+
"/* ===== 根容器 ===== */",
|
|
277
|
+
".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;}",
|
|
278
|
+
"/* ===== iOS segmented control(顶部 tab) ===== */",
|
|
279
|
+
".dbt-tabs{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
|
|
280
|
+
".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;}",
|
|
281
|
+
".dbt-tabs button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
282
|
+
"/* ===== inset grouped 卡片 ===== */",
|
|
283
|
+
".dbt-card{background:var(--dbt-surface);border:none;border-radius:var(--dbt-radius-card);padding:12px;display:flex;flex-direction:column;gap:10px;}",
|
|
284
|
+
".dbt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}",
|
|
285
|
+
"/* ===== 填充式无边框输入 ===== */",
|
|
286
|
+
".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:6px 10px;font-size:13px;font-family:inherit;transition:all .18s var(--dbt-ease);}",
|
|
287
|
+
".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;}",
|
|
288
|
+
".dbt-card textarea{font-family:var(--dbt-mono);min-height:96px;resize:vertical;}",
|
|
289
|
+
"/* ===== 按钮 ===== */",
|
|
290
|
+
".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);}",
|
|
291
|
+
".dbt-btn:hover{background:var(--dbt-surface-strong);}",
|
|
292
|
+
".dbt-btn:active{transform:scale(.97);}",
|
|
293
|
+
".dbt-btn.primary{background:var(--dbt-accent);color:#fff;}",
|
|
294
|
+
".dbt-btn.primary:hover{filter:brightness(1.1);}",
|
|
295
|
+
".dbt-btn.danger{color:var(--dbt-danger);background:transparent;border:1px solid var(--dbt-danger);}",
|
|
296
|
+
".dbt-btn.danger:hover{background:rgba(255,69,58,.12);}",
|
|
297
|
+
".dbt-btn:disabled{opacity:.4;cursor:default;transform:none;}",
|
|
298
|
+
"/* ===== 文本反馈 ===== */",
|
|
299
|
+
".dbt-muted{color:var(--dbt-text-secondary);font-size:11px;}",
|
|
300
|
+
".dbt-err{color:var(--dbt-danger);font-size:12px;white-space:pre-wrap;}",
|
|
301
|
+
".dbt-msg{color:var(--dbt-success);font-size:12px;white-space:pre-wrap;}",
|
|
302
|
+
"/* ===== 行 hairline 表格 + 毛玻璃 sticky 表头 ===== */",
|
|
303
|
+
".dbt-table{width:100%;border-collapse:collapse;font-size:12px;}",
|
|
304
|
+
".dbt-table th,.dbt-table td{border:none;border-bottom:1px solid var(--dbt-separator);padding:6px 8px;text-align:left;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
305
|
+
".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:11px;}",
|
|
306
|
+
".dbt-table td{font-family:var(--dbt-mono);}",
|
|
307
|
+
".dbt-tablewrap{border:none;border-radius:var(--dbt-radius-card);overflow:auto;max-height:320px;background:var(--dbt-surface);}",
|
|
308
|
+
"/* ===== 对话框(macOS alert 材质) ===== */",
|
|
309
|
+
".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;}",
|
|
310
|
+
".dbt-dialog{background:var(--dbt-dialog-bg);color:inherit;border:1px solid var(--dbt-separator);border-radius:14px;padding:16px;max-width:460px;width:90%;box-sizing:border-box;display:flex;flex-direction:column;gap:10px;font-size:13px;-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);box-shadow:var(--dbt-shadow);}",
|
|
311
|
+
".dbt-dialog pre{background:var(--dbt-surface-strong);border-radius:var(--dbt-radius-pill);padding:8px;font-family:var(--dbt-mono);font-size:12px;white-space:pre-wrap;word-break:break-all;max-height:160px;overflow:auto;}",
|
|
312
|
+
".dbt-dialog .dbt-row{justify-content:flex-end;}",
|
|
313
|
+
"/* ===== 授权/连接列表:inset grouped(一张卡多行 hairline) ===== */",
|
|
314
|
+
".dbt-group{background:var(--dbt-surface);border-radius:var(--dbt-radius-card);overflow:hidden;display:flex;flex-direction:column;}",
|
|
315
|
+
".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);}",
|
|
316
|
+
".dbt-listrow:last-child{border-bottom:none;}",
|
|
317
|
+
".dbt-listrow:hover{background:var(--dbt-surface-strong);}",
|
|
318
|
+
".dbt-tree{display:flex;flex-direction:column;}",
|
|
319
|
+
"/* ===== Navicat 式对象树(数据浏览) ===== */",
|
|
320
|
+
".dbt-treerow{display:flex;gap:6px;align-items:center;padding:7px 12px;font-size:13px;cursor:pointer;user-select:none;border-bottom:1px solid var(--dbt-separator);transition:background .15s var(--dbt-ease);}",
|
|
321
|
+
".dbt-treerow:last-child{border-bottom:none;}",
|
|
322
|
+
".dbt-treerow:hover{background:var(--dbt-surface-strong);}",
|
|
323
|
+
".dbt-treerow.active{color:var(--dbt-accent);font-weight:600;}",
|
|
324
|
+
".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);}",
|
|
325
|
+
".dbt-chev.open{transform:rotate(90deg);}",
|
|
326
|
+
".dbt-chev.leaf{visibility:hidden;}",
|
|
327
|
+
".dbt-treename{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
328
|
+
"/* ===== 视图切换入场(仅 transform/opacity;reduced-motion 全局已禁) ===== */",
|
|
329
|
+
"@keyframes dbt-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}",
|
|
330
|
+
".dbt-view{display:flex;flex-direction:column;gap:10px;animation:dbt-in .22s var(--dbt-ease);}",
|
|
331
|
+
"/* ===== mini segmented(ro/rw 等切换) ===== */",
|
|
332
|
+
".dbt-seg{display:flex;gap:2px;background:var(--dbt-surface);border-radius:var(--dbt-radius-ctrl);padding:2px;}",
|
|
333
|
+
".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);}",
|
|
334
|
+
".dbt-seg button.active{background:var(--dbt-seg-active);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.12);}",
|
|
335
|
+
"/* ===== 降级:透明度减弱 / 动效减弱 ===== */",
|
|
336
|
+
"@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;}}",
|
|
337
|
+
"@media (prefers-color-scheme: light) and (prefers-reduced-transparency: reduce){.dbt-dialog{background:#f5f5f7;}.dbt-table th{background:#f2f2f7;}}",
|
|
338
|
+
"@media (prefers-reduced-motion: reduce){.dbt-panel *{transition:none!important;animation:none!important;}.dbt-btn:active{transform:none;}}",
|
|
339
|
+
].join("\n");
|
|
340
|
+
document.head.appendChild(styleEl);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function DangerDialog(props) {
|
|
344
|
+
const ch = props.challenge; // {statement, danger, reason, resolve}
|
|
345
|
+
function done(v) { props.onClose(); ch.resolve(v); }
|
|
346
|
+
return React.createElement(
|
|
347
|
+
"div",
|
|
348
|
+
{ className: "dbt-overlay", onClick: () => done(false) },
|
|
349
|
+
React.createElement(
|
|
350
|
+
"div",
|
|
351
|
+
{ className: "dbt-dialog", onClick: (e) => e.stopPropagation() },
|
|
352
|
+
// macOS alert 风格:纯文字标题 + danger 色,不用 emoji
|
|
353
|
+
React.createElement("strong", { style: { color: "var(--dbt-danger, #ff453a)" } }, t("needConfirmTitle")),
|
|
354
|
+
React.createElement("pre", null, ch.statement || "(?)"),
|
|
355
|
+
React.createElement(
|
|
356
|
+
"div",
|
|
357
|
+
{ className: "dbt-muted" },
|
|
358
|
+
t("dangerLevel") + ": " + (ch.danger || "danger") + " · " + t("reason") + ": " + (ch.reason || "-"),
|
|
359
|
+
),
|
|
360
|
+
React.createElement("div", { className: "dbt-muted" }, t("confirmHint")),
|
|
361
|
+
React.createElement(
|
|
362
|
+
"div",
|
|
363
|
+
{ className: "dbt-row", style: { justifyContent: "flex-end" } },
|
|
364
|
+
React.createElement("button", { className: "dbt-btn danger", onClick: () => done(true) }, t("confirmRun")),
|
|
365
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => done(false) }, t("confirmCancel")),
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ---------------- 连接管理 ---------------- */
|
|
372
|
+
const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", host: "", port: "", user: "", password: "", database: "", ssl: false };
|
|
373
|
+
|
|
374
|
+
// 通用表格渲染:columns + rows(审计列表与查询结果共用)
|
|
375
|
+
function resultTable(columns, rows, cellTitles) {
|
|
376
|
+
return React.createElement(
|
|
377
|
+
"div",
|
|
378
|
+
{ className: "dbt-tablewrap" },
|
|
379
|
+
React.createElement(
|
|
380
|
+
"table",
|
|
381
|
+
{ className: "dbt-table" },
|
|
382
|
+
React.createElement(
|
|
383
|
+
"thead",
|
|
384
|
+
null,
|
|
385
|
+
React.createElement("tr", null, columns.map((c, i) => React.createElement("th", { key: i }, c))),
|
|
386
|
+
),
|
|
387
|
+
React.createElement(
|
|
388
|
+
"tbody",
|
|
389
|
+
null,
|
|
390
|
+
rows.map((row, i) =>
|
|
391
|
+
React.createElement("tr", { key: i },
|
|
392
|
+
row.map((cell, j) =>
|
|
393
|
+
React.createElement("td", { key: j, title: cellTitles ? cellTitles(row, cell, j) : cell === null ? "NULL" : String(cell) },
|
|
394
|
+
// NULL 用 muted 弱化;其余保持 String(cell) 渲染语义不变
|
|
395
|
+
cell === null ? React.createElement("span", { className: "dbt-muted" }, "NULL") : String(cell)))),
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function auditTable(audit) {
|
|
403
|
+
return resultTable(
|
|
404
|
+
[t("auditTime"), t("auditAction"), t("auditConn"), t("auditDetail")],
|
|
405
|
+
audit.map((a) => [
|
|
406
|
+
a.ts || "",
|
|
407
|
+
(a.action || "") +
|
|
408
|
+
(a.ok === false ? "(" + t("auditDenied") + (a.error ? ":" + a.error : "") + ")" : "") +
|
|
409
|
+
(a.confirmed ? "(" + t("auditConfirmed") + ")" : ""),
|
|
410
|
+
a.connId || "",
|
|
411
|
+
a.statement || "",
|
|
412
|
+
]),
|
|
413
|
+
// statement 全文进 td:视觉截断由 .dbt-table td 的 ellipsis 承担,title 悬浮看全文
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 各数据库官方默认连接参数(分字段方式自动填充;用户仍可改)
|
|
418
|
+
const KIND_DEFAULTS = {
|
|
419
|
+
mysql: { host: "127.0.0.1", port: 3306, user: "root" },
|
|
420
|
+
postgresql: { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" },
|
|
421
|
+
gaussdb: { host: "127.0.0.1", port: 8000, user: "gaussdb", database: "postgres" },
|
|
422
|
+
sqlite: {},
|
|
423
|
+
redis: { host: "127.0.0.1", port: 6379 },
|
|
424
|
+
mongodb: { host: "127.0.0.1", port: 27017, database: "test" },
|
|
425
|
+
oracle: { host: "127.0.0.1", port: 1521, user: "system" },
|
|
426
|
+
dmdb: { host: "127.0.0.1", port: 5236, user: "SYSDBA" },
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 按当前 kind 重填官方默认值。语义:用户手动改过的字段(dirty)保留,
|
|
431
|
+
* 其余字段重置为该库默认值;该库无此字段时清空(如 sqlite 无 host/port)。
|
|
432
|
+
*/
|
|
433
|
+
function withKindDefaults(form, dirty) {
|
|
434
|
+
const def = KIND_DEFAULTS[form.kind] || {};
|
|
435
|
+
const next = Object.assign({}, form);
|
|
436
|
+
for (const k of ["host", "port", "user", "database"]) {
|
|
437
|
+
if (dirty && dirty.has(k)) continue; // 手改过 → 保留
|
|
438
|
+
next[k] = def[k] !== undefined ? String(def[k]) : "";
|
|
439
|
+
}
|
|
440
|
+
return next;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 新建表单初始态:分字段模式 + 当前 kind 的官方默认值 */
|
|
444
|
+
const freshForm = () => withKindDefaults({ ...EMPTY_FORM, mode: "fields" });
|
|
445
|
+
|
|
446
|
+
function ConnForm(props) {
|
|
447
|
+
// 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
|
|
448
|
+
const [form, setForm] = React.useState(() => props.initial || freshForm());
|
|
449
|
+
const [dirty, setDirty] = React.useState(() => new Set()); // 用户手改过的字段(切 kind 时保留)
|
|
450
|
+
const [draftTest, setDraftTest] = React.useState(null); // null | {ok, msg}
|
|
451
|
+
function patch(p) {
|
|
452
|
+
setForm((prev) => Object.assign({}, prev, p));
|
|
453
|
+
setDirty((prev) => { const n = new Set(prev); for (const k of Object.keys(p)) n.add(k); return n; });
|
|
454
|
+
}
|
|
455
|
+
function switchKind(kind) {
|
|
456
|
+
setForm((prev) => withKindDefaults(Object.assign({}, prev, { kind }), dirty));
|
|
457
|
+
setDirty(new Set());
|
|
458
|
+
setDraftTest(null);
|
|
459
|
+
}
|
|
460
|
+
/** 从 URL 模式切回分字段:按当前 kind 重新补全官方默认值(未手改字段) */
|
|
461
|
+
function reenterFields() {
|
|
462
|
+
setForm((prev) => withKindDefaults(Object.assign({}, prev, { mode: "fields" }), dirty));
|
|
463
|
+
setDraftTest(null);
|
|
464
|
+
}
|
|
465
|
+
function draftBody() {
|
|
466
|
+
const body = { kind: form.kind, ssl: !!form.ssl };
|
|
467
|
+
if (form.mode === "url") body.url = form.url;
|
|
468
|
+
else {
|
|
469
|
+
body.fields = { host: form.host, user: form.user, database: form.database || undefined };
|
|
470
|
+
if (form.port) body.fields.port = Number(form.port);
|
|
471
|
+
if (form.password) body.fields.password = form.password;
|
|
472
|
+
}
|
|
473
|
+
return body;
|
|
474
|
+
}
|
|
475
|
+
function submit() {
|
|
476
|
+
const body = Object.assign(draftBody(), { id: form.id || undefined, name: form.name || undefined });
|
|
477
|
+
props.onSubmit(body, () => setForm(freshForm()));
|
|
478
|
+
}
|
|
479
|
+
async function testDraft() {
|
|
480
|
+
setDraftTest({ ok: null, msg: t("testing") });
|
|
481
|
+
try {
|
|
482
|
+
const r = await api("test-draft", { method: "POST", body: draftBody() });
|
|
483
|
+
setDraftTest(r && r.ok ? { ok: true, msg: t("testOk") + (r.serverInfo ? " · " + r.serverInfo : "") } : { ok: false, msg: t("testFail") + (r && r.error ? ":" + r.error : "") });
|
|
484
|
+
} catch (e) {
|
|
485
|
+
setDraftTest({ ok: false, msg: t("testFail") + ":" + (e && e.message ? e.message : String(e)) });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return React.createElement(
|
|
489
|
+
"div",
|
|
490
|
+
{ className: "dbt-card" },
|
|
491
|
+
React.createElement("strong", null, props.initial ? t("editConn") : t("newConn")),
|
|
492
|
+
// 分组 1:基本属性(kind / name / id / ssl)
|
|
493
|
+
React.createElement(
|
|
494
|
+
"div",
|
|
495
|
+
{ className: "dbt-row" },
|
|
496
|
+
React.createElement("select", { value: form.kind, onChange: (e) => switchKind(e.target.value) },
|
|
497
|
+
DB_KINDS.map((k) => React.createElement("option", { key: k, value: k }, k))),
|
|
498
|
+
React.createElement("input", { placeholder: t("connName"), value: form.name, onChange: (e) => patch({ name: e.target.value }) }),
|
|
499
|
+
React.createElement("input", { placeholder: t("connId") + "(" + t("idAutoHint") + ")", value: form.id, disabled: !!props.initial, onChange: (e) => patch({ id: e.target.value }) }),
|
|
500
|
+
React.createElement("label", { className: "dbt-row", style: { flex: "none" } },
|
|
501
|
+
React.createElement("input", { type: "checkbox", checked: !!form.ssl, onChange: (e) => patch({ ssl: e.target.checked }) }), t("ssl")),
|
|
502
|
+
),
|
|
503
|
+
// 分组 2:连接目标 —— 模式切换用 .dbt-seg mini segmented
|
|
504
|
+
React.createElement(
|
|
505
|
+
"div",
|
|
506
|
+
{ className: "dbt-seg" },
|
|
507
|
+
React.createElement("button", { className: form.mode === "url" ? "active" : "", onClick: () => patch({ mode: "url" }) }, t("urlMode")),
|
|
508
|
+
React.createElement("button", { className: form.mode === "fields" ? "active" : "", onClick: reenterFields }, t("fieldsMode")),
|
|
509
|
+
),
|
|
510
|
+
form.mode === "url"
|
|
511
|
+
? React.createElement("input", { placeholder: t("url"), value: form.url, onChange: (e) => patch({ url: e.target.value }) })
|
|
512
|
+
: React.createElement(
|
|
513
|
+
"div",
|
|
514
|
+
{ style: { display: "flex", flexDirection: "column", gap: 6 } },
|
|
515
|
+
React.createElement("div", { className: "dbt-row" },
|
|
516
|
+
React.createElement("input", { placeholder: t("fieldsHost"), value: form.host, onChange: (e) => patch({ host: e.target.value }) }),
|
|
517
|
+
React.createElement("input", { placeholder: t("fieldsPort"), value: form.port, onChange: (e) => patch({ port: e.target.value }) }),
|
|
518
|
+
React.createElement("input", { placeholder: t("fieldsDatabase"), value: form.database, onChange: (e) => patch({ database: e.target.value }) })),
|
|
519
|
+
React.createElement("div", { className: "dbt-row" },
|
|
520
|
+
React.createElement("input", { placeholder: t("fieldsUser"), value: form.user, onChange: (e) => patch({ user: e.target.value }) }),
|
|
521
|
+
React.createElement("input", { type: "password", placeholder: t("fieldsPassword"), value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
|
|
522
|
+
),
|
|
523
|
+
// 分组 3:操作
|
|
524
|
+
React.createElement(
|
|
525
|
+
"div",
|
|
526
|
+
{ className: "dbt-row" },
|
|
527
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: props.busy, onClick: submit }, props.busy ? t("saving") : t("save")),
|
|
528
|
+
React.createElement("button", { className: "dbt-btn", disabled: draftTest && draftTest.ok === null, onClick: testDraft },
|
|
529
|
+
draftTest && draftTest.ok === null ? t("testing") : t("test")),
|
|
530
|
+
React.createElement("button", { className: "dbt-btn", onClick: props.onCancel }, t("cancel")),
|
|
531
|
+
draftTest && draftTest.ok !== null
|
|
532
|
+
? React.createElement("span", { style: { color: draftTest.ok ? "var(--dbt-success, #30d158)" : "var(--dbt-danger, #ff453a)", fontSize: 12, alignSelf: "center" } }, draftTest.msg)
|
|
533
|
+
: null,
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ManageView(props) {
|
|
539
|
+
const { conns, busy, reload } = props;
|
|
540
|
+
const [editing, setEditing] = React.useState(null); // null | "new" | ConnectionMeta
|
|
541
|
+
const [auditOpen, setAuditOpen] = React.useState(false);
|
|
542
|
+
const [audit, setAudit] = React.useState(null);
|
|
543
|
+
const [testInfo, setTestInfo] = React.useState({}); // connId -> "ok" | "fail: msg"
|
|
544
|
+
|
|
545
|
+
async function saveConn(body, reset) {
|
|
546
|
+
await props.run("save", async () => {
|
|
547
|
+
if (editing && editing !== "new") {
|
|
548
|
+
await api("connections/" + encodeURIComponent(body.id || editing.id), { method: "PUT", body });
|
|
549
|
+
} else {
|
|
550
|
+
await api("connections", { method: "POST", body });
|
|
551
|
+
}
|
|
552
|
+
reset();
|
|
553
|
+
setEditing(null);
|
|
554
|
+
await reload();
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
async function testConn(id) {
|
|
558
|
+
setTestInfo((prev) => Object.assign({}, prev, { [id]: t("testing") }));
|
|
559
|
+
try {
|
|
560
|
+
const r = await api("connections/" + encodeURIComponent(id) + "/test", { method: "POST", body: {} });
|
|
561
|
+
setTestInfo((prev) => Object.assign({}, prev, { [id]: r && r.ok ? "✓ " + t("testOk") + (r.serverInfo ? " · " + r.serverInfo : "") : "✗ " + t("testFail") }));
|
|
562
|
+
} catch (e) {
|
|
563
|
+
setTestInfo((prev) => Object.assign({}, prev, { [id]: "✗ " + t("testFail") + ": " + e.message }));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function delConn(c) {
|
|
567
|
+
if (!window.confirm(t("deleteConnConfirm", { name: c.name || c.id }))) return;
|
|
568
|
+
await props.run("del", async () => {
|
|
569
|
+
await api("connections/" + encodeURIComponent(c.id), { method: "DELETE" });
|
|
570
|
+
await reload();
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
async function loadAudit() {
|
|
574
|
+
const open = !auditOpen;
|
|
575
|
+
setAuditOpen(open);
|
|
576
|
+
if (open) {
|
|
577
|
+
try { setAudit(await api("audit" + qs({ project: props.projectPath, limit: 50 }))); }
|
|
578
|
+
catch (e) { props.onError(e); }
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (editing) {
|
|
583
|
+
return React.createElement(ConnForm, {
|
|
584
|
+
initial: editing === "new" ? null : Object.assign({}, EMPTY_FORM, editing),
|
|
585
|
+
busy: busy === "save",
|
|
586
|
+
onSubmit: saveConn,
|
|
587
|
+
onCancel: () => setEditing(null),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return React.createElement(
|
|
591
|
+
"div",
|
|
592
|
+
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
593
|
+
// 顶部操作行:审计在左,「新建连接」主按钮置右侧
|
|
594
|
+
React.createElement(
|
|
595
|
+
"div",
|
|
596
|
+
{ className: "dbt-row", style: { justifyContent: "space-between" } },
|
|
597
|
+
React.createElement("button", { className: "dbt-btn", onClick: loadAudit }, t("auditTitle", { n: 50 }).split("(")[0].split(" (")[0]),
|
|
598
|
+
React.createElement("button", { className: "dbt-btn primary", onClick: () => setEditing("new") }, t("newConn")),
|
|
599
|
+
),
|
|
600
|
+
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
601
|
+
// iOS inset grouped:一张卡装全部连接,行间 hairline 分隔
|
|
602
|
+
React.createElement(
|
|
603
|
+
"div",
|
|
604
|
+
{ className: "dbt-group" },
|
|
605
|
+
conns.map((c) =>
|
|
606
|
+
React.createElement(
|
|
607
|
+
"div",
|
|
608
|
+
{ className: "dbt-listrow", key: c.id },
|
|
609
|
+
// 左列:kind 徽标 pill + 名称主行,safeUrl 等宽副行
|
|
610
|
+
React.createElement(
|
|
611
|
+
"div",
|
|
612
|
+
{ style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 } },
|
|
613
|
+
React.createElement(
|
|
614
|
+
"div",
|
|
615
|
+
{ className: "dbt-row" },
|
|
616
|
+
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),
|
|
617
|
+
React.createElement("strong", { style: { fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
|
|
618
|
+
),
|
|
619
|
+
(c.safeUrl || c.host) ? React.createElement(
|
|
620
|
+
"div",
|
|
621
|
+
{ className: "dbt-muted", style: { fontFamily: MONO_FONT, fontSize: 11, wordBreak: "break-all" } },
|
|
622
|
+
c.safeUrl || (c.host + ":" + (c.port || "")),
|
|
623
|
+
) : null,
|
|
624
|
+
testInfo[c.id] ? React.createElement("div", { className: "dbt-muted" }, testInfo[c.id]) : null,
|
|
625
|
+
),
|
|
626
|
+
// 右列:动作按钮(次要语义,danger 仅删除)
|
|
627
|
+
React.createElement(
|
|
628
|
+
"div",
|
|
629
|
+
{ className: "dbt-row", style: { flex: "none" } },
|
|
630
|
+
React.createElement("button", { className: "dbt-btn", disabled: busy === "test", onClick: () => testConn(c.id) }, t("test")),
|
|
631
|
+
React.createElement("button", { className: "dbt-btn", onClick: () => setEditing(c) }, t("edit")),
|
|
632
|
+
React.createElement("button", { className: "dbt-btn danger", disabled: busy === "del", onClick: () => delConn(c) }, t("delete")),
|
|
633
|
+
),
|
|
634
|
+
),
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
auditOpen
|
|
638
|
+
? React.createElement(
|
|
639
|
+
"div",
|
|
640
|
+
{ className: "dbt-card" },
|
|
641
|
+
React.createElement("strong", null, t("auditTitle", { n: 50 })),
|
|
642
|
+
!props.projectPath ? React.createElement("div", { className: "dbt-muted" }, t("projectUnbound")) :
|
|
643
|
+
!audit || audit.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("auditEmpty")) :
|
|
644
|
+
auditTable(audit),
|
|
645
|
+
)
|
|
646
|
+
: null,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/* ---------------- 项目授权 ---------------- */
|
|
651
|
+
function GrantsView(props) {
|
|
652
|
+
const { conns, grants, projectPath } = props;
|
|
653
|
+
function modeOf(connId) {
|
|
654
|
+
const g = (grants || []).find((x) => x.connId === connId);
|
|
655
|
+
return g ? g.mode : "";
|
|
656
|
+
}
|
|
657
|
+
async function setGrant(connId, mode) {
|
|
658
|
+
await props.run("grant", async () => {
|
|
659
|
+
if (!mode) await api("grants", { method: "DELETE", body: { projectPath, connId } });
|
|
660
|
+
else await api("grants", { method: "PUT", body: { projectPath, connId, mode } });
|
|
661
|
+
await props.reload();
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return React.createElement(
|
|
665
|
+
"div",
|
|
666
|
+
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
667
|
+
React.createElement("div", { className: "dbt-muted" }, t("grantsHint")),
|
|
668
|
+
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
669
|
+
// iOS inset grouped:授权行装一张卡,行间 hairline(授权链路:seg 点击 → PUT/DELETE /grants)
|
|
670
|
+
React.createElement(
|
|
671
|
+
"div",
|
|
672
|
+
{ className: "dbt-group" },
|
|
673
|
+
conns.map((c) => {
|
|
674
|
+
const m = modeOf(c.id);
|
|
675
|
+
return React.createElement(
|
|
676
|
+
"div",
|
|
677
|
+
{ className: "dbt-listrow", key: c.id },
|
|
678
|
+
// 列表行式:名称 13px semibold 主行 + kind muted 副行,右侧 mini segmented
|
|
679
|
+
React.createElement(
|
|
680
|
+
"div",
|
|
681
|
+
{ style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 } },
|
|
682
|
+
React.createElement("span", { style: { fontWeight: 600, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name || c.id),
|
|
683
|
+
React.createElement("span", { className: "dbt-muted" }, c.kind),
|
|
684
|
+
),
|
|
685
|
+
React.createElement(
|
|
686
|
+
"div",
|
|
687
|
+
{ className: "dbt-seg", "aria-label": c.name || c.id },
|
|
688
|
+
["", "ro", "rw"].map((mode) =>
|
|
689
|
+
React.createElement("button", {
|
|
690
|
+
key: mode || "none",
|
|
691
|
+
className: mode === m ? "active" : "",
|
|
692
|
+
disabled: props.busy === "grant",
|
|
693
|
+
onClick: () => setGrant(c.id, mode),
|
|
694
|
+
}, mode === "" ? t("modeNone") : mode === "ro" ? t("modeRo") : t("modeRw")),
|
|
695
|
+
),
|
|
696
|
+
),
|
|
697
|
+
);
|
|
698
|
+
}),
|
|
699
|
+
),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/* ---------------- 数据浏览 ---------------- */
|
|
704
|
+
function BrowseView(props) {
|
|
705
|
+
const { conns, projectPath } = props;
|
|
706
|
+
// Navicat 式对象树:连接 ▸ 库/schema ▸ 表,懒加载展开
|
|
707
|
+
const [open, setOpen] = React.useState({}); // "c:<id>" | "d:<id>/<db>" -> bool
|
|
708
|
+
const [loading, setLoading] = React.useState({});
|
|
709
|
+
const [error, setError] = React.useState({}); // 树节点加载失败信息(就地显示,可点重试)
|
|
710
|
+
const [dbs, setDbs] = React.useState({}); // connId -> string[]
|
|
711
|
+
const [tablesMap, setTablesMap] = React.useState({}); // "<connId>/<db>" -> TableInfo[]
|
|
712
|
+
const [sel, setSel] = React.useState(null); // {connId, db, table: TableInfo}
|
|
713
|
+
const [schema, setSchema] = React.useState([]);
|
|
714
|
+
const [preview, setPreview] = React.useState(null); // QueryResult
|
|
715
|
+
const [page, setPage] = React.useState(1);
|
|
716
|
+
const [busy, setBusy] = React.useState("");
|
|
717
|
+
const [view, setView] = React.useState("structure"); // structure | preview(纯视图切换,不影响数据加载)
|
|
718
|
+
|
|
719
|
+
// 会话项目切换 / 连接列表变化时清空树缓存,避免陈旧授权下的旧数据
|
|
720
|
+
React.useEffect(() => {
|
|
721
|
+
setOpen({}); setLoading({}); setError({}); setDbs({}); setTablesMap({}); setSel(null); setSchema([]); setPreview(null);
|
|
722
|
+
}, [projectPath]);
|
|
723
|
+
|
|
724
|
+
function toggle(key, load) {
|
|
725
|
+
const isOpen = !!open[key];
|
|
726
|
+
setOpen((o) => Object.assign({}, o, { [key]: !isOpen }));
|
|
727
|
+
if (isOpen || !load || loading[key]) return;
|
|
728
|
+
// 加载失败不弹全局错误:就地记 error[key],树内显示可重试的错误行
|
|
729
|
+
setError((s) => Object.assign({}, s, { [key]: undefined }));
|
|
730
|
+
setLoading((s) => Object.assign({}, s, { [key]: true }));
|
|
731
|
+
load()
|
|
732
|
+
.catch((e) => setError((s) => Object.assign({}, s, { [key]: e && e.message ? e.message : String(e) })))
|
|
733
|
+
.finally(() => setLoading((s) => Object.assign({}, s, { [key]: false })));
|
|
734
|
+
}
|
|
735
|
+
function retry(key, fn) {
|
|
736
|
+
setError((s) => Object.assign({}, s, { [key]: undefined }));
|
|
737
|
+
setOpen((o) => Object.assign({}, o, { [key]: false }));
|
|
738
|
+
Promise.resolve().then(() => fn());
|
|
739
|
+
}
|
|
740
|
+
function toggleConn(c) {
|
|
741
|
+
if (!projectPath) return;
|
|
742
|
+
toggle("c:" + c.id, () =>
|
|
743
|
+
api("databases" + qs({ project: projectPath, connId: c.id })).then((list) => {
|
|
744
|
+
setDbs((m) => Object.assign({}, m, { [c.id]: list || [] }));
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
function toggleDb(c, d) {
|
|
748
|
+
if (!projectPath) return;
|
|
749
|
+
toggle("d:" + c.id + "/" + d, () =>
|
|
750
|
+
api("tables" + qs({ project: projectPath, connId: c.id, database: d })).then((list) => {
|
|
751
|
+
setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d]: list || [] }));
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
// 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
|
|
755
|
+
const openSeq = React.useRef(0);
|
|
756
|
+
const openTable = React.useCallback((s, pg) => {
|
|
757
|
+
if (!s || !projectPath) return;
|
|
758
|
+
const seq = ++openSeq.current;
|
|
759
|
+
setBusy("open");
|
|
760
|
+
Promise.all([
|
|
761
|
+
api("schema" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name })),
|
|
762
|
+
api("preview" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
|
|
763
|
+
])
|
|
764
|
+
.then(([sch, prev]) => {
|
|
765
|
+
if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
|
|
766
|
+
setSchema(sch || []); setPreview(prev); setPage(pg || 1);
|
|
767
|
+
})
|
|
768
|
+
.catch((e) => props.onError(e))
|
|
769
|
+
.finally(() => { if (seq === openSeq.current) setBusy(""); });
|
|
770
|
+
}, [projectPath]);
|
|
771
|
+
React.useEffect(() => { if (sel) openTable(sel, 1); }, [sel]); // eslint-disable-line
|
|
772
|
+
|
|
773
|
+
// 树行:chevron(▸ 展开旋转 90°)+ 名称 + 可选右侧标注
|
|
774
|
+
function treerow(key, level, isOpen, leaf, label, onClick, active, extra) {
|
|
775
|
+
return React.createElement(
|
|
776
|
+
"div",
|
|
777
|
+
{ className: "dbt-treerow" + (active ? " active" : ""), key, onClick: onClick || undefined, style: { paddingLeft: 12 + level * 16 } },
|
|
778
|
+
React.createElement("span", { className: "dbt-chev" + (isOpen ? " open" : "") + (leaf ? " leaf" : "") }, "▶"),
|
|
779
|
+
React.createElement("span", { className: "dbt-treename" }, label),
|
|
780
|
+
extra || null,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// 组装树:连接 → 库/schema → 表(懒加载缓存,未加载完成显示 …)
|
|
785
|
+
const treeRows = [];
|
|
786
|
+
for (const c of conns) {
|
|
787
|
+
const ck = "c:" + c.id;
|
|
788
|
+
const isOpen = !!open[ck];
|
|
789
|
+
treeRows.push(treerow(ck, 0, isOpen, false, c.name || c.id, () => toggleConn(c), false,
|
|
790
|
+
React.createElement("span", { className: "dbt-muted", style: { flex: "none", fontSize: 11 } }, c.kind)));
|
|
791
|
+
if (!isOpen) continue;
|
|
792
|
+
const list = dbs[c.id];
|
|
793
|
+
if (loading[ck]) { treeRows.push(treerow(ck + ":l", 1, false, true, "…")); continue; }
|
|
794
|
+
if (error[ck]) { treeRows.push(treerow(ck + ":x", 1, false, true, t("loadFailed") + ":" + error[ck], () => retry(ck, () => toggleConn(c)), false)); continue; }
|
|
795
|
+
if (!list) continue;
|
|
796
|
+
if (list.length === 0) { treeRows.push(treerow(ck + ":e", 1, false, true, t("noDatabases"))); continue; }
|
|
797
|
+
for (const d of list) {
|
|
798
|
+
const dk = "d:" + c.id + "/" + d;
|
|
799
|
+
const dOpen = !!open[dk];
|
|
800
|
+
treeRows.push(treerow(dk, 1, dOpen, false, d, () => toggleDb(c, d)));
|
|
801
|
+
if (!dOpen) continue;
|
|
802
|
+
const tkey = c.id + "/" + d;
|
|
803
|
+
const tlist = tablesMap[tkey];
|
|
804
|
+
if (loading[dk]) { treeRows.push(treerow(dk + ":l", 2, false, true, "…")); continue; }
|
|
805
|
+
if (error[dk]) { treeRows.push(treerow(dk + ":x", 2, false, true, t("loadFailed") + ":" + error[dk], () => retry(dk, () => toggleDb(c, d)))); continue; }
|
|
806
|
+
if (!tlist) continue;
|
|
807
|
+
if (tlist.length === 0) { treeRows.push(treerow(dk + ":e", 2, false, true, t("noTables"))); continue; }
|
|
808
|
+
for (const tb of tlist) {
|
|
809
|
+
const active = !!sel && sel.connId === c.id && sel.db === d && sel.table.name === tb.name;
|
|
810
|
+
treeRows.push(treerow("t:" + tkey + "/" + tb.name, 2, false, true,
|
|
811
|
+
tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
|
|
812
|
+
() => setSel({ connId: c.id, db: d, table: tb }), active));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return React.createElement(
|
|
818
|
+
"div",
|
|
819
|
+
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
820
|
+
// Navicat 式对象树:点击展开连接/库,点击表查看结构/预览
|
|
821
|
+
conns.length === 0 ? React.createElement("div", { className: "dbt-muted" }, t("noConns")) :
|
|
822
|
+
React.createElement("div", { className: "dbt-group" },
|
|
823
|
+
React.createElement("div", { className: "dbt-tree" }, treeRows)),
|
|
824
|
+
sel
|
|
825
|
+
? React.createElement(
|
|
826
|
+
"div",
|
|
827
|
+
{ style: { display: "flex", flexDirection: "column", gap: 8 } },
|
|
828
|
+
// 结构/预览:mini segmented 切换(复用现有 i18n key,不新增)
|
|
829
|
+
React.createElement(
|
|
830
|
+
"div",
|
|
831
|
+
{ className: "dbt-seg", style: { alignSelf: "flex-start" } },
|
|
832
|
+
["structure", "preview"].map((v) =>
|
|
833
|
+
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
|
|
834
|
+
),
|
|
835
|
+
React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + " / " + sel.table.name),
|
|
836
|
+
view === "structure"
|
|
837
|
+
? resultTable(
|
|
838
|
+
[t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
|
|
839
|
+
schema.map((col) => [col.name, col.dataType, col.nullable ? "YES" : "NO", col.key || "", col.default === null || col.default === undefined ? "" : String(col.default), col.comment || ""]),
|
|
840
|
+
)
|
|
841
|
+
: React.createElement(
|
|
842
|
+
React.Fragment,
|
|
843
|
+
null,
|
|
844
|
+
preview ? resultTable(preview.columns, preview.rows) : null,
|
|
845
|
+
React.createElement(
|
|
846
|
+
"div",
|
|
847
|
+
{ className: "dbt-row", style: { justifyContent: "space-between" } },
|
|
848
|
+
React.createElement("button", { className: "dbt-btn", disabled: page <= 1 || busy === "open", onClick: () => openTable(sel, page - 1) }, "‹ " + t("prevPage")),
|
|
849
|
+
React.createElement("span", { className: "dbt-muted" }, t("pageInfo", { page })),
|
|
850
|
+
React.createElement("button", { className: "dbt-btn", disabled: (preview && preview.truncated) === false || busy === "open", onClick: () => openTable(sel, page + 1) }, t("nextPage") + " ›"),
|
|
851
|
+
),
|
|
852
|
+
preview && preview.truncated ? React.createElement("div", { className: "dbt-muted" }, t("previewTruncated")) : null,
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
: null,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/* ---------------- SQL 控制台 ---------------- */
|
|
860
|
+
// Apple 等宽字体栈(规范 §1):优先样式层 --dbt-mono token,fallback 内联栈
|
|
861
|
+
const MONO_FONT = "var(--dbt-mono, ui-monospace, \"SF Mono\", Menlo, Consolas, monospace)";
|
|
862
|
+
function ConsoleView(props) {
|
|
863
|
+
const { conns, projectPath, askConfirm } = props;
|
|
864
|
+
const [connId, setConnId] = React.useState("");
|
|
865
|
+
const [mode, setMode] = React.useState("query"); // query | execute | script
|
|
866
|
+
const [sql, setSql] = React.useState("");
|
|
867
|
+
const [params, setParams] = React.useState("");
|
|
868
|
+
const [result, setResult] = React.useState(null); // {kind:'query',...}|{kind:'exec',...}
|
|
869
|
+
const [busy, setBusy] = React.useState(false);
|
|
870
|
+
|
|
871
|
+
async function run() {
|
|
872
|
+
setBusy(true);
|
|
873
|
+
setResult(null);
|
|
874
|
+
try {
|
|
875
|
+
if (mode === "script") {
|
|
876
|
+
const data = await runGuarded(
|
|
877
|
+
(challengeId) => api("script", { method: "POST", body: { projectPath, connId, code: sql, challengeId } }),
|
|
878
|
+
askConfirm,
|
|
879
|
+
);
|
|
880
|
+
setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
881
|
+
} else if (mode === "execute") {
|
|
882
|
+
let parsedParams;
|
|
883
|
+
if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
|
|
884
|
+
const data = await runGuarded(
|
|
885
|
+
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, challengeId } }),
|
|
886
|
+
askConfirm,
|
|
887
|
+
);
|
|
888
|
+
setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
889
|
+
} else {
|
|
890
|
+
let parsedParams;
|
|
891
|
+
if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
|
|
892
|
+
const data = await runGuarded(
|
|
893
|
+
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, challengeId } }),
|
|
894
|
+
askConfirm,
|
|
895
|
+
);
|
|
896
|
+
setResult({ kind: "query", data });
|
|
897
|
+
}
|
|
898
|
+
} catch (e) {
|
|
899
|
+
if (!e.cancelled) props.onError(e);
|
|
900
|
+
} finally {
|
|
901
|
+
setBusy(false);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function renderResult() {
|
|
905
|
+
if (!result) return null;
|
|
906
|
+
if (result.kind === "exec") {
|
|
907
|
+
return React.createElement("div", { className: "dbt-msg" },
|
|
908
|
+
result.message + (result.affectedRows !== undefined ? " · " + t("execResult", { n: result.affectedRows }) : ""));
|
|
909
|
+
}
|
|
910
|
+
const r = result.data;
|
|
911
|
+
return React.createElement(
|
|
912
|
+
React.Fragment,
|
|
913
|
+
null,
|
|
914
|
+
React.createElement("span", { className: "dbt-muted" }, t("rowsResult", { n: r.rowCount }) + (r.truncated ? " · " + t("previewTruncated") : "")),
|
|
915
|
+
// 公共 resultTable:新 table 契约(hairline 行/sticky 表头/等宽数据列)由样式层承担
|
|
916
|
+
resultTable(r.columns || [], r.rows || []),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
return React.createElement(
|
|
920
|
+
"div",
|
|
921
|
+
{ className: "dbt-card" },
|
|
922
|
+
// 选择器:inset grouped 每行一个控件(连接 / 模式)
|
|
923
|
+
React.createElement(
|
|
924
|
+
"div",
|
|
925
|
+
{ className: "dbt-group" },
|
|
926
|
+
React.createElement(
|
|
927
|
+
"div",
|
|
928
|
+
{ className: "dbt-listrow" },
|
|
929
|
+
React.createElement("select", { value: connId, onChange: (e) => setConnId(e.target.value) },
|
|
930
|
+
React.createElement("option", { value: "" }, t("viewManage") + "…"),
|
|
931
|
+
conns.map((c) => React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + ")"))),
|
|
932
|
+
),
|
|
933
|
+
React.createElement(
|
|
934
|
+
"div",
|
|
935
|
+
{ className: "dbt-listrow" },
|
|
936
|
+
React.createElement("select", { value: mode, onChange: (e) => setMode(e.target.value) },
|
|
937
|
+
React.createElement("option", { value: "query" }, t("modeQuery")),
|
|
938
|
+
React.createElement("option", { value: "execute" }, t("modeExecute")),
|
|
939
|
+
React.createElement("option", { value: "script" }, t("modeScript"))),
|
|
940
|
+
),
|
|
941
|
+
),
|
|
942
|
+
// SQL 输入:等宽字体 + 填充式输入(背景/focus ring 由样式层承担),min-height 加大
|
|
943
|
+
React.createElement("textarea", {
|
|
944
|
+
placeholder: mode === "script" ? t("scriptPlaceholder") : t("sqlPlaceholder"),
|
|
945
|
+
value: sql, onChange: (e) => setSql(e.target.value),
|
|
946
|
+
style: { fontFamily: MONO_FONT, minHeight: mode === "script" ? 160 : 96 },
|
|
947
|
+
}),
|
|
948
|
+
mode !== "script"
|
|
949
|
+
? React.createElement("input", { placeholder: t("paramsJson"), value: params, onChange: (e) => setParams(e.target.value), style: { fontFamily: MONO_FONT } })
|
|
950
|
+
: null,
|
|
951
|
+
React.createElement(
|
|
952
|
+
"div",
|
|
953
|
+
{ className: "dbt-row" },
|
|
954
|
+
React.createElement("button", { className: "dbt-btn primary", disabled: busy || !connId || !sql.trim(), onClick: run }, busy ? t("running") : t("run")),
|
|
955
|
+
),
|
|
956
|
+
renderResult(),
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/* ---------------- 根面板 ---------------- */
|
|
961
|
+
function Panel(props) {
|
|
962
|
+
const visible = props.visible;
|
|
963
|
+
const scope = props.scope || {};
|
|
964
|
+
const [view, setView] = React.useState("manage");
|
|
965
|
+
const [projectPath, setProjectPath] = React.useState(scope.cwd || scope.workspacePath || "");
|
|
966
|
+
const [projectEdited, setProjectEdited] = React.useState(!(scope.cwd || scope.workspacePath));
|
|
967
|
+
const [conns, setConns] = React.useState([]);
|
|
968
|
+
const [grants, setGrants] = React.useState([]);
|
|
969
|
+
const [busy, setBusy] = React.useState("");
|
|
970
|
+
const [error, setError] = React.useState("");
|
|
971
|
+
const [message, setMessage] = React.useState("");
|
|
972
|
+
const [confirmReq, setConfirmReq] = React.useState(null); // {statement,danger,reason,resolve}
|
|
973
|
+
|
|
974
|
+
const askConfirm = React.useCallback((info) => new Promise((resolve) => {
|
|
975
|
+
setConfirmReq(Object.assign({}, info, { resolve }));
|
|
976
|
+
}), []);
|
|
977
|
+
|
|
978
|
+
// 项目路径权威解析(对齐 dsh-ssh-tunnel getProjectContext):
|
|
979
|
+
// host 端以会话 header.cwd 为准归一化出 projectPathKey,前端不自拼 key。
|
|
980
|
+
// scope.cwd/sessionId 变化(切换会话)时自动重新解析;用户手填(projectEdited)时不覆盖。
|
|
981
|
+
const sessionKey = scope.sessionId || "";
|
|
982
|
+
const scopeCwd = scope.cwd || scope.workspacePath || "";
|
|
983
|
+
const resolvedRef = React.useRef("");
|
|
984
|
+
React.useEffect(() => {
|
|
985
|
+
if (!visible || projectEdited) return;
|
|
986
|
+
api("project-context", { method: "POST", body: { sessionId: sessionKey, cwd: scopeCwd } })
|
|
987
|
+
.then((r) => {
|
|
988
|
+
if (r && r.hasProject && r.projectPathKey) {
|
|
989
|
+
resolvedRef.current = r.projectPathKey;
|
|
990
|
+
setProjectPath(r.projectPathKey);
|
|
991
|
+
}
|
|
992
|
+
})
|
|
993
|
+
.catch(() => { /* 解析失败保留现值,仍可手填 */ });
|
|
994
|
+
}, [visible, sessionKey, scopeCwd, projectEdited]);
|
|
995
|
+
|
|
996
|
+
const reload = React.useCallback(async () => {
|
|
997
|
+
const [c, g] = await Promise.all([
|
|
998
|
+
api("connections"),
|
|
999
|
+
projectPath ? api("grants" + qs({ project: projectPath })) : Promise.resolve([]),
|
|
1000
|
+
]);
|
|
1001
|
+
setConns(c || []);
|
|
1002
|
+
setGrants(g || []);
|
|
1003
|
+
}, [projectPath]);
|
|
1004
|
+
|
|
1005
|
+
React.useEffect(() => {
|
|
1006
|
+
if (!visible) return;
|
|
1007
|
+
reload().catch((e) => setError(String(e && e.message ? e.message : e)));
|
|
1008
|
+
}, [visible, reload]);
|
|
1009
|
+
|
|
1010
|
+
function run(name, fn) {
|
|
1011
|
+
setBusy(name); setError(""); setMessage("");
|
|
1012
|
+
return fn()
|
|
1013
|
+
.then(() => setMessage(t("ok")))
|
|
1014
|
+
.catch((e) => setError(String(e && e.message ? e.message : e)))
|
|
1015
|
+
.finally(() => setBusy(""));
|
|
1016
|
+
}
|
|
1017
|
+
function onError(e) { setError(String(e && e.message ? e.message : e)); }
|
|
1018
|
+
|
|
1019
|
+
const shared = { conns, grants, projectPath, busy, run, reload, onError };
|
|
1020
|
+
return React.createElement(
|
|
1021
|
+
"div",
|
|
1022
|
+
{ className: "dbt-panel" },
|
|
1023
|
+
React.createElement(
|
|
1024
|
+
"div",
|
|
1025
|
+
{ className: "dbt-tabs" },
|
|
1026
|
+
[["manage", t("viewManage")], ["grants", t("viewGrants")], ["browse", t("viewBrowse")], ["console", t("viewConsole")]].map(([v, label]) =>
|
|
1027
|
+
React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, label)),
|
|
1028
|
+
),
|
|
1029
|
+
(projectEdited || !projectPath)
|
|
1030
|
+
? React.createElement(
|
|
1031
|
+
"div",
|
|
1032
|
+
{ className: "dbt-row" },
|
|
1033
|
+
// 填充式输入:裸 input 命中样式层 .dbt-row input 契约(去掉旧 dbt-card + 内联 padding hack)
|
|
1034
|
+
React.createElement("input", {
|
|
1035
|
+
placeholder: t("projectPathPlaceholder"), value: projectPath,
|
|
1036
|
+
onChange: (e) => setProjectPath(e.target.value),
|
|
1037
|
+
// 失焦时:输入与权威解析一致才收回自动跟随;用户填了自己的路径则保持手填态(不被权威值打回)
|
|
1038
|
+
onBlur: (e) => {
|
|
1039
|
+
const v = (e.target.value || "").trim();
|
|
1040
|
+
if (!v || v === resolvedRef.current) setProjectEdited(false);
|
|
1041
|
+
},
|
|
1042
|
+
}),
|
|
1043
|
+
)
|
|
1044
|
+
: React.createElement("div", { className: "dbt-row" },
|
|
1045
|
+
React.createElement("span", { className: "dbt-muted" }, t("projectLabel", { path: projectPath })),
|
|
1046
|
+
// 编辑图标:极简铅笔 SVG(验收要求无 emoji;svg aria-hidden,按钮 aria-label 提供语义)
|
|
1047
|
+
React.createElement("button", {
|
|
1048
|
+
className: "dbt-btn", onClick: () => setProjectEdited(true), "aria-label": "edit",
|
|
1049
|
+
},
|
|
1050
|
+
React.createElement("svg", { width: 12, height: 12, viewBox: "0 0 12 12", fill: "none", "aria-hidden": "true" },
|
|
1051
|
+
React.createElement("path", {
|
|
1052
|
+
d: "M8.6 1.4 L10.6 3.4 L4.2 9.8 L1.6 10.4 L2.2 7.8 Z",
|
|
1053
|
+
stroke: "currentColor", strokeWidth: 1.2, strokeLinejoin: "round",
|
|
1054
|
+
})),
|
|
1055
|
+
)),
|
|
1056
|
+
error ? React.createElement("div", { className: "dbt-err" }, t("error") + ": " + error) : null,
|
|
1057
|
+
message ? React.createElement("div", { className: "dbt-msg" }, message) : null,
|
|
1058
|
+
// 视图切换:key=view 触发 dbt-in 进入动画(opacity + 4px 上移,.22s)
|
|
1059
|
+
React.createElement("div", { className: "dbt-view", key: view },
|
|
1060
|
+
view === "manage" ? React.createElement(ManageView, shared) : null,
|
|
1061
|
+
view === "grants" ? React.createElement(GrantsView, shared) : null,
|
|
1062
|
+
view === "browse" ? React.createElement(BrowseView, shared) : null,
|
|
1063
|
+
view === "console" ? React.createElement(ConsoleView, Object.assign({}, shared, { askConfirm })) : null,
|
|
1064
|
+
),
|
|
1065
|
+
React.createElement("div", { className: "dbt-muted" }, t("footerHint")),
|
|
1066
|
+
confirmReq ? React.createElement(DangerDialog, { challenge: confirmReq, onClose: () => setConfirmReq(null) }) : null,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function DbToolRoot(props) {
|
|
1071
|
+
const ctx = props.ctx;
|
|
1072
|
+
// DSH locale 切换时重渲染(better-sidebar 同款模式)
|
|
1073
|
+
const localeKey = React.useSyncExternalStore(
|
|
1074
|
+
React.useCallback(function (cb) {
|
|
1075
|
+
if (!ctx.locale || typeof ctx.locale.subscribe !== "function") return function () {};
|
|
1076
|
+
return ctx.locale.subscribe(cb);
|
|
1077
|
+
}, [ctx]),
|
|
1078
|
+
React.useCallback(function () {
|
|
1079
|
+
try {
|
|
1080
|
+
return ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot().active : activeLocale;
|
|
1081
|
+
} catch (e) { return activeLocale; }
|
|
1082
|
+
}, [ctx]),
|
|
1083
|
+
function () { return "zh"; },
|
|
1084
|
+
);
|
|
1085
|
+
return React.createElement(Panel, {
|
|
1086
|
+
key: "db-tool-" + String(localeKey || "zh"),
|
|
1087
|
+
visible: props.visible,
|
|
1088
|
+
scope: props.scope,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const inject = ["betterSidebar", "locale"];
|
|
1093
|
+
function apply(ctx) {
|
|
1094
|
+
ensureStyles();
|
|
1095
|
+
if (ctx.locale) {
|
|
1096
|
+
attachLocale(ctx.locale);
|
|
1097
|
+
ctx.effect(function () {
|
|
1098
|
+
const offZh = ctx.locale.register(LOCALE_NS, "zh", zh);
|
|
1099
|
+
const offEn = ctx.locale.register(LOCALE_NS, "en", en);
|
|
1100
|
+
return function () {
|
|
1101
|
+
try { offZh(); } catch (e) {}
|
|
1102
|
+
try { offEn(); } catch (e) {}
|
|
1103
|
+
};
|
|
1104
|
+
}, "dsh-db-tool: dictionaries");
|
|
1105
|
+
}
|
|
1106
|
+
if (!ctx.betterSidebar) return;
|
|
1107
|
+
ctx.effect(function () {
|
|
1108
|
+
return ctx.betterSidebar.registerTab({
|
|
1109
|
+
id: TAB_ID,
|
|
1110
|
+
title: function () { return t("tabTitle"); },
|
|
1111
|
+
icon: function (size) { return icon(size); },
|
|
1112
|
+
order: 45,
|
|
1113
|
+
single: true,
|
|
1114
|
+
component: function (p) {
|
|
1115
|
+
return React.createElement(DbToolRoot, {
|
|
1116
|
+
ctx: ctx,
|
|
1117
|
+
visible: p.visible,
|
|
1118
|
+
scope: p.scope,
|
|
1119
|
+
});
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
}, "dsh-db-tool: register tab");
|
|
1123
|
+
}
|
|
1124
|
+
exports.apply = apply;
|
|
1125
|
+
exports.inject = inject;
|
|
1126
|
+
return module.exports;
|
|
1127
|
+
},
|
|
1128
|
+
});
|