tronclass-auto 1.7.3 → 1.7.5

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/bin/tronclass.js CHANGED
@@ -64,6 +64,29 @@ program
64
64
  manageCourses(opts);
65
65
  });
66
66
 
67
+ program
68
+ .command('update')
69
+ .description('更新到最新版本')
70
+ .action(async () => {
71
+ const { execSync } = require('child_process');
72
+ console.log(`目前版本: v${pkg.version}`);
73
+ console.log('檢查最新版本...');
74
+ try {
75
+ const latest = execSync('npm view tronclass-auto version', { encoding: 'utf8' }).trim();
76
+ console.log(`最新版本: v${latest}`);
77
+ if (latest === pkg.version) {
78
+ console.log('已經是最新版本!');
79
+ return;
80
+ }
81
+ console.log('正在更新...');
82
+ execSync('npm install -g tronclass-auto@latest', { stdio: 'inherit' });
83
+ console.log('更新完成!');
84
+ } catch (e) {
85
+ console.error('更新失敗:', e.message);
86
+ console.error('請手動執行: npm install -g tronclass-auto@latest');
87
+ }
88
+ });
89
+
67
90
  program
68
91
  .command('config')
69
92
  .description('顯示目前設定')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tronclass-auto",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "自動觀看 eclass/TronClass 影片、填寫表單的 CLI 工具",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -268,22 +268,33 @@ async function showStatus() {
268
268
  const { chromium } = re('playwright');
269
269
  const { ensureBrowser } = re('./browser');
270
270
  const exePath = await ensureBrowser();
271
- const launchOpts = { headless: true, slowMo: 50, args: ['--disable-gpu', '--disable-software-rasterizer'] };
271
+ const launchOpts = {
272
+ headless: true,
273
+ slowMo: 50,
274
+ args: [
275
+ '--disable-gpu',
276
+ '--disable-software-rasterizer',
277
+ '--disable-dev-shm-usage',
278
+ '--no-sandbox',
279
+ '--disable-setuid-sandbox',
280
+ '--disable-cache',
281
+ '--disable-application-cache'
282
+ ]
283
+ };
272
284
  if (exePath) launchOpts.executablePath = exePath;
273
285
  const browser = await chromium.launch(launchOpts);
274
286
  const context = await browser.newContext();
275
287
  await context.addCookies(cookies);
276
288
 
277
- const checkPage = await context.newPage();
278
- await checkPage.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
279
- await checkPage.waitForTimeout(2000);
289
+ const page = await context.newPage();
290
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
291
+ await page.waitForTimeout(2000);
280
292
 
