@mindbase/node-tools 1.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/LICENSE +21 -0
- package/bin/clear.js +74 -0
- package/bin/git-log.js +189 -0
- package/package.json +42 -0
- package/src/clear/cleaner.js +68 -0
- package/src/clear/config.js +130 -0
- package/src/clear/scanner.js +75 -0
- package/src/clear/ui.js +353 -0
- package/src/common/ui/ansi.js +49 -0
- package/src/common/ui/pagination.js +63 -0
- package/src/common/ui/screen.js +56 -0
- package/src/common/ui/single-select.js +80 -0
- package/src/common/ui/utils.js +80 -0
- package/src/git-log/ui.js +309 -0
package/src/clear/ui.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 清理工具 UI 模块
|
|
3
|
+
* 使用 prompts + chalk + cli-table3 + treeify 实现
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const prompts = require('prompts');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const Table = require('cli-table3');
|
|
9
|
+
const treeify = require('treeify');
|
|
10
|
+
const { enterAlternateScreen, exitAlternateScreen } = require('../common/ui/ansi.js');
|
|
11
|
+
const { ScreenRenderer } = require('../common/ui/screen.js');
|
|
12
|
+
const { PagedDisplay } = require('../common/ui/pagination.js');
|
|
13
|
+
const { waitForAnyKey } = require('../common/ui/utils.js');
|
|
14
|
+
const readline = require('readline');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 格式化项目名称
|
|
18
|
+
*/
|
|
19
|
+
function formatItemName(item) {
|
|
20
|
+
const icon = item.type === 'dir' ? '📁' : '📄';
|
|
21
|
+
const coloredPath =
|
|
22
|
+
item.type === 'dir' ? chalk.cyan(item.relativePath) : chalk.white(item.relativePath);
|
|
23
|
+
return `${icon} ${coloredPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 等待导航按键(分页场景)
|
|
28
|
+
*/
|
|
29
|
+
function waitForNavigation() {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
readline.emitKeypressEvents(process.stdin);
|
|
32
|
+
|
|
33
|
+
if (process.stdin.isPaused()) {
|
|
34
|
+
process.stdin.resume();
|
|
35
|
+
process.stdin.setRawMode(true);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handler = (_str, key) => {
|
|
39
|
+
if (key.name === 'n' || key.name === 'down' || key.name === 'pagedown') {
|
|
40
|
+
cleanup();
|
|
41
|
+
resolve('next');
|
|
42
|
+
} else if (key.name === 'p' || key.name === 'up' || key.name === 'pageup') {
|
|
43
|
+
cleanup();
|
|
44
|
+
resolve('prev');
|
|
45
|
+
} else if (key.name === 'return') {
|
|
46
|
+
cleanup();
|
|
47
|
+
resolve('confirm');
|
|
48
|
+
} else if ((key.name === 'c' && key.ctrl) || key.name === 'escape') {
|
|
49
|
+
cleanup();
|
|
50
|
+
resolve('exit');
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const cleanup = () => {
|
|
55
|
+
process.stdin.removeListener('keypress', handler);
|
|
56
|
+
process.stdin.setRawMode(false);
|
|
57
|
+
process.stdin.pause();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
process.stdin.on('keypress', handler);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 显示多选菜单
|
|
66
|
+
* @param {Array} items - 可选项数组
|
|
67
|
+
* @returns {Promise<Array>} 选中的项
|
|
68
|
+
*/
|
|
69
|
+
async function selectItems(items) {
|
|
70
|
+
if (items.length === 0) {
|
|
71
|
+
console.log(chalk.yellow('没有找到可清理的内容'));
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 进入备用屏幕
|
|
76
|
+
enterAlternateScreen();
|
|
77
|
+
|
|
78
|
+
// 创建渲染器
|
|
79
|
+
const renderer = new ScreenRenderer();
|
|
80
|
+
|
|
81
|
+
// 分页显示
|
|
82
|
+
const usePagination = items.length > 20;
|
|
83
|
+
const pageSize = 15;
|
|
84
|
+
let pagedDisplay = null;
|
|
85
|
+
let currentPageItems = items;
|
|
86
|
+
|
|
87
|
+
if (usePagination) {
|
|
88
|
+
pagedDisplay = new PagedDisplay(items, pageSize);
|
|
89
|
+
currentPageItems = pagedDisplay.getCurrentPage().items;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 选中状态追踪(默认全选)
|
|
93
|
+
const selectedIndices = new Set(items.map((_, idx) => idx));
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// 主循环
|
|
97
|
+
while (true) {
|
|
98
|
+
// 渲染当前页面
|
|
99
|
+
const header = chalk.bold.white(
|
|
100
|
+
`找到 ${items.length} 个可清理的项目 (已选: ${selectedIndices.size})\n`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const hint = chalk.dim(
|
|
104
|
+
'↑↓ 移动 | 空格 选择 | Enter 确认' +
|
|
105
|
+
(usePagination ? ' | PageUp/PageDown/n/p 翻页' : '') +
|
|
106
|
+
' | CTRL_C 退出\n'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// 构建选项列表
|
|
110
|
+
const choices = currentPageItems.map((item) => {
|
|
111
|
+
const globalIndex = items.indexOf(item);
|
|
112
|
+
return {
|
|
113
|
+
title: formatItemName(item),
|
|
114
|
+
value: globalIndex,
|
|
115
|
+
selected: selectedIndices.has(globalIndex),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 使用 readline 实现自定义多选
|
|
120
|
+
const result = await customMultiselect(choices, selectedIndices, header + hint);
|
|
121
|
+
|
|
122
|
+
// 处理退出
|
|
123
|
+
if (result === 'exit') {
|
|
124
|
+
exitAlternateScreen();
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 更新选中状态
|
|
129
|
+
result.newSelections.forEach((idx) => selectedIndices.add(idx));
|
|
130
|
+
result.deselections.forEach((idx) => selectedIndices.delete(idx));
|
|
131
|
+
|
|
132
|
+
// 分页处理
|
|
133
|
+
if (usePagination) {
|
|
134
|
+
const navigation = await waitForNavigation();
|
|
135
|
+
|
|
136
|
+
if (navigation === 'next') {
|
|
137
|
+
pagedDisplay.nextPage();
|
|
138
|
+
currentPageItems = pagedDisplay.getCurrentPage().items;
|
|
139
|
+
renderer.clear();
|
|
140
|
+
continue;
|
|
141
|
+
} else if (navigation === 'prev') {
|
|
142
|
+
pagedDisplay.prevPage();
|
|
143
|
+
currentPageItems = pagedDisplay.getCurrentPage().items;
|
|
144
|
+
renderer.clear();
|
|
145
|
+
continue;
|
|
146
|
+
} else if (navigation === 'exit') {
|
|
147
|
+
exitAlternateScreen();
|
|
148
|
+
return [];
|
|
149
|
+
} else if (navigation === 'confirm') {
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 退出备用屏幕
|
|
158
|
+
exitAlternateScreen();
|
|
159
|
+
|
|
160
|
+
// 返回选中项目
|
|
161
|
+
return Array.from(selectedIndices).map((idx) => items[idx]);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
exitAlternateScreen();
|
|
164
|
+
if (error === 'CTRL_C') {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 自定义多选实现
|
|
173
|
+
*/
|
|
174
|
+
function customMultiselect(choices, currentSelected, titleText = '') {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
let currentIndex = 0;
|
|
177
|
+
|
|
178
|
+
// 显示选项
|
|
179
|
+
function display() {
|
|
180
|
+
console.clear();
|
|
181
|
+
if (titleText) {
|
|
182
|
+
console.log(titleText);
|
|
183
|
+
}
|
|
184
|
+
choices.forEach((choice, idx) => {
|
|
185
|
+
const isSelected = currentSelected.has(choice.value);
|
|
186
|
+
const isCurrent = idx === currentIndex;
|
|
187
|
+
const prefix = isCurrent ? '> ' : ' ';
|
|
188
|
+
const marker = isSelected ? '[x]' : '[ ]';
|
|
189
|
+
const color = isSelected ? chalk.green : chalk.gray;
|
|
190
|
+
console.log(prefix + marker + ' ' + color(choice.title));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
display();
|
|
195
|
+
|
|
196
|
+
readline.emitKeypressEvents(process.stdin);
|
|
197
|
+
process.stdin.resume();
|
|
198
|
+
process.stdin.setRawMode(true);
|
|
199
|
+
|
|
200
|
+
const handler = (_str, key) => {
|
|
201
|
+
if (key.name === 'up') {
|
|
202
|
+
currentIndex = Math.max(0, currentIndex - 1);
|
|
203
|
+
display();
|
|
204
|
+
} else if (key.name === 'down') {
|
|
205
|
+
currentIndex = Math.min(choices.length - 1, currentIndex + 1);
|
|
206
|
+
display();
|
|
207
|
+
} else if (key.name === 'space') {
|
|
208
|
+
const value = choices[currentIndex].value;
|
|
209
|
+
if (currentSelected.has(value)) {
|
|
210
|
+
currentSelected.delete(value);
|
|
211
|
+
} else {
|
|
212
|
+
currentSelected.add(value);
|
|
213
|
+
}
|
|
214
|
+
display();
|
|
215
|
+
} else if (key.name === 'return') {
|
|
216
|
+
cleanup();
|
|
217
|
+
resolve({ newSelections: Array.from(currentSelected), deselections: [] });
|
|
218
|
+
} else if (key.name === 'c' && key.ctrl) {
|
|
219
|
+
cleanup();
|
|
220
|
+
resolve('exit');
|
|
221
|
+
} else if (key.name === 'escape') {
|
|
222
|
+
cleanup();
|
|
223
|
+
resolve('exit');
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const cleanup = () => {
|
|
228
|
+
process.stdin.removeListener('keypress', handler);
|
|
229
|
+
process.stdin.setRawMode(false);
|
|
230
|
+
process.stdin.pause();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
process.stdin.on('keypress', handler);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 显示删除预览
|
|
239
|
+
* @param {Array} items - 选中的项
|
|
240
|
+
* @returns {Promise<boolean>} 是否确认删除
|
|
241
|
+
*/
|
|
242
|
+
async function confirmDelete(items) {
|
|
243
|
+
if (items.length === 0) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
enterAlternateScreen();
|
|
248
|
+
const renderer = new ScreenRenderer();
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// 创建表格
|
|
252
|
+
const table = new Table({
|
|
253
|
+
head: [chalk.bold('类型'), chalk.bold('路径')],
|
|
254
|
+
colWidths: [10, 70],
|
|
255
|
+
wordWrap: true,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// 填充数据
|
|
259
|
+
items.forEach((item) => {
|
|
260
|
+
const icon =
|
|
261
|
+
item.type === 'dir' ? chalk.cyan('📁 目录') : chalk.white('📄 文件');
|
|
262
|
+
table.push([icon, item.relativePath]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// 渲染预览
|
|
266
|
+
const header = chalk.bold.red(`\n即将删除以下 ${items.length} 项:\n`);
|
|
267
|
+
const content = header + table.toString();
|
|
268
|
+
|
|
269
|
+
renderer.render(content);
|
|
270
|
+
|
|
271
|
+
// 确认
|
|
272
|
+
const response = await prompts({
|
|
273
|
+
type: 'confirm',
|
|
274
|
+
name: 'confirm',
|
|
275
|
+
message: chalk.yellow('确认删除?'),
|
|
276
|
+
initial: false,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
exitAlternateScreen();
|
|
280
|
+
|
|
281
|
+
return response.confirm === true;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
exitAlternateScreen();
|
|
284
|
+
if (error === 'CTRL_C') {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 显示删除结果
|
|
293
|
+
* @param {Object} stats - 删除统计
|
|
294
|
+
*/
|
|
295
|
+
async function showResult(stats) {
|
|
296
|
+
enterAlternateScreen();
|
|
297
|
+
const renderer = new ScreenRenderer();
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
// 统计表格
|
|
301
|
+
const summaryTable = new Table({
|
|
302
|
+
colWidths: [20, 10],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
summaryTable.push(
|
|
306
|
+
[chalk.white('总处理数'), chalk.bold(stats.success + stats.failed)],
|
|
307
|
+
[chalk.green('成功'), chalk.bold.green(stats.success)],
|
|
308
|
+
[chalk.red('失败'), chalk.bold.red(stats.failed)]
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
let content = chalk.bold.white('\n删除完成:\n\n') + summaryTable.toString();
|
|
312
|
+
|
|
313
|
+
// 失败详情
|
|
314
|
+
if (stats.failed > 0) {
|
|
315
|
+
const failedItems = stats.details.filter((d) => d.status === 'failed');
|
|
316
|
+
|
|
317
|
+
content += '\n\n' + chalk.bold.red('失败详情:\n');
|
|
318
|
+
|
|
319
|
+
// 使用 treeify 构建目录树
|
|
320
|
+
const tree = {};
|
|
321
|
+
failedItems.forEach((item) => {
|
|
322
|
+
const parts = item.relativePath.split(/[/\\]/);
|
|
323
|
+
let current = tree;
|
|
324
|
+
|
|
325
|
+
parts.forEach((part, idx) => {
|
|
326
|
+
if (!current[part]) {
|
|
327
|
+
current[part] = idx === parts.length - 1 ? null : {};
|
|
328
|
+
}
|
|
329
|
+
if (current[part] !== null) {
|
|
330
|
+
current = current[part];
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
content += '\n' + chalk.red(treeify.asTree(tree, true)) + '\n';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 操作提示
|
|
339
|
+
content += '\n' + chalk.dim('按任意键继续...');
|
|
340
|
+
|
|
341
|
+
renderer.render(content);
|
|
342
|
+
|
|
343
|
+
// 等待按键
|
|
344
|
+
await waitForAnyKey();
|
|
345
|
+
|
|
346
|
+
exitAlternateScreen();
|
|
347
|
+
} catch (error) {
|
|
348
|
+
exitAlternateScreen();
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = { selectItems, confirmDelete, showResult };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI 转义序列常量
|
|
3
|
+
* 用于终端控制:备用屏幕、清屏、光标控制等
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
// 备用屏幕
|
|
8
|
+
ENTER_ALTERNATE_SCREEN: '\x1b[?1049h', // 进入备用屏幕
|
|
9
|
+
EXIT_ALTERNATE_SCREEN: '\x1b[?1049l', // 退出备用屏幕
|
|
10
|
+
|
|
11
|
+
// 清屏
|
|
12
|
+
CLEAR_SCREEN: '\x1b[2J', // 清除整个屏幕
|
|
13
|
+
CLEAR_LINE: '\x1b[K', // 清除从光标到行尾
|
|
14
|
+
MOVE_CURSOR_HOME: '\x1b[H', // 移动光标到左上角
|
|
15
|
+
|
|
16
|
+
// 光标控制
|
|
17
|
+
HIDE_CURSOR: '\x1b[?25l', // 隐藏光标
|
|
18
|
+
SHOW_CURSOR: '\x1b[?25h', // 显示光标
|
|
19
|
+
SAVE_CURSOR: '\x1b[s', // 保存光标位置
|
|
20
|
+
RESTORE_CURSOR: '\x1b[u', // 恢复光标位置
|
|
21
|
+
|
|
22
|
+
// 滚动
|
|
23
|
+
DISABLE_SCROLL: '\x1b[?7l', // 禁用自动换行
|
|
24
|
+
ENABLE_SCROLL: '\x1b[?7h', // 启用自动换行
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 进入备用屏幕(清屏并保存原屏幕内容)
|
|
29
|
+
*/
|
|
30
|
+
function enterAlternateScreen() {
|
|
31
|
+
process.stdout.write('\x1b[?1049h'); // 进入备用屏幕
|
|
32
|
+
process.stdout.write('\x1b[2J'); // 清除屏幕
|
|
33
|
+
process.stdout.write('\x1b[H'); // 移动光标到左上角
|
|
34
|
+
process.stdout.write('\x1b[?25l'); // 隐藏光标
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 退出备用屏幕(恢复原屏幕)
|
|
39
|
+
*/
|
|
40
|
+
function exitAlternateScreen() {
|
|
41
|
+
process.stdout.write('\x1b[?25h'); // 显示光标
|
|
42
|
+
process.stdout.write('\x1b[?1049l'); // 退出备用屏幕
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
...module.exports,
|
|
47
|
+
enterAlternateScreen,
|
|
48
|
+
exitAlternateScreen,
|
|
49
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分页显示
|
|
3
|
+
* 支持对大量数据进行分页展示
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class PagedDisplay {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Array} items - 要分页的数据
|
|
9
|
+
* @param {number} pageSize - 每页显示数量
|
|
10
|
+
*/
|
|
11
|
+
constructor(items, pageSize = 10) {
|
|
12
|
+
this.items = items;
|
|
13
|
+
this.pageSize = pageSize;
|
|
14
|
+
this.currentPage = 0;
|
|
15
|
+
this.totalPages = Math.ceil(items.length / pageSize);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 获取当前页数据
|
|
20
|
+
* @returns {Object} 包含当前页信息
|
|
21
|
+
*/
|
|
22
|
+
getCurrentPage() {
|
|
23
|
+
const start = this.currentPage * this.pageSize;
|
|
24
|
+
const end = Math.min(start + this.pageSize, this.items.length);
|
|
25
|
+
return {
|
|
26
|
+
items: this.items.slice(start, end),
|
|
27
|
+
pageIndex: this.currentPage,
|
|
28
|
+
totalPages: this.totalPages,
|
|
29
|
+
hasNext: this.currentPage < this.totalPages - 1,
|
|
30
|
+
hasPrev: this.currentPage > 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 下一页
|
|
36
|
+
*/
|
|
37
|
+
nextPage() {
|
|
38
|
+
if (this.currentPage < this.totalPages - 1) {
|
|
39
|
+
this.currentPage++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 上一页
|
|
45
|
+
*/
|
|
46
|
+
prevPage() {
|
|
47
|
+
if (this.currentPage > 0) {
|
|
48
|
+
this.currentPage--;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 跳转到指定页
|
|
54
|
+
* @param {number} page - 页码(从0开始)
|
|
55
|
+
*/
|
|
56
|
+
jumpToPage(page) {
|
|
57
|
+
if (page >= 0 && page < this.totalPages) {
|
|
58
|
+
this.currentPage = page;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { PagedDisplay };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 屏幕渲染器
|
|
3
|
+
* 负责屏幕的清屏、重绘等操作
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { CLEAR_SCREEN, MOVE_CURSOR_HOME, SAVE_CURSOR, RESTORE_CURSOR } = require('./ansi.js');
|
|
7
|
+
|
|
8
|
+
class ScreenRenderer {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.lastRender = '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 清除并重新渲染
|
|
15
|
+
* @param {string} content - 要渲染的内容
|
|
16
|
+
*/
|
|
17
|
+
render(content) {
|
|
18
|
+
// 保存光标位置
|
|
19
|
+
process.stdout.write(SAVE_CURSOR);
|
|
20
|
+
|
|
21
|
+
// 移动到左上角
|
|
22
|
+
process.stdout.write(MOVE_CURSOR_HOME);
|
|
23
|
+
|
|
24
|
+
// 清除屏幕
|
|
25
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
26
|
+
|
|
27
|
+
// 渲染新内容
|
|
28
|
+
process.stdout.write(content);
|
|
29
|
+
|
|
30
|
+
// 恢复光标
|
|
31
|
+
process.stdout.write(RESTORE_CURSOR);
|
|
32
|
+
|
|
33
|
+
this.lastRender = content;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 部分更新指定行
|
|
38
|
+
* @param {number} line - 行号(从1开始)
|
|
39
|
+
* @param {string} content - 新内容
|
|
40
|
+
*/
|
|
41
|
+
updateLine(line, content) {
|
|
42
|
+
process.stdout.write(`\x1b[${line};0H`); // 移动到指定行
|
|
43
|
+
process.stdout.write('\x1b[K'); // 清除行
|
|
44
|
+
process.stdout.write(content); // 写入新内容
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 清空屏幕
|
|
49
|
+
*/
|
|
50
|
+
clear() {
|
|
51
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
52
|
+
process.stdout.write(MOVE_CURSOR_HOME);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { ScreenRenderer };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用单选菜单组件
|
|
3
|
+
* 使用 readline 原生键盘处理
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 单选菜单
|
|
11
|
+
* @param {Array} items - 选项数组 [{title, value}]
|
|
12
|
+
* @param {Object} options - 配置项
|
|
13
|
+
* @param {string} options.title - 标题
|
|
14
|
+
* @param {string} options.hint - 提示文本
|
|
15
|
+
* @returns {Promise<any>} 选中的 value,退出时返回 null
|
|
16
|
+
*/
|
|
17
|
+
function singleSelect(items, options = {}) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
if (items.length === 0) {
|
|
20
|
+
resolve(null);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let currentIndex = 0;
|
|
25
|
+
|
|
26
|
+
// 显示选项
|
|
27
|
+
function display() {
|
|
28
|
+
console.clear();
|
|
29
|
+
if (options.title) {
|
|
30
|
+
console.log(options.title);
|
|
31
|
+
console.log('');
|
|
32
|
+
}
|
|
33
|
+
items.forEach((item, idx) => {
|
|
34
|
+
const isCurrent = idx === currentIndex;
|
|
35
|
+
const prefix = isCurrent ? '> ' : ' ';
|
|
36
|
+
const color = isCurrent ? chalk.green : chalk.gray;
|
|
37
|
+
console.log(prefix + color(item.title));
|
|
38
|
+
});
|
|
39
|
+
if (options.hint) {
|
|
40
|
+
console.log('');
|
|
41
|
+
console.log(chalk.dim(options.hint));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
display();
|
|
46
|
+
|
|
47
|
+
readline.emitKeypressEvents(process.stdin);
|
|
48
|
+
process.stdin.resume();
|
|
49
|
+
process.stdin.setRawMode(true);
|
|
50
|
+
|
|
51
|
+
const handler = (_str, key) => {
|
|
52
|
+
if (key.name === 'up') {
|
|
53
|
+
currentIndex = Math.max(0, currentIndex - 1);
|
|
54
|
+
display();
|
|
55
|
+
} else if (key.name === 'down') {
|
|
56
|
+
currentIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
57
|
+
display();
|
|
58
|
+
} else if (key.name === 'return') {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve(items[currentIndex].value);
|
|
61
|
+
} else if (key.name === 'c' && key.ctrl) {
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve(null);
|
|
64
|
+
} else if (key.name === 'escape') {
|
|
65
|
+
cleanup();
|
|
66
|
+
resolve(null);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const cleanup = () => {
|
|
71
|
+
process.stdin.removeListener('keypress', handler);
|
|
72
|
+
process.stdin.setRawMode(false);
|
|
73
|
+
process.stdin.pause();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
process.stdin.on('keypress', handler);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { singleSelect };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用 UI 辅助函数
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 等待任意按键
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*/
|
|
11
|
+
function waitForAnyKey() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
readline.emitKeypressEvents(process.stdin);
|
|
14
|
+
|
|
15
|
+
if (process.stdin.isPaused()) {
|
|
16
|
+
process.stdin.resume();
|
|
17
|
+
process.stdin.setRawMode(true);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const handler = () => {
|
|
21
|
+
cleanup();
|
|
22
|
+
resolve();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const cleanup = () => {
|
|
26
|
+
process.stdin.removeListener('keypress', handler);
|
|
27
|
+
process.stdin.setRawMode(false);
|
|
28
|
+
process.stdin.pause();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
process.stdin.once('keypress', handler);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 等待导航按键(用于分页场景)
|
|
37
|
+
* @returns {Promise<string>} 返回按键类型: 'next', 'prev', 'confirm', 'exit'
|
|
38
|
+
*/
|
|
39
|
+
function waitForNavigation() {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
readline.emitKeypressEvents(process.stdin);
|
|
42
|
+
|
|
43
|
+
if (process.stdin.isPaused()) {
|
|
44
|
+
process.stdin.resume();
|
|
45
|
+
process.stdin.setRawMode(true);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handler = (str, key) => {
|
|
49
|
+
if (key.name === 'n') {
|
|
50
|
+
cleanup();
|
|
51
|
+
resolve('next');
|
|
52
|
+
} else if (key.name === 'p') {
|
|
53
|
+
cleanup();
|
|
54
|
+
resolve('prev');
|
|
55
|
+
} else if (key.name === 'return') {
|
|
56
|
+
cleanup();
|
|
57
|
+
resolve('confirm');
|
|
58
|
+
} else if (key.name === 'c' && key.ctrl) {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve('exit');
|
|
61
|
+
} else if (key.name === 'escape') {
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve('exit');
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
process.stdin.removeListener('keypress', handler);
|
|
69
|
+
process.stdin.setRawMode(false);
|
|
70
|
+
process.stdin.pause();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
process.stdin.on('keypress', handler);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
waitForAnyKey,
|
|
79
|
+
waitForNavigation,
|
|
80
|
+
};
|