quick-ssh-new 1.0.0 → 1.0.2

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/Quick-SSH.psm1 CHANGED
@@ -1,4 +1,4 @@
1
- # Quick-SSH.psm1 - PowerShell SSH Connection Manager
1
+ # Quick-SSH.psm1 - PowerShell SSH Connection Manager
2
2
  # 仿 Docker 命令行风格的 SSH 连接管理工具
3
3
  # 配置文件路径: %USERPROFILE%\.quickssh\hosts.json
4
4
 
@@ -6,8 +6,10 @@
6
6
  # 内部函数 - 配置管理
7
7
  # ============================================================
8
8
 
9
- $Script:ConfigDir = Join-Path $env:USERPROFILE ".quickssh"
10
- $Script:ConfigFile = Join-Path $Script:ConfigDir "hosts.json"
9
+ $Script:ConfigDir = Join-Path $env:USERPROFILE ".quickssh"
10
+ $Script:ConfigFile = Join-Path $Script:ConfigDir "hosts.json"
11
+ $Script:ModuleRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
12
+ $Script:TUIScript = Join-Path $Script:ModuleRoot "qssh-tui.js"
11
13
 
12
14
  # 初始化配置目录和空 JSON 文件
13
15
  function Initialize-QuickSSHConfig {
@@ -25,7 +27,10 @@ function Get-QuickSSHHosts {
25
27
  try {
26
28
  $raw = Get-Content -Path $Script:ConfigFile -Raw -Encoding UTF8
27
29
  if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
28
- return @($raw | ConvertFrom-Json)
30
+ $data = $raw | ConvertFrom-Json
31
+ # 兼容单个对象 { ... } 和数组 [{ ... }, { ... }]
32
+ if ($data -is [array]) { return $data }
33
+ return @($data)
29
34
  } catch {
30
35
  return @()
31
36
  }
@@ -162,7 +167,7 @@ function Invoke-QuickSSHConnect {
162
167
  param([string]$Alias)
163
168
 
164
169
  if (-not $Alias) {
165
- Show-QuickSSHHelp
170
+ Invoke-QuickSSHTUI
166
171
  return
167
172
  }
168
173
 
@@ -248,6 +253,29 @@ function Invoke-QuickSSHImport {
248
253
  Write-QSSuccess "✔ 导入完成:新增 $added 个,跳过 $skipped 个(别名重复)。"
249
254
  }
250
255
 
256
+ # ============================================================
257
+ # TUI 终端界面
258
+ # ============================================================
259
+
260
+ function Invoke-QuickSSHTUI {
261
+ # 检测 Node.js 是否可用
262
+ $nodePath = (Get-Command "node" -ErrorAction SilentlyContinue).Source
263
+ if (-not $nodePath) {
264
+ Write-QSError "错误:启动 TUI 需要 Node.js,请先安装 Node.js (https://nodejs.org)"
265
+ Write-QSError "或者使用命令行模式: qssh help"
266
+ return
267
+ }
268
+
269
+ if (-not (Test-Path $Script:TUIScript)) {
270
+ Write-QSError "错误:未找到 TUI 脚本: $Script:TUIScript"
271
+ return
272
+ }
273
+
274
+ # 启动 TUI,等待退出后返回
275
+ Write-Host "正在启动 Quick-SSH TUI ..." -ForegroundColor Cyan
276
+ & "node" $Script:TUIScript
277
+ }
278
+
251
279
  # ============================================================
252
280
  # 帮助信息
253
281
  # ============================================================
@@ -257,6 +285,7 @@ function Show-QuickSSHHelp {
257
285
  Write-Host "Quick-SSH - PowerShell SSH 连接管理工具" -ForegroundColor Cyan
258
286
  Write-Host ""
259
287
  Write-Host "用法:" -ForegroundColor Yellow
288
+ Write-Host " qssh 启动 TUI 终端界面(推荐,类似 yazi 操作体验)"
260
289
  Write-Host " qssh ps [关键词] 列出所有已保存的 SSH 连接(对应 docker ps)"
261
290
  Write-Host " qssh add <别名> <用户@主机:端口> [--key <私钥路径>]" -ForegroundColor Gray
262
291
  Write-Host " 添加新 SSH 连接(端口默认 22,私钥默认 ~/.ssh/id_rsa)"
@@ -267,6 +296,7 @@ function Show-QuickSSHHelp {
267
296
  Write-Host " qssh help 显示本帮助信息"
268
297
  Write-Host ""
269
298
  Write-Host "示例:" -ForegroundColor Yellow
299
+ Write-Host " qssh # 启动 TUI 界面"
270
300
  Write-Host " qssh ps"
271
301
  Write-Host " qssh ps 生产"
272
302
  Write-Host " qssh add my-server root@192.168.1.100:22 --key D:\.ssh\id_rsa"
@@ -295,7 +325,7 @@ function global:qssh {
295
325
  Initialize-QuickSSHConfig
296
326
 
297
327
  if (-not $Command) {
298
- Show-QuickSSHHelp
328
+ Invoke-QuickSSHTUI
299
329
  return
300
330
  }
301
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quick-ssh-new",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "🚀 Quick-SSH - 仿 Docker 命令行风格的 PowerShell SSH 连接管理工具",
5
5
  "keywords": [
6
6
  "ssh",
@@ -31,5 +31,8 @@
31
31
  "os": [
32
32
  "win32"
33
33
  ],
34
- "private": false
34
+ "private": false,
35
+ "dependencies": {
36
+ "blessed": "^0.1.81"
37
+ }
35
38
  }
package/qssh-tui.js ADDED
@@ -0,0 +1,778 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * qssh-tui.js - Quick-SSH 终端用户界面 (TUI)
4
+ * 类似 yazi 的布局 + Vim 操作键位
5
+ *
6
+ * 布局:
7
+ * ┌─ 标题栏 ─────────────────────────────────┐
8
+ * │ 连接列表 (60%) │ 详情面板 (40%) │
9
+ * │ ● myserver │ 别名: myserver │
10
+ * │ ○ devbox │ 主机: 192.168.1.100│
11
+ * │ ... │ ... │
12
+ * ├─ 状态栏 ─────────────────────────────────┤
13
+ * │ NORMAL j/k ↑↓ ↵连接 d删除 /搜索 q退出│
14
+ * └──────────────────────────────────────────┘
15
+ *
16
+ * 依赖: blessed (npm install blessed)
17
+ * 数据: %USERPROFILE%\.quickssh\hosts.json
18
+ */
19
+
20
+ const blessed = require("blessed");
21
+
22
+ // 阻止 blessed 切换备用屏幕缓冲区,保留 PowerShell 透明背景
23
+ if (blessed.Program) {
24
+ blessed.Program.prototype.alternateBuffer = function (val, cb) {
25
+ if (typeof val === "function") { cb = val; }
26
+ if (typeof cb === "function") { cb(); }
27
+ return this;
28
+ };
29
+ if (blessed.Program.prototype.smcup) blessed.Program.prototype.smcup = function () { return this; };
30
+ if (blessed.Program.prototype.rmcup) blessed.Program.prototype.rmcup = function () { return this; };
31
+ }
32
+ const net = require("net");
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const { spawn } = require("child_process");
36
+
37
+ // ============================================================
38
+ // 配置
39
+ // ============================================================
40
+
41
+ const CONFIG_DIR = path.join(process.env.USERPROFILE || "~", ".quickssh");
42
+ const CONFIG_FILE = path.join(CONFIG_DIR, "hosts.json");
43
+
44
+ // ============================================================
45
+ // 数据层
46
+ // ============================================================
47
+
48
+ function ensureConfig() {
49
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
50
+ if (!fs.existsSync(CONFIG_FILE)) fs.writeFileSync(CONFIG_FILE, "[]", "utf-8");
51
+ }
52
+
53
+ function loadHosts() {
54
+ ensureConfig();
55
+ try {
56
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8").trim();
57
+ if (!raw) return [];
58
+ const data = JSON.parse(raw);
59
+ // 兼容单个对象 { ... } 和数组 [{ ... }, { ... }]
60
+ return Array.isArray(data) ? data : [data];
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ function saveHosts(hosts) {
67
+ ensureConfig();
68
+ // 确保始终保存为数组
69
+ const data = Array.isArray(hosts) ? hosts : [];
70
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 4), "utf-8");
71
+ }
72
+
73
+ // ============================================================
74
+ // SSH 连接
75
+ // ============================================================
76
+
77
+ function sshConnect(host, cbReturn) {
78
+ const sshExe = "ssh.exe";
79
+ const args = [
80
+ "-i", host.key,
81
+ "-p", String(host.port),
82
+ "-o", "HostKeyAlgorithms=+ssh-rsa",
83
+ `${host.user}@${host.host}`,
84
+ ];
85
+
86
+ screen.destroy();
87
+ process.stdin.removeAllListeners("data");
88
+
89
+ process.stdout.write(`\n\x1b[32m正在连接到 '${host.alias}' (${host.user}@${host.host}:${host.port}) ...\x1b[0m\n\n`);
90
+
91
+ const child = spawn(sshExe, args, {
92
+ stdio: "inherit",
93
+ shell: true,
94
+ });
95
+
96
+ child.on("exit", (code) => {
97
+ process.stdout.write(`\n\x1b[33mSSH 会话已结束 (退出码: ${code})\x1b[0m\n`);
98
+ process.stdout.write(`\x1b[32m按 Enter 键返回 Quick-SSH TUI...\x1b[0m`);
99
+ process.stdin.setRawMode(false);
100
+ process.stdin.once("data", () => cbReturn());
101
+ });
102
+ }
103
+
104
+ // ============================================================
105
+ // TUI 组件
106
+ // ============================================================
107
+
108
+ let screen;
109
+ let headerBar;
110
+ let listBox;
111
+ let detailBox;
112
+ let statusBar;
113
+ let inputBox;
114
+ let helpBox;
115
+ let confirmBox;
116
+
117
+ // 在线状态缓存
118
+ const hostStatus = {}; // { alias: "unknown" | "online" | "offline" }
119
+
120
+ let hosts = [];
121
+ let filteredHosts = [];
122
+ let filterText = "";
123
+
124
+ // 模式管理
125
+ const MODE = { NORMAL: 0, SEARCH: 1, ADD: 2, EXPORT: 3, IMPORT: 4, CONFIRM: 5, HELP: 6, RENAME: 7 };
126
+ let currentMode = MODE.NORMAL;
127
+
128
+ // ============================================================
129
+ // UI 更新函数
130
+ // ============================================================
131
+
132
+ const MODE_LABELS = {
133
+ [MODE.NORMAL]: "{green-fg}{bold} NORMAL {/bold}{/green-fg}",
134
+ [MODE.SEARCH]: "{cyan-fg}{bold} SEARCH {/bold}{/cyan-fg}",
135
+ [MODE.ADD]: "{yellow-fg}{bold} ADD {/bold}{/yellow-fg}",
136
+ [MODE.EXPORT]: "{yellow-fg}{bold} EXPORT {/bold}{/yellow-fg}",
137
+ [MODE.IMPORT]: "{yellow-fg}{bold} IMPORT {/bold}{/yellow-fg}",
138
+ [MODE.CONFIRM]: "{red-fg}{bold} CONFIRM {/bold}{/red-fg}",
139
+ [MODE.HELP]: "{white-fg}{bold} HELP {/bold}{/white-fg}",
140
+ [MODE.RENAME]: "{cyan-fg}{bold} RENAME {/bold}{/cyan-fg}",
141
+ };
142
+
143
+ const MODE_HINTS = {
144
+ [MODE.NORMAL]: " j/k ↑↓ g首 G尾 ↵连接 d删除 a添加 /搜索 e导出 i导入 r重命名 p检测 P全检 ?帮助 q退出",
145
+ [MODE.SEARCH]: " 输入关键词过滤 Enter确认 Esc取消",
146
+ [MODE.ADD]: " 格式: 别名 用户@主机:端口 [--key 路径] Enter确认 Esc取消",
147
+ [MODE.EXPORT]: " 输入导出文件路径 (默认: ~/.quickssh/export.json) Enter确认 Esc取消",
148
+ [MODE.IMPORT]: " 输入导入文件路径 Enter确认 Esc取消",
149
+ [MODE.CONFIRM]: " y确认 n取消",
150
+ [MODE.HELP]: " 按任意键返回",
151
+ [MODE.RENAME]: " 输入新别名 Enter确认 Esc取消",
152
+ };
153
+
154
+ function setMode(mode, inputValue) {
155
+ currentMode = mode;
156
+ statusBar.setContent(`${MODE_LABELS[mode] || ""} ${MODE_HINTS[mode] || ""}`);
157
+
158
+ if (mode === MODE.NORMAL || mode === MODE.HELP) {
159
+ inputBox.hide();
160
+ confirmBox.hide();
161
+ if (mode === MODE.NORMAL) listBox.focus();
162
+ }
163
+
164
+ if (mode === MODE.SEARCH || mode === MODE.ADD || mode === MODE.EXPORT || mode === MODE.IMPORT || mode === MODE.RENAME) {
165
+ inputBox.show();
166
+ inputBox.setValue(inputValue || "");
167
+ inputBox.focus();
168
+ inputBox.readInput();
169
+ }
170
+
171
+ if (mode === MODE.CONFIRM) {
172
+ confirmBox.show();
173
+ }
174
+
175
+ screen.render();
176
+ }
177
+
178
+ function refreshList(keepSelection) {
179
+ hosts = loadHosts();
180
+ filteredHosts = filterText
181
+ ? hosts.filter(h =>
182
+ h.alias.toLowerCase().includes(filterText.toLowerCase()) ||
183
+ h.host.toLowerCase().includes(filterText.toLowerCase()) ||
184
+ h.user.toLowerCase().includes(filterText.toLowerCase())
185
+ )
186
+ : [...hosts];
187
+
188
+ const prevIdx = listBox.selected;
189
+ const statusIndicators = {
190
+ online: "{green-fg}●{/green-fg}",
191
+ offline: "{red-fg}○{/red-fg}",
192
+ unknown: "{yellow-fg}◌{/yellow-fg}",
193
+ };
194
+ listBox.setItems(filteredHosts.map(h => {
195
+ const sta = hostStatus[h.alias] || "unknown";
196
+ const indicator = statusIndicators[sta] || statusIndicators.unknown;
197
+ return `${indicator} ${h.alias.padEnd(14)} ${h.user}@${h.host}:${h.port}`;
198
+ }));
199
+
200
+ if (keepSelection && prevIdx < filteredHosts.length) {
201
+ listBox.select(prevIdx);
202
+ listBox.scrollTo(prevIdx);
203
+ } else if (filteredHosts.length > 0) {
204
+ listBox.select(0);
205
+ listBox.scrollTo(0);
206
+ }
207
+
208
+ updateDetail();
209
+ headerBar.setContent(
210
+ `{bold} Quick-SSH {/bold}(${filteredHosts.length}/${hosts.length})`
211
+ );
212
+ screen.render();
213
+ }
214
+
215
+ function updateDetail() {
216
+ const idx = listBox.selected;
217
+ if (idx < 0 || idx >= filteredHosts.length) {
218
+ detailBox.setContent("{center}{yellow-fg}--- 无选中连接 ---{/yellow-fg}{/center}");
219
+ screen.render();
220
+ return;
221
+ }
222
+
223
+ const h = filteredHosts[idx];
224
+ const sta = hostStatus[h.alias] || "unknown";
225
+ const statusMap = {
226
+ online: "{green-fg}● 在线{/green-fg}",
227
+ offline: "{red-fg}○ 离线{/red-fg}",
228
+ unknown: "{yellow-fg}◌ 未检测{/yellow-fg}",
229
+ };
230
+ detailBox.setContent(`
231
+ {bold}{cyan-fg} 连接详情{/cyan-fg}{/bold}
232
+
233
+ {bold}别名:{/bold} ${h.alias}
234
+ {bold}主机:{/bold} ${h.host}
235
+ {bold}账号:{/bold} ${h.user}
236
+ {bold}端口:{/bold} ${h.port}
237
+ {bold}私钥:{/bold} ${h.key || "(默认)"}
238
+ {bold}状态:{/bold} ${statusMap[sta]}
239
+
240
+ {blue-fg}─────────────────────{/blue-fg}
241
+ {green-fg} Enter → 连接{/green-fg} {red-fg} d → 删除{/red-fg}
242
+ {yellow-fg} p → 检测在线{/yellow-fg}
243
+ `);
244
+ screen.render();
245
+ }
246
+
247
+ // ============================================================
248
+ // 在线检测
249
+ // ============================================================
250
+
251
+ /**
252
+ * 检查单台服务器是否在线(TCP 连接 SSH 端口)
253
+ */
254
+ function checkHost(alias) {
255
+ return new Promise((resolve) => {
256
+ const h = hosts.find(e => e.alias === alias);
257
+ if (!h) { resolve(false); return; }
258
+
259
+ hostStatus[alias] = "checking";
260
+ refreshList();
261
+
262
+ const sock = new net.Socket();
263
+ const port = h.port || 22;
264
+ const timeout = 3000; // 3s
265
+
266
+ sock.setTimeout(timeout);
267
+ sock.on("connect", () => {
268
+ sock.destroy();
269
+ hostStatus[alias] = "online";
270
+ refreshList();
271
+ resolve(true);
272
+ });
273
+ sock.on("error", () => {
274
+ sock.destroy();
275
+ hostStatus[alias] = "offline";
276
+ refreshList();
277
+ resolve(false);
278
+ });
279
+ sock.on("timeout", () => {
280
+ sock.destroy();
281
+ hostStatus[alias] = "offline";
282
+ refreshList();
283
+ resolve(false);
284
+ });
285
+ sock.connect(port, h.host);
286
+ });
287
+ }
288
+
289
+ function checkSelectedHost() {
290
+ const idx = listBox.selected;
291
+ if (idx < 0 || idx >= filteredHosts.length) return;
292
+ const alias = filteredHosts[idx].alias;
293
+ flashMessage(`正在检测 '${alias}' ...`, "yellow");
294
+ checkHost(alias).then(ok => {
295
+ flashMessage(`'${alias}' ${ok ? "● 在线" : "○ 离线"}`, ok ? "green" : "red");
296
+ });
297
+ }
298
+
299
+ function checkAllHosts() {
300
+ const list = filteredHosts.length > 0 ? filteredHosts : hosts;
301
+ if (list.length === 0) {
302
+ flashMessage("没有可检测的服务器", "red");
303
+ return;
304
+ }
305
+ flashMessage(`正在检测 ${list.length} 台服务器 ...`, "yellow");
306
+ let done = 0;
307
+ for (const h of list) {
308
+ checkHost(h.alias).then(() => {
309
+ done++;
310
+ if (done === list.length) {
311
+ const online = list.filter(e => hostStatus[e.alias] === "online").length;
312
+ const offline = list.filter(e => hostStatus[e.alias] === "offline").length;
313
+ flashMessage(`检测完成: ${online} 在线, ${offline} 离线`, online > 0 ? "green" : "red");
314
+ }
315
+ });
316
+ }
317
+ }
318
+
319
+ // ============================================================
320
+ // 操作函数
321
+ // ============================================================
322
+
323
+ function connectSelected() {
324
+ const idx = listBox.selected;
325
+ if (idx < 0 || idx >= filteredHosts.length) return;
326
+ sshConnect(filteredHosts[idx], startTUI);
327
+ }
328
+
329
+ function deleteSelected() {
330
+ const idx = listBox.selected;
331
+ if (idx < 0 || idx >= filteredHosts.length) return;
332
+ const h = filteredHosts[idx];
333
+ confirmBox.setContent(
334
+ `{center}{red-fg}确认删除连接 '{h.alias}'? (y/n){/red-fg}{/center}`
335
+ );
336
+ setMode(MODE.CONFIRM);
337
+ }
338
+
339
+ function confirmDelete(confirmed) {
340
+ confirmBox.hide();
341
+ if (confirmed) {
342
+ const idx = listBox.selected;
343
+ const h = filteredHosts[idx];
344
+ hosts = hosts.filter(item => item.alias !== h.alias);
345
+ saveHosts(hosts);
346
+ refreshList();
347
+ flashMessage(`已删除连接 '${h.alias}'`, "green");
348
+ }
349
+ setMode(MODE.NORMAL);
350
+ }
351
+
352
+ function flashMessage(msg, color) {
353
+ statusBar.setContent(`{${color}-fg} ${msg}{/${color}-fg}`);
354
+ screen.render();
355
+ setTimeout(() => {
356
+ if (currentMode === MODE.NORMAL) setMode(MODE.NORMAL);
357
+ }, 3000);
358
+ }
359
+
360
+ // ============================================================
361
+ // 输入处理
362
+ // ============================================================
363
+
364
+ function handleInputSubmit(value) {
365
+ const val = value.trim();
366
+
367
+ switch (currentMode) {
368
+ case MODE.SEARCH: {
369
+ filterText = val;
370
+ inputBox.hide();
371
+ refreshList();
372
+ setMode(MODE.NORMAL);
373
+ break;
374
+ }
375
+ case MODE.ADD: {
376
+ inputBox.hide();
377
+ const parts = val.split(/\s+/);
378
+ if (parts.length < 2) {
379
+ flashMessage("格式错误: 需要 别名 用户@主机:端口", "red");
380
+ setMode(MODE.NORMAL);
381
+ break;
382
+ }
383
+ const alias = parts[0];
384
+ const userAtHost = parts[1];
385
+ let keyPath = "";
386
+ const ki = parts.indexOf("--key");
387
+ if (ki >= 0 && ki + 1 < parts.length) keyPath = parts[ki + 1];
388
+
389
+ let user = "", hostname = "", port = 22;
390
+ const m1 = userAtHost.match(/^(.+)@(.+):(\d+)$/);
391
+ const m2 = userAtHost.match(/^(.+)@(.+)$/);
392
+ if (m1) { user = m1[1]; hostname = m1[2]; port = parseInt(m1[3]); }
393
+ else if (m2) { user = m2[1]; hostname = m2[2]; }
394
+ else {
395
+ flashMessage("格式无效: 请使用 用户@主机:端口", "red");
396
+ setMode(MODE.NORMAL);
397
+ break;
398
+ }
399
+
400
+ if (!keyPath) keyPath = path.join(process.env.USERPROFILE || "~", ".ssh", "id_rsa");
401
+
402
+ if (hosts.find(h => h.alias === alias)) {
403
+ flashMessage(`别名 '${alias}' 已存在`, "red");
404
+ } else {
405
+ hosts.push({ alias, host: hostname, user, port, key: keyPath });
406
+ saveHosts(hosts);
407
+ refreshList(true);
408
+ flashMessage(`已添加 '${alias}' → ${user}@${hostname}:${port}`, "green");
409
+ }
410
+ setMode(MODE.NORMAL);
411
+ break;
412
+ }
413
+ case MODE.EXPORT: {
414
+ inputBox.hide();
415
+ const fp = val || path.join(CONFIG_DIR, "export.json");
416
+ try {
417
+ fs.writeFileSync(fp, JSON.stringify(hosts, null, 4), "utf-8");
418
+ flashMessage(`已导出 ${hosts.length} 个连接到 '${fp}'`, "green");
419
+ } catch (e) {
420
+ flashMessage(`导出失败: ${e.message}`, "red");
421
+ }
422
+ setMode(MODE.NORMAL);
423
+ break;
424
+ }
425
+ case MODE.IMPORT: {
426
+ inputBox.hide();
427
+ if (!val || !fs.existsSync(val)) {
428
+ flashMessage("文件不存在", "red");
429
+ setMode(MODE.NORMAL);
430
+ break;
431
+ }
432
+ try {
433
+ const imported = JSON.parse(fs.readFileSync(val, "utf-8"));
434
+ let added = 0, skipped = 0;
435
+ for (const h of imported) {
436
+ if (!hosts.find(e => e.alias === h.alias)) { hosts.push(h); added++; }
437
+ else { skipped++; }
438
+ }
439
+ saveHosts(hosts);
440
+ refreshList();
441
+ flashMessage(`导入完成: 新增 ${added}, 跳过 ${skipped}`, "green");
442
+ } catch (e) {
443
+ flashMessage(`导入失败: ${e.message}`, "red");
444
+ }
445
+ setMode(MODE.NORMAL);
446
+ break;
447
+ }
448
+ case MODE.RENAME: {
449
+ inputBox.hide();
450
+ const idx = listBox.selected;
451
+ if (idx < 0 || idx >= filteredHosts.length) { setMode(MODE.NORMAL); break; }
452
+ const oldAlias = filteredHosts[idx].alias;
453
+ if (!val || val.trim() === oldAlias) { setMode(MODE.NORMAL); break; }
454
+ const newAlias = val.trim();
455
+ if (hosts.find(h => h.alias === newAlias)) {
456
+ flashMessage(`别名 '${newAlias}' 已存在`, "red");
457
+ setMode(MODE.NORMAL);
458
+ break;
459
+ }
460
+ const target = hosts.find(h => h.alias === oldAlias);
461
+ if (target) {
462
+ target.alias = newAlias;
463
+ saveHosts(hosts);
464
+ refreshList(true);
465
+ flashMessage(`已重命名 '${oldAlias}' → '${newAlias}'`, "green");
466
+ }
467
+ setMode(MODE.NORMAL);
468
+ break;
469
+ }
470
+ }
471
+ }
472
+
473
+ function handleInputCancel() {
474
+ inputBox.hide();
475
+ switch (currentMode) {
476
+ case MODE.SEARCH: setMode(MODE.NORMAL); break;
477
+ case MODE.ADD: setMode(MODE.NORMAL); break;
478
+ case MODE.EXPORT: setMode(MODE.NORMAL); break;
479
+ case MODE.IMPORT: setMode(MODE.NORMAL); break;
480
+ case MODE.RENAME: setMode(MODE.NORMAL); break;
481
+ default: setMode(MODE.NORMAL); break;
482
+ }
483
+ }
484
+
485
+ // ============================================================
486
+ // 启动 TUI
487
+ // ============================================================
488
+
489
+ function startTUI() {
490
+ screen = blessed.screen({
491
+ smartCSR: true,
492
+ title: "Quick-SSH",
493
+ cursor: { artificial: true, shape: "underline" },
494
+ dockBorders: true,
495
+ fullUnicode: true,
496
+ });
497
+
498
+ // 标题栏
499
+ headerBar = blessed.box({
500
+ top: 0, left: 0, width: "100%", height: 1,
501
+ content: "{bold} Quick-SSH {/bold}",
502
+ tags: true,
503
+ style: { fg: "white", bg: -1 },
504
+ });
505
+
506
+ // 连接列表
507
+ listBox = blessed.list({
508
+ top: 1, left: 0, width: "60%", height: "100%-2",
509
+ label: " 连接列表 ",
510
+ border: { type: "line", fg: "cyan" },
511
+ tags: true, keys: false, vi: false, mouse: true,
512
+ scrollbar: { ch: " ", bg: -1 },
513
+ style: {
514
+ fg: "white", bg: -1,
515
+ selected: { fg: "white", bg: "blue", bold: true },
516
+ item: { fg: "white", bg: -1 },
517
+ },
518
+ });
519
+
520
+ // 详情面板
521
+ detailBox = blessed.box({
522
+ top: 1, right: 0, width: "40%", height: "100%-2",
523
+ label: " 详情 ",
524
+ border: { type: "line", fg: "cyan" },
525
+ tags: true, style: { fg: "white", bg: -1 },
526
+ scrollable: true, alwaysScroll: true,
527
+ });
528
+
529
+ // 状态栏
530
+ statusBar = blessed.box({
531
+ bottom: 0, left: 0, width: "100%", height: 1,
532
+ tags: true, style: { fg: "white", bg: -1 },
533
+ });
534
+
535
+ // 输入框
536
+ inputBox = blessed.textbox({
537
+ top: "50%-3", left: "25%", width: "50%", height: 3,
538
+ label: " 输入 ",
539
+ border: { type: "line", fg: "cyan" },
540
+ hidden: true, keys: true, vi: false,
541
+ style: { fg: "white", bg: -1, border: { fg: "cyan" } },
542
+ });
543
+
544
+ // 确认框
545
+ confirmBox = blessed.box({
546
+ top: "50%-3", left: "30%", width: "40%", height: 3,
547
+ border: { type: "line", fg: "red" },
548
+ tags: true, hidden: true,
549
+ style: { fg: "white", bg: -1 },
550
+ });
551
+
552
+ // 帮助弹窗
553
+ helpBox = blessed.box({
554
+ top: "5%", left: "10%", width: "80%", height: "90%",
555
+ label: " 帮助 ",
556
+ border: { type: "line", fg: "yellow" },
557
+ tags: true, hidden: true, scrollable: true, alwaysScroll: true,
558
+ keys: true, vi: false,
559
+ style: { fg: "white", bg: -1 },
560
+ content: `
561
+ {bold}{yellow-fg}Quick-SSH TUI — 快捷键帮助{/yellow-fg}{/bold}
562
+
563
+ {cyan-fg}━━━━━━━━━ 导航 ━━━━━━━━━{/cyan-fg}
564
+ {green-fg}j{/green-fg} / {green-fg}↓{/green-fg} 向下移动
565
+ {green-fg}k{/green-fg} / {green-fg}↑{/green-fg} 向上移动
566
+ {green-ff}g{/green-fg} 跳转到第一个
567
+ {green-fg}G{/green-fg} 跳转到最后一个
568
+
569
+ {cyan-fg}━━━━━━━━━ 操作 ━━━━━━━━━{/cyan-fg}
570
+ {green-fg}Enter{/green-fg} 连接选中的服务器
571
+ {green-fg}d{/green-fg} 删除选中的连接
572
+ {green-fg}a{/green-fg} 添加新连接
573
+ {green-fg}/ {/green-fg} 搜索 / 筛选
574
+ {green-fg}Esc{/green-fg} 取消 / 返回普通模式
575
+
576
+ {cyan-fg}━━━━━━━━━ 在线检测 ━━━━━━━━━{/cyan-fg}
577
+ {green-fg}p{/green-fg} 检测选中服务器是否在线
578
+ {green-fg}P{/green-fg} / {green-fg}C-p{/green-fg} 检测全部服务器
579
+
580
+ {cyan-fg}━━━━━━━━━ 导入导出 ━━━━━━━━━{/cyan-fg}
581
+ {green-fg}e{/green-fg} 导出全部配置
582
+ {green-fg}i{/green-fg} 从 JSON 文件导入
583
+
584
+ {cyan-fg}━━━━━━━━━ 其他 ━━━━━━━━━{/cyan-fg}
585
+ {green-fg}q{/green-fg} 退出 TUI
586
+ {green-fg}?{/green-fg} 显示本帮助
587
+
588
+ {white-fg}按任意键关闭帮助{/white-fg}
589
+ `
590
+ });
591
+
592
+ screen.append(headerBar);
593
+ screen.append(listBox);
594
+ screen.append(detailBox);
595
+ screen.append(statusBar);
596
+ screen.append(inputBox);
597
+ screen.append(confirmBox);
598
+ screen.append(helpBox);
599
+
600
+ // ============================================================
601
+ // 键位绑定
602
+ // ============================================================
603
+
604
+ // ----- 全局键 -----
605
+ screen.key(["C-c"], () => {
606
+ process.stdout.write("\x1b[2J\x1b[H");
607
+ screen.destroy();
608
+ process.exit(0);
609
+ });
610
+
611
+ screen.key(["escape"], () => {
612
+ if (currentMode === MODE.HELP) {
613
+ helpBox.hide();
614
+ setMode(MODE.NORMAL);
615
+ } else if (currentMode === MODE.CONFIRM) {
616
+ confirmDelete(false);
617
+ } else if (currentMode !== MODE.NORMAL) {
618
+ handleInputCancel();
619
+ }
620
+ });
621
+
622
+ // ----- NORMAL 模式 -----
623
+ screen.key(["q"], () => {
624
+ if (currentMode === MODE.NORMAL) {
625
+ process.stdout.write("\x1b[2J\x1b[H");
626
+ screen.destroy();
627
+ process.exit(0);
628
+ }
629
+ });
630
+
631
+ screen.key(["/"], () => {
632
+ if (currentMode === MODE.NORMAL) setMode(MODE.SEARCH, filterText);
633
+ });
634
+
635
+ screen.key(["a"], () => {
636
+ if (currentMode === MODE.NORMAL) setMode(MODE.ADD);
637
+ });
638
+
639
+ screen.key(["d"], () => {
640
+ if (currentMode === MODE.NORMAL) deleteSelected();
641
+ });
642
+
643
+ screen.key(["y"], () => {
644
+ if (currentMode === MODE.CONFIRM) confirmDelete(true);
645
+ });
646
+
647
+ screen.key(["n"], () => {
648
+ if (currentMode === MODE.CONFIRM) confirmDelete(false);
649
+ });
650
+
651
+ screen.key(["enter"], () => {
652
+ if (currentMode === MODE.NORMAL) connectSelected();
653
+ });
654
+
655
+ screen.key(["e"], () => {
656
+ if (currentMode === MODE.NORMAL) setMode(MODE.EXPORT, path.join(CONFIG_DIR, "export.json"));
657
+ });
658
+
659
+ screen.key(["i"], () => {
660
+ if (currentMode === MODE.NORMAL) setMode(MODE.IMPORT);
661
+ });
662
+
663
+ screen.key(["p"], () => {
664
+ if (currentMode === MODE.NORMAL) checkSelectedHost();
665
+ });
666
+
667
+ // 全检: P(Shift) / S-p / C-p(备选,部分终端会拦截 Shift)
668
+ screen.key(["P", "S-p", "C-p"], () => {
669
+ if (currentMode === MODE.NORMAL) checkAllHosts();
670
+ });
671
+
672
+ screen.key(["r"], () => {
673
+ if (currentMode === MODE.NORMAL) {
674
+ const idx = listBox.selected;
675
+ if (idx >= 0 && idx < filteredHosts.length) {
676
+ setMode(MODE.RENAME, filteredHosts[idx].alias);
677
+ }
678
+ }
679
+ });
680
+
681
+ screen.key(["?", "h"], () => {
682
+ if (currentMode === MODE.NORMAL) { helpBox.show(); helpBox.focus(); setMode(MODE.HELP); }
683
+ });
684
+
685
+ // Vim 导航键
686
+ screen.key(["j", "down"], () => {
687
+ if (currentMode !== MODE.NORMAL) return;
688
+ if (listBox.selected < filteredHosts.length - 1) {
689
+ listBox.select(listBox.selected + 1);
690
+ listBox.scrollTo(listBox.selected);
691
+ updateDetail();
692
+ screen.render();
693
+ }
694
+ });
695
+
696
+ screen.key(["k", "up"], () => {
697
+ if (currentMode !== MODE.NORMAL) return;
698
+ if (listBox.selected > 0) {
699
+ listBox.select(listBox.selected - 1);
700
+ listBox.scrollTo(listBox.selected);
701
+ updateDetail();
702
+ screen.render();
703
+ }
704
+ });
705
+
706
+ screen.key(["g"], () => {
707
+ if (currentMode !== MODE.NORMAL) return;
708
+ if (filteredHosts.length > 0) {
709
+ listBox.select(0);
710
+ listBox.scrollTo(0);
711
+ updateDetail();
712
+ screen.render();
713
+ }
714
+ });
715
+
716
+ screen.key(["G"], () => {
717
+ if (currentMode !== MODE.NORMAL) return;
718
+ if (filteredHosts.length > 0) {
719
+ listBox.select(filteredHosts.length - 1);
720
+ listBox.scrollTo(filteredHosts.length - 1);
721
+ updateDetail();
722
+ screen.render();
723
+ }
724
+ });
725
+
726
+ // ----- 输入框事件 -----
727
+ inputBox.on("submit", handleInputSubmit);
728
+ inputBox.on("cancel", handleInputCancel);
729
+
730
+ // ----- 列表事件 -----
731
+ listBox.on("select", () => {
732
+ if (currentMode === MODE.NORMAL) connectSelected();
733
+ });
734
+
735
+ listBox.on("select item", () => { updateDetail(); });
736
+
737
+ // 鼠标滚轮
738
+ listBox.on("wheeldown", () => {
739
+ if (currentMode !== MODE.NORMAL) return;
740
+ if (listBox.selected < filteredHosts.length - 1) {
741
+ listBox.select(listBox.selected + 1);
742
+ listBox.scrollTo(listBox.selected);
743
+ updateDetail();
744
+ screen.render();
745
+ }
746
+ });
747
+
748
+ listBox.on("wheelup", () => {
749
+ if (currentMode !== MODE.NORMAL) return;
750
+ if (listBox.selected > 0) {
751
+ listBox.select(listBox.selected - 1);
752
+ listBox.scrollTo(listBox.selected);
753
+ updateDetail();
754
+ screen.render();
755
+ }
756
+ });
757
+
758
+ // ----- 帮助弹窗关闭 -----
759
+ helpBox.key(["escape", "q", "enter", "space"], () => {
760
+ helpBox.hide();
761
+ setMode(MODE.NORMAL);
762
+ });
763
+
764
+ // ============================================================
765
+ // 初始化
766
+ // ============================================================
767
+
768
+ refreshList();
769
+ listBox.focus();
770
+ setMode(MODE.NORMAL);
771
+ screen.render();
772
+ }
773
+
774
+ // ============================================================
775
+ // 入口
776
+ // ============================================================
777
+
778
+ startTUI();