281
- if (checkPage.url().includes('login')) {
293
+ if (page.url().includes('login')) {
282
294
  console.log(chalk.red('[ERROR] Cookie 過期'));
283
295
  await browser.close();
284
296
  return;
285
297
  }
286
- await checkPage.close();
287
298
 
288
299
  const courses = config.courses || [];
289
300
  console.log(chalk.cyan('\n┌' + '─'.repeat(48) + '┐'));
@@ -297,13 +308,14 @@ async function showStatus() {
297
308
  const courseResults = await Promise.all(courses.map(async (courseUrl) => {
298
309
  const match = courseUrl.match(/\/course\/(\d+)\//);
299
310
  const courseId = match ? match[1] : '?';
311
+ const pg = await context.newPage();
300
312
  try {
301
- const pg = await context.newPage();
302
313
  const allVideos = await getAllVideos(pg, courseId);
303
- await pg.close();
304
314
  return { courseId, allVideos };
305
315
  } catch (e) {
306
316
  return { courseId, allVideos: [] };
317
+ } finally {
318
+ await pg.close();
307
319
  }
308
320
  }));
309
321
 
@@ -395,10 +407,11 @@ async function run(opts = {}) {
395
407
  await page.waitForTimeout(2000);
396
408
 
397
409
  if (page.url().includes('login')) {
398
- console.log(chalk.red('[ERROR] Cookie 過期,請重新執行 tronclass login'));
410
+ console.log(chalk.red('[ERROR] Cookie 過期'));
399
411
  await browser.close();
400
412
  return;
401
413
  }
414
+ await page.close();
402
415
 
403
416
  console.log(chalk.green('[OK] Login verified'));
404
417
 
package/src/tui.js CHANGED
@@ -4,38 +4,47 @@ const { loadConfig, saveConfig, BASE_URL } = require('./config');
4
4
  const { loadCookies } = require('./auth');
5
5
  const pkg = require('../package.json');
6
6
 
7
- function box(lines, width = 52) {
8
- console.log(chalk.cyan('╔' + '═'.repeat(width - 2) + '╗'));
9
- lines.forEach(l => console.log(chalk.cyan('║ ') + l.padEnd(width - 4) + chalk.cyan(' ║')));
10
- console.log(chalk.cyan('╚' + '═'.repeat(width - 2) + '╝'));
7
+ function clear() {
8
+ process.stdout.write('\x1B[2J\x1B[H');
11
9
  }
12
10
 
13
- function promptList(message, choices, header) {
11
+ function header() {
12
+ const config = loadConfig();
13
+ const lines = [
14
+ '',
15
+ chalk.white('╔' + '═'.repeat(52) + '╗'),
16
+ chalk.white('║ ') + chalk.bold.white(`tronclass-auto v${pkg.version}`.padEnd(52)) + chalk.white('║'),
17
+ chalk.white('║ ') + chalk.gray('自動觀看 eclass/TronClass 影片'.padEnd(52)) + chalk.white('║'),
18
+ chalk.white('║ ') + `課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`.padEnd(52) + chalk.white('║'),
19
+ chalk.white('╚' + '═'.repeat(52) + '╝'),
20
+ ];
21
+ lines.forEach(l => process.stdout.write(l + '\n'));
22
+ }
23
+
24
+ function draw(extraLines) {
25
+ clear();
26
+ header();
27
+ if (extraLines) extraLines.forEach(l => process.stdout.write(l + '\n'));
28
+ }
29
+
30
+ function menu(title, choices, extraLines) {
14
31
  return new Promise((resolve) => {
15
32
  let selected = 0;
16
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
17
33
 
18
34
  function render() {
19
- const lines = [];
20
- lines.push('');
35
+ clear();
36
+ header();
37
+ if (extraLines) extraLines.forEach(l => process.stdout.write(l + '\n'));
38
+ process.stdout.write('\n');
21
39
  choices.forEach((c, i) => {
22
40
  const prefix = i === selected ? chalk.green('>') : ' ';
23
41
  if (typeof c === 'string' && c === '__sep__') {
24
- lines.push(chalk.gray(' ─────────────'));
42
+ process.stdout.write(chalk.gray(' ─────────────') + '\n');
25
43
  } else {
26
- const label = typeof c === 'string' ? c : c.name;
27
- lines.push(` ${prefix} ${label}`);
44
+ process.stdout.write(` ${prefix} ${c.name}\n`);
28
45
  }
29
46
  });
30
- lines.push('');
31
- lines.push(chalk.gray(' ↑↓ 移動 Enter 確認 ESC 返回'));
32
-
33
- const headerLines = header || [];
34
- const totalLines = headerLines.length + lines.length;
35
-
36
- process.stdout.write('\x1B[2J\x1B[H');
37
- headerLines.forEach(l => process.stdout.write(l + '\n'));
38
- lines.forEach(l => process.stdout.write(l + '\n'));
47
+ process.stdout.write('\n' + chalk.gray(' ↑↓ 移動 Enter 確認 ESC 返回') + '\n');
39
48
  }
40
49
 
41
50
  render();
@@ -45,26 +54,22 @@ function promptList(message, choices, header) {
45
54
 
46
55
  function onData(data) {
47
56
  const key = data.toString();
48
-
49
57
  if (key === '\u001b[A') {
50
58
  selected = Math.max(0, selected - 1);
51
- while (selected < choices.length && (typeof choices[selected] === 'string' || choices[selected].value === '__sep__')) {
52
- selected = Math.max(0, selected - 1);
53
- }
59
+ while (selected >= 0 && typeof choices[selected] === 'string' && choices[selected] === '__sep__') selected--;
60
+ if (selected < 0) selected = 0;
54
61
  render();
55
62
  } else if (key === '\u001b[B') {
56
63
  selected = Math.min(choices.length - 1, selected + 1);
57
- while (selected < choices.length && (typeof choices[selected] === 'string' || choices[selected].value === '__sep__')) {
58
- selected = Math.min(choices.length - 1, selected + 1);
59
- }
64
+ while (selected < choices.length && typeof choices[selected] === 'string' && choices[selected] === '__sep__') selected++;
65
+ if (selected >= choices.length) selected = choices.length - 1;
60
66
  render();
61
67
  } else if (key === '\r' || key === '\n') {
62
68
  cleanup();
63
- const choice = choices[selected];
64
- resolve(typeof choice === 'string' ? choice : choice.value);
69
+ resolve(choices[selected]);
65
70
  } else if (key === '\u001b') {
66
71
  cleanup();
67
- resolve('__esc__');
72
+ resolve(null);
68
73
  }
69
74
  }
70
75
 
@@ -72,72 +77,98 @@ function promptList(message, choices, header) {
72
77
  process.stdin.removeListener('data', onData);
73
78
  process.stdin.setRawMode(false);
74
79
  process.stdin.pause();
75
- rl.close();
76
80
  }
77
81
 
78
82
  process.stdin.on('data', onData);
79
83
  });
80
84
  }
81
85
 
82
- function promptConfirm(message, defaultVal = true) {
86
+ function flushStdin() {
87
+ process.stdin.setRawMode(true);
88
+ process.stdin.resume();
89
+ while (process.stdin.readableLength > 0) process.stdin.read();
90
+ process.stdin.setRawMode(false);
91
+ process.stdin.pause();
92
+ }
93
+
94
+ function confirm(message, defaultVal = true) {
83
95
  return new Promise((resolve) => {
84
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
85
96
  process.stdout.write(` ${message} (${defaultVal ? 'Y/n' : 'y/N'}) `);
86
-
87
97
  process.stdin.setRawMode(true);
88
98
  process.stdin.resume();
89
99
 
90
100
  function onData(data) {
91
101
  const key = data.toString();
92
- if (key === '\r' || key === '\n') {
93
- cleanup();
94
- resolve(defaultVal);
95
- } else if (key === '\u001b') {
96
- cleanup();
97
- resolve(null);
98
- } else if (key.toLowerCase() === 'y') {
99
- cleanup();
100
- resolve(true);
101
- } else if (key.toLowerCase() === 'n') {
102
- cleanup();
103
- resolve(false);
104
- }
102
+ if (key === '\r' || key === '\n') { cleanup(); resolve(defaultVal); }
103
+ else if (key === '\u001b') { cleanup(); resolve(null); }
104
+ else if (key.toLowerCase() === 'y') { cleanup(); resolve(true); }
105
+ else if (key.toLowerCase() === 'n') { cleanup(); resolve(false); }
105
106
  }
106
107
 
107
108
  function cleanup() {
108
109
  process.stdin.removeListener('data', onData);
109
110
  process.stdin.setRawMode(false);
110
111
  process.stdin.pause();
111
- rl.close();
112
112
  }
113
113
 
114
114
  process.stdin.on('data', onData);
115
115
  });
116
116
  }
117
117
 
118
- function promptInput(message) {
118
+ function input(message) {
119
119
  return new Promise((resolve) => {
120
120
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
121
- rl.question(` ${message}: `, (answer) => {
122
- rl.close();
123
- resolve(answer);
124
- });
121
+ rl.question(` ${message}: `, (answer) => { rl.close(); resolve(answer); });
125
122
  });
126
123
  }
127
124
 
128
125
  function pressAnyKey() {
129
126
  return new Promise((resolve) => {
130
- process.stdout.write(chalk.gray('\n 按任意鍵返回...'));
131
127
  process.stdin.setRawMode(true);
132
128
  process.stdin.resume();
133
- process.stdin.once('data', () => {
134
- process.stdin.setRawMode(false);
135
- process.stdin.pause();
136
- resolve();
137
- });
129
+ while (process.stdin.readableLength > 0) process.stdin.read();
130
+ setTimeout(() => {
131
+ process.stdout.write(chalk.gray('\n 按任意鍵返回...'));
132
+ process.stdin.once('data', () => { process.stdin.setRawMode(false); process.stdin.pause(); resolve(); });
133
+ }, 100);
138
134
  });
139
135
  }
140
136
 
137
+ async function fetchCourseNames(config) {
138
+ const courses = config.courses || [];
139
+ if (!courses.length) return {};
140
+ const cookies = loadCookies();
141
+ if (!cookies.length) return {};
142
+ try {
143
+ const { chromium } = require('playwright');
144
+ const browser = await chromium.launch({ headless: true, slowMo: 50, args: ['--disable-gpu', '--no-sandbox', '--disable-cache'] });
145
+ const ctx = await browser.newContext();
146
+ await ctx.addCookies(cookies);
147
+ const pg = await ctx.newPage();
148
+ const results = await Promise.all(courses.map(async (url) => {
149
+ const match = url.match(/\/course\/(\d+)\//);
150
+ if (!match) return { id: '?', name: '' };
151
+ const p = await ctx.newPage();
152
+ try {
153
+ await p.goto(`${BASE_URL}/course/${match[1]}/content#/`, { waitUntil: 'domcontentloaded', timeout: 8000 });
154
+ await p.waitForTimeout(2000);
155
+ const name = await p.evaluate(() => {
156
+ const el = document.querySelector('.learning-activities');
157
+ if (!el) return '';
158
+ try { const s = angular.element(el).scope(); return (s && s.course) ? (s.course.name || '') : ''; } catch { return ''; }
159
+ });
160
+ return { id: match[1], name: name.substring(0, 25) };
161
+ } catch (e) { return { id: match[1], name: '' }; }
162
+ finally { await p.close(); }
163
+ }));
164
+ await pg.close();
165
+ await browser.close();
166
+ const names = {};
167
+ results.forEach(r => { if (r.name) names[r.id] = r.name; });
168
+ return names;
169
+ } catch (e) { return {}; }
170
+ }
171
+
141
172
  async function mainMenu() {
142
173
  const cookies = loadCookies();
143
174
  if (!cookies.length) {
@@ -147,71 +178,55 @@ async function mainMenu() {
147
178
 
148
179
  let running = true;
149
180
  while (running) {
150
- const config = loadConfig();
151
-
152
- console.clear();
153
- box([
154
- chalk.bold.white(`tronclass-auto v${pkg.version}`),
155
- chalk.gray('自動觀看 eclass/TronClass 影片'),
156
- '',
157
- `課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
158
- ]);
159
-
160
- const action = await promptList('選擇操作:', [
181
+ const result = await menu('選擇操作:', [
161
182
  { name: chalk.green('▶ 開始自動觀看'), value: 'run' },
162
183
  { name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
163
184
  { name: chalk.yellow('📊 查看進度統計'), value: 'status' },
164
185
  { name: chalk.magenta('⚙ 設定'), value: 'settings' },
165
186
  '__sep__',
166
187
  { name: chalk.gray('🚪 離開'), value: 'exit' }
167
- ], [
168
- '',
169
- chalk.white('╔' + '═'.repeat(50) + '╗'),
170
- chalk.white('║') + chalk.bold.white(` tronclass-auto v${pkg.version}`.padEnd(50)) + chalk.white('║'),
171
- chalk.white('║') + chalk.gray(' 自動觀看 eclass/TronClass 影片'.padEnd(50)) + chalk.white('║'),
172
- chalk.white('║') + ` 課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`.padEnd(50) + chalk.white('║'),
173
- chalk.white('╚' + '═'.repeat(50) + '╝'),
174
188
  ]);
175
189
 
176
- if (action === '__esc__') { running = false; continue; }
190
+ const action = result?.value;
191
+ if (!action || action === 'exit') { running = false; continue; }
177
192
 
178
193
  switch (action) {
179
- case 'run': await runMenu(config); break;
194
+ case 'run': await runMenu(); break;
180
195
  case 'courses': await courseMenu(); break;
181
196
  case 'status': {
197
+ draw([chalk.gray(' 載入進度統計中,請稍候...')]);
182
198
  const { showStatus } = require('./index');
183
199
  await showStatus();
184
200
  await pressAnyKey();
185
201
  break;
186
202
  }
187
203
  case 'settings': await settingsMenu(); break;
188
- case 'exit': running = false; break;
189
204
  }
190
205
  }
191
206
  console.log(chalk.gray('\n再見!\n'));
192
207
  }
193
208
 
194
- async function runMenu(config) {
209
+ async function runMenu() {
210
+ const config = loadConfig();
195
211
  if (!config.courses.length) {
196
- console.log(chalk.yellow('\n 還沒有設定課程,請先新增\n'));
212
+ draw([chalk.yellow(' 還沒有設定課程,請先新增')]);
197
213
  await pressAnyKey();
198
214
  return;
199
215
  }
200
216
 
201
- const confirm = await promptConfirm(`開始觀看全部 ${config.courses.length} 個課程?`);
202
- if (confirm === null) return;
217
+ const yes = await confirm(`開始觀看全部 ${config.courses.length} 個課程?`);
218
+ if (!yes) return;
203
219
 
204
- const headless = await promptConfirm('無頭模式(背景執行)?', false);
220
+ const headless = await confirm('無頭模式(背景執行)?', false);
205
221
  if (headless === null) return;
206
222
 
207
- console.log('');
208
- box([
209
- chalk.green('開始自動觀看'),
210
- `課程: ${chalk.cyan(config.courses.length)} 個`,
211
- `模式: ${headless ? chalk.gray('無頭') : chalk.white('有頭')}`,
212
- `倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
223
+ draw([
224
+ '',
225
+ chalk.green(' 開始自動觀看'),
226
+ ` 課程: ${chalk.cyan(config.courses.length)} 個`,
227
+ ` 模式: ${headless ? chalk.gray('無頭') : chalk.white('有頭')}`,
228
+ ` 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
213
229
  ]);
214
- console.log('');
215
230
 
216
231
  const { runWithSelection } = require('./index');
217
232
  await runWithSelection(config.courses, headless);
@@ -224,99 +239,53 @@ async function courseMenu() {
224
239
  const config = loadConfig();
225
240
  const courses = config.courses || [];
226
241
 
227
- console.clear();
228
-
229
- let courseNames = {};
230
- if (courses.length > 0) {
231
- try {
232
- const { chromium } = require('playwright');
233
- const cookies = loadCookies();
234
- if (cookies.length > 0) {
235
- const browser = await chromium.launch({ headless: true, slowMo: 50 });
236
- const ctx = await browser.newContext();
237
- await ctx.addCookies(cookies);
238
-
239
- const results = await Promise.all(courses.map(async (url) => {
240
- const match = url.match(/\/course\/(\d+)\//);
241
- if (!match) return { id: '?', name: '' };
242
- const pg = await ctx.newPage();
243
- try {
244
- await pg.goto(`${BASE_URL}/course/${match[1]}/content#/`, { waitUntil: 'domcontentloaded', timeout: 8000 });
245
- await pg.waitForTimeout(2000);
246
- const name = await pg.evaluate(() => {
247
- const el = document.querySelector('.learning-activities');
248
- if (!el) return '';
249
- const scope = angular.element(el).scope();
250
- return (scope && scope.course) ? (scope.course.name || '') : '';
251
- });
252
- return { id: match[1], name: name.substring(0, 30) };
253
- } catch (e) {
254
- return { id: match[1], name: '' };
255
- } finally {
256
- await pg.close();
257
- }
258
- }));
259
-
260
- results.forEach(r => { if (r.name) courseNames[r.id] = r.name; });
261
- await browser.close();
262
- }
263
- } catch (e) {}
264
- }
242
+ draw([chalk.cyan(' 📋 課程列表')]);
265
243
 
266
- const choices = [
244
+ const result = await menu(`課程管理 (${courses.length} 個):`, [
267
245
  { name: chalk.green('➕ 新增課程'), value: 'add' },
268
246
  '__sep__',
269
- ];
270
- courses.forEach((url, i) => {
271
- const match = url.match(/\/course\/(\d+)\//);
272
- const id = match ? match[1] : '?';
273
- const name = courseNames[id] || id;
274
- choices.push({ name: `${chalk.red('✕')} 移除 ${name} [${id}]`, value: `remove_${i}` });
275
- });
276
- if (courses.length > 0) {
277
- choices.push({ name: chalk.red('🗑 清空所有'), value: 'clear' });
278
- }
279
- choices.push('__sep__');
280
- choices.push({ name: chalk.gray('⬅ 返回'), value: 'back' });
281
-
282
- const action = await promptList(`課程管理 (${courses.length} 個):`, choices, [
283
- '',
284
- chalk.white('┌' + '─'.repeat(50) + '┐'),
285
- chalk.white('│') + chalk.bold.white(' 📋 課程列表'.padEnd(50)) + chalk.white('│'),
286
- chalk.white('├' + '─'.repeat(50) + '┤'),
287
247
  ...courses.map((url, i) => {
288
248
  const match = url.match(/\/course\/(\d+)\//);
289
249
  const id = match ? match[1] : '?';
290
- const name = courseNames[id] || id;
291
- return chalk.white('│') + ` ${chalk.white(i + 1 + '.')} ${chalk.cyan(name)} [${id}]`.padEnd(51) + chalk.white('│');
250
+ const name = (config.courseNames || {})[id];
251
+ const label = name ? `${name} [${id}]` : `[${id}]`;
252
+ return { name: `${chalk.red('✕')} 移除 ${label}`, value: `remove_${i}` };
292
253
  }),
293
- chalk.white('└' + '─'.repeat(50) + '┘'),
254
+ ...(courses.length > 0 ? [{ name: chalk.red('🗑 清空所有'), value: 'clear' }] : []),
255
+ '__sep__',
256
+ { name: chalk.gray('⬅ 返回'), value: 'back' }
294
257
  ]);
295
258
 
296
- if (action === '__esc__' || action === 'back') {
297
- editing = false;
298
- } else if (action === 'add') {
299
- const input = await promptInput('輸入課程 ID 或 URL');
300
- if (input && input.trim()) {
301
- config.courses = config.courses || [];
302
- config.courses.push(normalizeUrl(input.trim()));
259
+ const action = result?.value;
260
+ if (!action || action === 'back') { editing = false; continue; }
261
+
262
+ if (action === 'add') {
263
+ const val = await input('輸入課程 ID 或 URL');
264
+ if (val && val.trim()) {
265
+ let url = val.trim();
266
+ if (/^\d+$/.test(url)) url = `${BASE_URL}/course/${url}/content#/`;
267
+ else if (!url.startsWith('http')) url = `${BASE_URL}/course/${url}/content#/`;
268
+ config.courses.push(url);
269
+ delete config.courseNames;
303
270
  saveConfig(config);
304
- console.log(chalk.green(' 已新增!'));
271
+ draw([chalk.green(' 已新增!')]);
305
272
  await pressAnyKey();
306
273
  }
307
274
  } else if (action === 'clear') {
308
- const confirm = await promptConfirm('確定要清空所有課程?', false);
309
- if (confirm) {
275
+ const yes = await confirm('確定要清空所有課程?', false);
276
+ if (yes) {
310
277
  config.courses = [];
278
+ config.courseNames = {};
311
279
  saveConfig(config);
312
- console.log(chalk.green(' 已清空'));
280
+ draw([chalk.green(' 已清空')]);
313
281
  await pressAnyKey();
314
282
  }
315
283
  } else if (typeof action === 'string' && action.startsWith('remove_')) {
316
284
  const idx = parseInt(action.split('_')[1]);
317
- const removed = config.courses.splice(idx, 1)[0];
285
+ config.courses.splice(idx, 1);
286
+ delete config.courseNames;
318
287
  saveConfig(config);
319
- console.log(chalk.green(` 已移除: ${removed}`));
288
+ draw([chalk.green(' 已移除')]);
320
289
  await pressAnyKey();
321
290
  }
322
291
  }
@@ -324,58 +293,49 @@ async function courseMenu() {
324
293
 
325
294
  async function settingsMenu() {
326
295
  const config = loadConfig();
296
+ const speedOptions = [2, 1.5, 1.25, 1, 0.75];
327
297
 
328
- const setting = await promptList('設定:', [
298
+ const result = await menu('設定:', [
329
299
  { name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
330
300
  { name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
331
301
  { name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
302
+ { name: `快取課程名稱: ${config.courseNames && Object.keys(config.courseNames).length > 0 ? chalk.green('已快取') + ` (${Object.keys(config.courseNames).length} 個)` : chalk.red('未快取')}`, value: 'cache' },
332
303
  '__sep__',
333
304
  { name: chalk.gray('⬅ 返回'), value: 'back' }
334
- ], [
335
- '',
336
- chalk.white('╔' + '═'.repeat(50) + '╗'),
337
- chalk.white('║') + chalk.bold.white(' ⚙ 設定'.padEnd(50)) + chalk.white('║'),
338
- chalk.white('╚' + '═'.repeat(50) + '╝'),
339
305
  ]);
340
306
 
341
- if (setting === '__esc__' || setting === 'back') return;
307
+ const action = result?.value;
308
+ if (!action || action === 'back') return;
342
309
 
343
- if (setting === 'speed') {
344
- const speed = await promptList('選擇播放倍速:', [
345
- { name: '2x', value: 2 },
346
- { name: '1.5x', value: 1.5 },
347
- { name: '1.25x', value: 1.25 },
348
- { name: '1x', value: 1 },
349
- { name: '0.75x', value: 0.75 },
350
- ]);
351
- if (speed !== '__esc__') {
310
+ if (action === 'speed') {
311
+ const r = await menu('選擇播放倍速:', speedOptions.map(s => ({ name: `${s}x`, value: s })));
312
+ const speed = r?.value;
313
+ if (speed) {
352
314
  config.playbackRate = speed;
353
315
  saveConfig(config);
354
- console.log(chalk.green(` 已設定為 ${speed}x`));
316
+ draw([chalk.green(` 已設定為 ${speed}x`)]);
317
+ await pressAnyKey();
355
318
  }
356
- } else if (setting === 'headless') {
357
- const hl = await promptConfirm('啟用無頭模式?', config.headless || false);
319
+ } else if (action === 'headless') {
320
+ const hl = await confirm('啟用無頭模式?', config.headless || false);
358
321
  if (hl !== null) {
359
322
  config.headless = hl;
360
323
  saveConfig(config);
361
324
  }
362
- } else if (setting === 'slowmo') {
363
- const ms = await promptInput('SlowMo 毫秒數');
325
+ } else if (action === 'slowmo') {
326
+ const ms = await input('SlowMo 毫秒數');
364
327
  if (ms && !isNaN(parseInt(ms))) {
365
328
  config.slowMo = parseInt(ms);
366
329
  saveConfig(config);
367
330
  }
331
+ } else if (action === 'cache') {
332
+ draw([chalk.gray(' 載入課程名稱...')]);
333
+ const names = await fetchCourseNames(config);
334
+ config.courseNames = names;
335
+ saveConfig(config);
336
+ draw([chalk.green(` 已快取 ${Object.keys(names).length} 個課程名稱`)]);
337
+ await pressAnyKey();
368
338
  }
369
339
  }
370
340
 
371
- function normalizeUrl(input) {
372
- if (/^\d+$/.test(input)) {
373
- return `${BASE_URL}/course/${input}/content#/`;
374
- }
375
- if (!input.startsWith('http')) {
376
- return `${BASE_URL}/course/${input}/content#/`;
377
- }
378
- return input;
379
- }
380
-
381
341
  module.exports = { mainMenu };