cc98-cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/commands/update.d.ts.map +1 -1
  3. package/dist/cli/commands/update.js +49 -3
  4. package/dist/cli/commands/update.js.map +1 -1
  5. package/dist/storage/settings-store.d.ts +12 -0
  6. package/dist/storage/settings-store.d.ts.map +1 -0
  7. package/dist/storage/settings-store.js +46 -0
  8. package/dist/storage/settings-store.js.map +1 -0
  9. package/dist/tui/ansi.d.ts.map +1 -1
  10. package/dist/tui/ansi.js +5 -1
  11. package/dist/tui/ansi.js.map +1 -1
  12. package/dist/tui/app.js +1 -1
  13. package/dist/tui/app.js.map +1 -1
  14. package/dist/tui/components/content.d.ts +2 -0
  15. package/dist/tui/components/content.d.ts.map +1 -1
  16. package/dist/tui/components/content.js +47 -9
  17. package/dist/tui/components/content.js.map +1 -1
  18. package/dist/tui/components/status.d.ts +3 -0
  19. package/dist/tui/components/status.d.ts.map +1 -1
  20. package/dist/tui/components/status.js +21 -8
  21. package/dist/tui/components/status.js.map +1 -1
  22. package/dist/tui/components/utils.d.ts.map +1 -1
  23. package/dist/tui/components/utils.js +33 -12
  24. package/dist/tui/components/utils.js.map +1 -1
  25. package/dist/tui/controller.d.ts +36 -1
  26. package/dist/tui/controller.d.ts.map +1 -1
  27. package/dist/tui/controller.js +802 -124
  28. package/dist/tui/controller.js.map +1 -1
  29. package/dist/tui/emoji-art.d.ts +11 -0
  30. package/dist/tui/emoji-art.d.ts.map +1 -0
  31. package/dist/tui/emoji-art.js +371 -0
  32. package/dist/tui/emoji-art.js.map +1 -0
  33. package/dist/tui/emoji-renderer.d.ts +16 -0
  34. package/dist/tui/emoji-renderer.d.ts.map +1 -0
  35. package/dist/tui/emoji-renderer.js +110 -0
  36. package/dist/tui/emoji-renderer.js.map +1 -0
  37. package/dist/tui/image-renderer.d.ts +46 -0
  38. package/dist/tui/image-renderer.d.ts.map +1 -0
  39. package/dist/tui/image-renderer.js +259 -0
  40. package/dist/tui/image-renderer.js.map +1 -0
  41. package/dist/tui/keybindings.d.ts +24 -0
  42. package/dist/tui/keybindings.d.ts.map +1 -0
  43. package/dist/tui/keybindings.js +207 -0
  44. package/dist/tui/keybindings.js.map +1 -0
  45. package/dist/tui/navigation.d.ts.map +1 -1
  46. package/dist/tui/navigation.js +4 -0
  47. package/dist/tui/navigation.js.map +1 -1
  48. package/dist/tui/renderer.d.ts.map +1 -1
  49. package/dist/tui/renderer.js +181 -11
  50. package/dist/tui/renderer.js.map +1 -1
  51. package/dist/tui/state/store.d.ts.map +1 -1
  52. package/dist/tui/state/store.js +13 -2
  53. package/dist/tui/state/store.js.map +1 -1
  54. package/dist/tui/state/types.d.ts +24 -0
  55. package/dist/tui/state/types.d.ts.map +1 -1
  56. package/dist/tui/terminal-capabilities.d.ts +24 -0
  57. package/dist/tui/terminal-capabilities.d.ts.map +1 -0
  58. package/dist/tui/terminal-capabilities.js +55 -0
  59. package/dist/tui/terminal-capabilities.js.map +1 -0
  60. package/dist/tui/terminal.js +1 -1
  61. package/dist/tui/terminal.js.map +1 -1
  62. package/dist/tui/topic-reader.d.ts +9 -1
  63. package/dist/tui/topic-reader.d.ts.map +1 -1
  64. package/dist/tui/topic-reader.js +30 -14
  65. package/dist/tui/topic-reader.js.map +1 -1
  66. package/dist/tui/ubb-renderer.d.ts +5 -1
  67. package/dist/tui/ubb-renderer.d.ts.map +1 -1
  68. package/dist/tui/ubb-renderer.js +71 -25
  69. package/dist/tui/ubb-renderer.js.map +1 -1
  70. package/dist/update.d.ts +4 -1
  71. package/dist/update.d.ts.map +1 -1
  72. package/dist/update.js +71 -11
  73. package/dist/update.js.map +1 -1
  74. package/dist/version.d.ts +2 -2
  75. package/dist/version.d.ts.map +1 -1
  76. package/dist/version.js +10 -2
  77. package/dist/version.js.map +1 -1
  78. package/package.json +2 -1
  79. package/dist/tui/components/layout.d.ts +0 -3
  80. package/dist/tui/components/layout.d.ts.map +0 -1
  81. package/dist/tui/components/layout.js +0 -453
  82. package/dist/tui/components/layout.js.map +0 -1
@@ -1,10 +1,14 @@
1
1
  import { checkForUpdate } from "../update.js";
2
2
  import { appVersion } from "../version.js";
3
+ import { Cc98Client } from "../api/client.js";
3
4
  import { getImageCache } from "../storage/image-cache.js";
5
+ import { SettingsStore } from "../storage/settings-store.js";
6
+ import { getKeybindingManager } from "./keybindings.js";
7
+ import { EMOJI_CATEGORIES, getEmojiArt, renderCc98Logo, renderEmojiCode } from "./emoji-renderer.js";
4
8
  import { navItems, settingsItems } from "./navigation.js";
5
9
  import { getStatus } from "./state/store.js";
6
10
  import { asArray, asNumber, asObject, chatItem, chatMessageItems, flattenBoards, genericItem, historyItem, isAbortError, jsonPreviewLines, loadChatUserNames, mapLimit, noticeItem, overviewStats, topicItem, unreadStats, userItem } from "./helpers.js";
7
- import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, jumpRelativeTopicFloor } from "./topic-reader.js";
11
+ import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, FLOORS_PER_PAGE } from "./topic-reader.js";
8
12
  export class TuiController {
9
13
  state;
10
14
  client;
@@ -13,8 +17,13 @@ export class TuiController {
13
17
  close;
14
18
  nextSignal;
15
19
  abortCurrent;
20
+ webVpnOptions;
16
21
  loadVersion = 0;
17
- constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent) {
22
+ keybindings;
23
+ settingsStore = new SettingsStore();
24
+ updateChecked = false;
25
+ autoSigninChecked = false;
26
+ constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent, webVpnOptions) {
18
27
  this.state = state;
19
28
  this.client = client;
20
29
  this.tokenStore = tokenStore;
@@ -22,9 +31,12 @@ export class TuiController {
22
31
  this.close = close;
23
32
  this.nextSignal = nextSignal;
24
33
  this.abortCurrent = abortCurrent;
34
+ this.webVpnOptions = webVpnOptions;
35
+ this.keybindings = getKeybindingManager();
25
36
  }
26
37
  async load(force = false) {
27
38
  const version = ++this.loadVersion;
39
+ let shouldAutoSignin = false;
28
40
  const signal = this.nextSignal();
29
41
  const nav = navItems[this.state.navIndex] ?? navItems[0];
30
42
  this.state.viewTitle = nav.label;
@@ -44,7 +56,18 @@ export class TuiController {
44
56
  this.state.currentChat = undefined;
45
57
  this.render();
46
58
  try {
59
+ // 加载快捷键配置
60
+ await this.keybindings.load();
47
61
  this.state.account = await this.tokenStore.getCurrentAccountName();
62
+ // 异步检查更新(不阻塞主加载)
63
+ if (!this.updateChecked) {
64
+ this.updateChecked = true;
65
+ void this.checkUpdate();
66
+ }
67
+ if (!this.autoSigninChecked) {
68
+ this.autoSigninChecked = true;
69
+ shouldAutoSignin = true;
70
+ }
48
71
  const next = await this.loadView(nav.id, force, signal);
49
72
  if (version !== this.loadVersion)
50
73
  return;
@@ -67,6 +90,9 @@ export class TuiController {
67
90
  if (version === this.loadVersion) {
68
91
  this.state.loading = false;
69
92
  this.render();
93
+ if (shouldAutoSignin) {
94
+ void this.runAutoSignin();
95
+ }
70
96
  }
71
97
  }
72
98
  }
@@ -75,11 +101,20 @@ export class TuiController {
75
101
  this.handleInputKey(key);
76
102
  return;
77
103
  }
78
- if (key === "\u0003" || key === "q") {
104
+ // 关闭更新通知(Esc 或任意键)
105
+ if (this.state.updateAvailable?.isNew) {
106
+ if (key === "\x1b" || key === "\r") {
107
+ this.dismissUpdate();
108
+ return;
109
+ }
110
+ // 其他按键也关闭更新通知,继续处理原按键动作
111
+ this.dismissUpdate();
112
+ }
113
+ if (this.keybindings.matches(key, "quit")) {
79
114
  this.close();
80
115
  return;
81
116
  }
82
- if (key === "?") {
117
+ if (this.keybindings.matches(key, "help")) {
83
118
  this.state.modal = this.state.modal === "help" ? null : "help";
84
119
  this.render();
85
120
  return;
@@ -103,17 +138,17 @@ export class TuiController {
103
138
  this.handleContentKey(key);
104
139
  }
105
140
  handleInputKey(key) {
106
- if (key === "\x1b") {
141
+ if (this.keybindings.matches(key, "inputCancel")) {
107
142
  this.state.inputMode = false;
108
143
  this.state.inputValue = "";
109
144
  this.render();
110
145
  return;
111
146
  }
112
- if (key === "\r") {
147
+ if (this.keybindings.matches(key, "inputConfirm")) {
113
148
  this.state.inputCallback?.(this.state.inputValue);
114
149
  return;
115
150
  }
116
- if (key === "\x7f") {
151
+ if (this.keybindings.matches(key, "inputBackspace")) {
117
152
  this.state.inputValue = this.state.inputValue.slice(0, -1);
118
153
  this.render();
119
154
  return;
@@ -124,8 +159,19 @@ export class TuiController {
124
159
  }
125
160
  }
126
161
  handleModalKey(key) {
127
- if (this.state.modal === "help" || this.state.modal === "info") {
128
- // 帮助/信息弹窗:任意键关闭
162
+ if (this.state.modal === "help") {
163
+ this.closeModal();
164
+ return;
165
+ }
166
+ if (this.state.modal === "info") {
167
+ // 如果有确认回调,确认键执行回调,其它键关闭。
168
+ if (this.state.confirmCallback && this.keybindings.matches(key, "confirm")) {
169
+ const callback = this.state.confirmCallback;
170
+ this.state.confirmCallback = undefined;
171
+ this.closeModal();
172
+ callback();
173
+ return;
174
+ }
129
175
  this.closeModal();
130
176
  return;
131
177
  }
@@ -142,31 +188,30 @@ export class TuiController {
142
188
  }
143
189
  }
144
190
  handleSearchKey(key) {
145
- if (key === "\x1b" || key === "/") {
146
- // Esc 或 / 关闭搜索
191
+ if (this.keybindings.matches(key, "searchClose")) {
147
192
  this.state.modal = null;
148
193
  this.state.searchQuery = "";
149
194
  this.render();
150
195
  return;
151
196
  }
152
- if (key === "\t") {
197
+ if (this.keybindings.matches(key, "searchToggleMode")) {
153
198
  this.state.searchMode = this.state.searchMode === "topics" ? "users" : "topics";
154
199
  this.state.searchResults = [];
155
200
  this.state.itemIndex = 0;
156
201
  this.render();
157
202
  return;
158
203
  }
159
- if ((key === "j" || key === "\x1b[B") && this.state.searchResults.length > 0) {
204
+ if (this.keybindings.matches(key, "searchNext") && this.state.searchResults.length > 0) {
160
205
  this.state.itemIndex = Math.min(this.state.searchResults.length - 1, this.state.itemIndex + 1);
161
206
  this.render();
162
207
  return;
163
208
  }
164
- if ((key === "k" || key === "\x1b[A") && this.state.searchResults.length > 0) {
209
+ if (this.keybindings.matches(key, "searchPrev") && this.state.searchResults.length > 0) {
165
210
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
166
211
  this.render();
167
212
  return;
168
213
  }
169
- if (key === "\r") {
214
+ if (this.keybindings.matches(key, "searchExecute")) {
170
215
  const selected = this.state.searchResults[this.state.itemIndex];
171
216
  if (selected) {
172
217
  // 有选中项:打开
@@ -222,23 +267,22 @@ export class TuiController {
222
267
  }
223
268
  }
224
269
  handleMenuKey(key) {
225
- if (key === "j" || key === "\x1b[B") {
270
+ if (this.keybindings.matches(key, "menuNext")) {
226
271
  this.state.menuIndex = Math.min(Math.max(0, this.state.menuItems.length - 1), this.state.menuIndex + 1);
227
272
  this.render();
228
273
  return;
229
274
  }
230
- if (key === "k" || key === "\x1b[A") {
275
+ if (this.keybindings.matches(key, "menuPrev")) {
231
276
  this.state.menuIndex = Math.max(0, this.state.menuIndex - 1);
232
277
  this.render();
233
278
  return;
234
279
  }
235
- if (key === "\x1b" || key === "o") {
236
- // Esc 或 o 关闭菜单
280
+ if (this.keybindings.matches(key, "menuClose")) {
237
281
  this.state.modal = null;
238
282
  this.render();
239
283
  return;
240
284
  }
241
- if (key === "\r" || key === "l") {
285
+ if (this.keybindings.matches(key, "menuExecute")) {
242
286
  // Enter 或 l 执行选中项
243
287
  const selected = this.state.menuItems[this.state.menuIndex];
244
288
  this.state.modal = null;
@@ -250,84 +294,137 @@ export class TuiController {
250
294
  }
251
295
  }
252
296
  handleTopicKey(key) {
297
+ // 数字输入:收集跳转目标
253
298
  if (/^\d$/.test(key) && this.state.topic) {
254
299
  this.state.topic.floorInput = `${this.state.topic.floorInput}${key}`.slice(0, 6);
255
- this.state.status = `跳转到 ${this.state.topic.floorInput} 楼:Enter 确认 Esc 取消`;
300
+ this.state.status = `输入: ${this.state.topic.floorInput}`;
256
301
  this.render();
257
302
  return;
258
303
  }
259
- if (key === "\x7f" && this.state.topic?.floorInput) {
304
+ // 退格:删除输入
305
+ if (this.keybindings.matches(key, "inputBackspace") && this.state.topic?.floorInput) {
260
306
  this.state.topic.floorInput = this.state.topic.floorInput.slice(0, -1);
261
- this.state.status = this.state.topic.floorInput ? `跳转到 ${this.state.topic.floorInput} 楼:Enter 确认 Esc 取消` : getStatus(this.state);
307
+ this.state.status = this.state.topic.floorInput ? `输入: ${this.state.topic.floorInput}` : "";
262
308
  this.render();
263
309
  return;
264
310
  }
265
- if (key === "\r" && this.state.topic?.floorInput) {
266
- const floor = Number(this.state.topic.floorInput);
311
+ // 数字 + 跳页键:跳页
312
+ if (this.keybindings.matches(key, "topicJumpPage") && this.state.topic?.floorInput) {
313
+ const page = Number(this.state.topic.floorInput);
314
+ this.state.topic.jumpTarget = { type: "page", value: page };
267
315
  this.state.topic.floorInput = "";
268
- if (Number.isInteger(floor) && floor > 0)
269
- void this.jumpToTopicFloor(floor, this.nextSignal());
316
+ this.state.status = `跳转到第 ${page} 页?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
317
+ this.render();
270
318
  return;
271
319
  }
272
- if ((key === "]" || key === "】") && this.state.topic) {
273
- this.state.scroll = jumpRelativeTopicFloor(this.state.topic, this.state.scroll, 1);
274
- this.state.status = getStatus(this.state);
320
+ // 数字 + 跳楼键:跳楼
321
+ if (this.keybindings.matches(key, "topicJumpFloor") && this.state.topic?.floorInput) {
322
+ const floor = Number(this.state.topic.floorInput);
323
+ this.state.topic.jumpTarget = { type: "floor", value: floor };
324
+ this.state.topic.floorInput = "";
325
+ this.state.status = `跳转到第 ${floor} 楼?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
275
326
  this.render();
276
327
  return;
277
328
  }
278
- if ((key === "[" || key === "【") && this.state.topic) {
279
- this.state.scroll = jumpRelativeTopicFloor(this.state.topic, this.state.scroll, -1);
280
- this.state.status = getStatus(this.state);
329
+ // 跳到最后一页
330
+ if (this.keybindings.matches(key, "topicJumpLast") && !this.state.topic?.floorInput && this.state.topic) {
331
+ const pageInfo = getTopicPageInfo(this.state.topic, this.state.topic.cursorLine);
332
+ this.state.topic.jumpTarget = { type: "page", value: pageInfo.totalPages };
333
+ this.state.status = `跳转到最后一页(第 ${pageInfo.totalPages} 页)?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
281
334
  this.render();
282
335
  return;
283
336
  }
284
- if (key === "\x1b" && this.state.topic?.floorInput) {
337
+ // 确认跳转
338
+ if (this.keybindings.matches(key, "confirm") && this.state.topic?.jumpTarget) {
339
+ const target = this.state.topic.jumpTarget;
340
+ this.state.topic.jumpTarget = undefined;
341
+ this.state.status = "";
342
+ if (target.type === "page") {
343
+ void this.jumpToTopicPage(target.value, this.nextSignal());
344
+ }
345
+ else {
346
+ void this.jumpToTopicFloor(target.value, this.nextSignal());
347
+ }
348
+ return;
349
+ }
350
+ // 取消跳转
351
+ if (this.keybindings.matches(key, "back") && (this.state.topic?.floorInput || this.state.topic?.jumpTarget)) {
285
352
  this.state.topic.floorInput = "";
286
- this.state.status = getStatus(this.state);
353
+ this.state.topic.jumpTarget = undefined;
354
+ this.state.status = "";
287
355
  this.render();
288
356
  return;
289
357
  }
290
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
358
+ // ]:下一层
359
+ if (this.keybindings.matches(key, "topicNextFloor") && this.state.topic) {
360
+ void this.jumpRelativeFloor(1);
361
+ return;
362
+ }
363
+ // [:上一层
364
+ if (this.keybindings.matches(key, "topicPrevFloor") && this.state.topic) {
365
+ void this.jumpRelativeFloor(-1);
366
+ return;
367
+ }
368
+ // }:下一页
369
+ if (this.keybindings.matches(key, "topicNextPage") && this.state.topic) {
370
+ void this.jumpToNextPage();
371
+ return;
372
+ }
373
+ // {:上一页
374
+ if (this.keybindings.matches(key, "topicPrevPage") && this.state.topic) {
375
+ void this.jumpToPrevPage();
376
+ return;
377
+ }
378
+ // h/Esc:返回
379
+ if (this.keybindings.matches(key, "back")) {
291
380
  this.leave();
292
381
  return;
293
382
  }
294
- if (key === "j" || key === "\x1b[B") {
383
+ // j:下移
384
+ if (this.keybindings.matches(key, "topicScrollDown")) {
295
385
  const maxScroll = Math.max(0, (this.state.topic?.lines.length ?? 0) - 1);
296
- const wasAtEnd = this.state.scroll >= maxScroll;
297
- this.state.scroll = Math.min(maxScroll, this.state.scroll + 1);
298
- this.render();
299
- if (wasAtEnd && this.state.topic?.hasMore && !this.state.loadingMore) {
300
- void this.loadNextTopicPage(this.nextSignal(), true);
386
+ if (this.state.topic) {
387
+ this.state.topic.cursorLine = Math.min(maxScroll, this.state.topic.cursorLine + 1);
301
388
  }
302
- return;
303
- }
304
- if (key === "k" || key === "\x1b[A") {
305
- this.state.scroll = Math.max(0, this.state.scroll - 1);
389
+ this.state.status = "";
306
390
  this.render();
391
+ void this.checkAutoLoad();
307
392
  return;
308
393
  }
309
- if (key === "n" || key === " ") {
310
- void this.loadNextTopicPage(this.nextSignal());
394
+ // k:上移
395
+ if (this.keybindings.matches(key, "topicScrollUp")) {
396
+ if (this.state.topic) {
397
+ this.state.topic.cursorLine = Math.max(0, this.state.topic.cursorLine - 1);
398
+ }
399
+ this.state.status = "";
400
+ this.render();
311
401
  return;
312
402
  }
313
- if (key === "r" && this.state.topic) {
403
+ // r:刷新
404
+ if (this.keybindings.matches(key, "topicRefresh") && this.state.topic) {
314
405
  void this.openTopic(this.state.topic.topicId, true, this.nextSignal());
315
406
  return;
316
407
  }
317
- if (key === "s")
408
+ // s:收藏
409
+ if (this.keybindings.matches(key, "topicFavorite"))
318
410
  void this.toggleFavorite();
319
- if (key === "l")
411
+ // l:点赞
412
+ if (this.keybindings.matches(key, "topicLike"))
320
413
  void this.reactToCurrentPost(true);
321
- if (key === "d")
414
+ // d:踩
415
+ if (this.keybindings.matches(key, "topicDislike"))
322
416
  void this.reactToCurrentPost(false);
323
- if (key === "u")
417
+ // u:查看用户
418
+ if (this.keybindings.matches(key, "topicUser"))
324
419
  void this.showCurrentUser(this.nextSignal());
325
- if (key === "v")
420
+ // v:查看投票
421
+ if (this.keybindings.matches(key, "topicVote"))
326
422
  void this.showTopicVote(this.nextSignal());
327
- if (key === "a")
423
+ // a:查看评价
424
+ if (this.keybindings.matches(key, "topicReaction"))
328
425
  void this.showPostReactionState(this.nextSignal());
329
- if (key === "o") {
330
- // 如果当前行是图片行,打开图片;否则打开菜单
426
+ // o:打开图片/菜单
427
+ if (this.keybindings.matches(key, "topicOpenImage")) {
331
428
  const currentLine = this.getCurrentTopicLine();
332
429
  if (currentLine?.kind === "image" && currentLine.imageUrl) {
333
430
  void this.openImage(currentLine.imageUrl);
@@ -336,11 +433,11 @@ export class TuiController {
336
433
  this.openMenu();
337
434
  }
338
435
  }
339
- if (key === "c") {
340
- // 复制图片链接
436
+ // c:图片复制图片本体,链接复制 URL
437
+ if (this.keybindings.matches(key, "topicCopyLink")) {
341
438
  const currentLine = this.getCurrentTopicLine();
342
439
  if (currentLine?.kind === "image" && currentLine.imageUrl) {
343
- void this.copyToClipboard(currentLine.imageUrl);
440
+ void this.copyImageToClipboard(currentLine.imageUrl);
344
441
  }
345
442
  else if (currentLine?.kind === "link" && currentLine.linkUrl) {
346
443
  void this.copyToClipboard(currentLine.linkUrl);
@@ -348,39 +445,40 @@ export class TuiController {
348
445
  }
349
446
  }
350
447
  handleSettingsKey(key) {
351
- if (key === "j" || key === "\x1b[B") {
352
- this.state.itemIndex = Math.min(settingsItems.length - 1, this.state.itemIndex + 1);
448
+ const itemCount = this.state.items.length || settingsItems.length;
449
+ if (this.keybindings.matches(key, "moveDown")) {
450
+ this.state.itemIndex = Math.min(itemCount - 1, this.state.itemIndex + 1);
353
451
  this.render();
354
452
  return;
355
453
  }
356
- if (key === "k" || key === "\x1b[A") {
454
+ if (this.keybindings.matches(key, "moveUp")) {
357
455
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
358
456
  this.render();
359
457
  return;
360
458
  }
361
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
459
+ if (this.keybindings.matches(key, "back")) {
362
460
  this.state.mode = "list";
363
461
  this.state.focus = "nav";
364
462
  this.state.status = getStatus(this.state);
365
463
  this.render();
366
464
  return;
367
465
  }
368
- if (key === "l" || key === "\x1b[C" || key === "\r") {
369
- void this.activateSetting(settingsItems[this.state.itemIndex]);
466
+ if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
467
+ void this.activateSetting(this.state.items[this.state.itemIndex] ?? settingsItems[this.state.itemIndex]);
370
468
  }
371
469
  }
372
470
  handleNavKey(key) {
373
- if (key === "j" || key === "\x1b[B") {
471
+ if (this.keybindings.matches(key, "moveDown")) {
374
472
  this.state.navIndex = Math.min(navItems.length - 1, this.state.navIndex + 1);
375
473
  void this.load();
376
474
  return;
377
475
  }
378
- if (key === "k" || key === "\x1b[A") {
476
+ if (this.keybindings.matches(key, "moveUp")) {
379
477
  this.state.navIndex = Math.max(0, this.state.navIndex - 1);
380
478
  void this.load();
381
479
  return;
382
480
  }
383
- if (key === "l" || key === "\x1b[C" || key === "\r") {
481
+ if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
384
482
  if (!this.state.loading && this.state.items.length > 0) {
385
483
  if (navItems[this.state.navIndex]?.id === "settings")
386
484
  this.state.mode = "settings";
@@ -391,25 +489,25 @@ export class TuiController {
391
489
  }
392
490
  return;
393
491
  }
394
- if (key === "r")
492
+ if (this.keybindings.matches(key, "refresh"))
395
493
  void this.load(true);
396
494
  }
397
495
  handleContentKey(key) {
398
- if (key === "j" || key === "\x1b[B") {
496
+ if (this.keybindings.matches(key, "listNext")) {
399
497
  this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), this.state.itemIndex + 1);
400
498
  this.render();
401
499
  return;
402
500
  }
403
- if (key === "k" || key === "\x1b[A") {
501
+ if (this.keybindings.matches(key, "listPrev")) {
404
502
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
405
503
  this.render();
406
504
  return;
407
505
  }
408
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
506
+ if (this.keybindings.matches(key, "listBack")) {
409
507
  this.leave();
410
508
  return;
411
509
  }
412
- if (key === "l" || key === "\x1b[C" || key === "\r") {
510
+ if (this.keybindings.matches(key, "listOpen")) {
413
511
  const selected = this.state.items[this.state.itemIndex];
414
512
  if (selected) {
415
513
  void this.activateContentItem(selected, this.nextSignal());
@@ -420,15 +518,11 @@ export class TuiController {
420
518
  }
421
519
  return;
422
520
  }
423
- if ((key === "n" || key === " ") && this.state.currentChat) {
424
- void this.loadNextChatPage(this.nextSignal());
425
- return;
426
- }
427
- if (key === "r")
521
+ if (this.keybindings.matches(key, "listRefresh"))
428
522
  void this.refresh();
429
- if (key === "/")
523
+ if (this.keybindings.matches(key, "search"))
430
524
  this.openSearch();
431
- if (key === "o")
525
+ if (this.keybindings.matches(key, "menu"))
432
526
  this.openMenu();
433
527
  }
434
528
  leave() {
@@ -625,11 +719,18 @@ export class TuiController {
625
719
  };
626
720
  }
627
721
  case "settings": {
628
- const cacheStats = await this.client.getCacheStats();
722
+ const [cacheStats, autoSignin] = await Promise.all([
723
+ this.client.getCacheStats(),
724
+ this.settingsStore.isAutoSigninEnabled()
725
+ ]);
629
726
  return {
630
727
  title: "设置",
631
- items: [...settingsItems],
632
- stats: [{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` }, { title: "版本", detail: `v${appVersion}` }],
728
+ items: this.renderSettingsItems(autoSignin),
729
+ stats: [
730
+ { title: "自动签到", detail: autoSignin ? "已开启" : "已关闭" },
731
+ { title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` },
732
+ { title: "版本", detail: `v${appVersion}` }
733
+ ],
633
734
  status: "设置:j/k 选择 Enter 执行 h 返回"
634
735
  };
635
736
  }
@@ -643,34 +744,97 @@ export class TuiController {
643
744
  this.render();
644
745
  return;
645
746
  }
747
+ if (selected.meta === "keybindings") {
748
+ void this.openKeybindingEditor();
749
+ return;
750
+ }
646
751
  if (selected.meta === "cache") {
647
- this.state.status = "正在清理缓存...";
648
- this.render();
649
- try {
650
- await this.client.clearCache();
651
- this.state.status = "缓存已清理";
652
- await this.load(true);
653
- }
654
- catch {
655
- this.state.status = "缓存清理失败";
656
- this.render();
657
- }
752
+ void this.openCacheManager();
753
+ return;
754
+ }
755
+ if (selected.meta === "pixel-logo") {
756
+ this.openPixelLogo();
757
+ return;
758
+ }
759
+ if (selected.meta === "emoji-preview") {
760
+ this.openEmojiPreview();
658
761
  return;
659
762
  }
660
763
  if (selected.meta === "update") {
661
- this.state.status = "正在检查 GitHub Release...";
662
- this.render();
764
+ void this.checkUpdate(true);
765
+ return;
766
+ }
767
+ if (selected.meta === "account") {
768
+ void this.openAccountSwitcher();
769
+ return;
770
+ }
771
+ if (selected.meta === "auto-signin") {
772
+ void this.toggleAutoSignin();
773
+ return;
774
+ }
775
+ if (selected.meta === "logout") {
776
+ void this.confirmLogout();
777
+ return;
778
+ }
779
+ this.state.status = "功能开发中...";
780
+ this.render();
781
+ }
782
+ renderSettingsItems(autoSignin) {
783
+ return settingsItems.map((item) => {
784
+ if (item.meta !== "auto-signin") {
785
+ return { ...item };
786
+ }
787
+ return {
788
+ ...item,
789
+ title: `自动签到: ${autoSignin ? "开启" : "关闭"}`,
790
+ detail: autoSignin
791
+ ? "启动后为所有账号执行每日签到"
792
+ : "默认关闭,启动时不自动签到"
793
+ };
794
+ });
795
+ }
796
+ async toggleAutoSignin() {
797
+ const enabled = await this.settingsStore.isAutoSigninEnabled();
798
+ const next = !enabled;
799
+ await this.settingsStore.setAutoSigninEnabled(next);
800
+ this.state.items = this.renderSettingsItems(next);
801
+ this.state.stats = [
802
+ { title: "自动签到", detail: next ? "已开启" : "已关闭" },
803
+ ...this.state.stats.filter((item) => item.title !== "自动签到")
804
+ ];
805
+ this.state.status = next ? "已开启自动签到" : "已关闭自动签到";
806
+ this.render();
807
+ }
808
+ async runAutoSignin() {
809
+ const enabled = await this.settingsStore.isAutoSigninEnabled();
810
+ if (!enabled)
811
+ return;
812
+ const accounts = await this.tokenStore.listAccounts();
813
+ if (accounts.length === 0)
814
+ return;
815
+ let success = 0;
816
+ let failed = 0;
817
+ this.state.status = `自动签到: 0/${accounts.length}`;
818
+ this.render();
819
+ for (const account of accounts) {
663
820
  try {
664
- const result = await checkForUpdate();
665
- this.state.status = result.message;
821
+ const tokenStore = this.tokenStore.withAccount(account.account);
822
+ const client = new Cc98Client({ tokenStore, webVpn: this.webVpnOptions });
823
+ if (this.webVpnOptions) {
824
+ await client.initWebVpn();
825
+ }
826
+ await client.signin();
827
+ success += 1;
666
828
  }
667
- catch (error) {
668
- this.state.status = error instanceof Error ? error.message : "检查更新失败";
829
+ catch {
830
+ failed += 1;
669
831
  }
832
+ this.state.status = `自动签到: ${success + failed}/${accounts.length}`;
670
833
  this.render();
671
- return;
672
834
  }
673
- this.state.status = selected.meta === "logout" ? "退出登录功能开发中..." : "账号切换功能开发中...";
835
+ this.state.status = failed > 0
836
+ ? `自动签到完成: ${success} 成功,${failed} 失败`
837
+ : `自动签到完成: ${success} 个账号`;
674
838
  this.render();
675
839
  }
676
840
  async activateContentItem(selected, signal) {
@@ -690,6 +854,25 @@ export class TuiController {
690
854
  await this.showUserDetailById(selected.userId, signal);
691
855
  return;
692
856
  }
857
+ // 账号切换
858
+ if (selected.meta?.startsWith("account:")) {
859
+ const accountName = selected.meta.slice(8);
860
+ await this.switchAccount(accountName);
861
+ return;
862
+ }
863
+ if (selected.meta?.startsWith("emoji-category:")) {
864
+ this.openEmojiCategory(selected.meta.slice("emoji-category:".length));
865
+ return;
866
+ }
867
+ if (selected.meta?.startsWith("emoji-batch:")) {
868
+ this.state.status = "继续向下选择具体表情,Enter 放大预览";
869
+ this.render();
870
+ return;
871
+ }
872
+ if (selected.meta?.startsWith("emoji:")) {
873
+ this.openEmojiDetail(selected.meta.slice("emoji:".length));
874
+ return;
875
+ }
693
876
  if (selected.action?.startsWith("notices:")) {
694
877
  await this.openNoticeList(selected.action.split(":")[1], signal);
695
878
  return;
@@ -725,6 +908,10 @@ export class TuiController {
725
908
  }
726
909
  }
727
910
  this.render();
911
+ // Start background image preloading so preview/open/copy can reuse the local cache.
912
+ if (this.state.topic) {
913
+ void this.preloadTopicImages(this.state.topic);
914
+ }
728
915
  }
729
916
  async openBoard(boardId, title, force, signal, pushParent = true) {
730
917
  if (pushParent)
@@ -800,6 +987,94 @@ export class TuiController {
800
987
  this.render();
801
988
  }
802
989
  }
990
+ async checkAutoLoad() {
991
+ const topic = this.state.topic;
992
+ if (!topic?.hasMore || this.state.loadingMore)
993
+ return;
994
+ const viewportRows = Math.max(1, topic.viewportRows);
995
+ const viewportBottom = this.state.scroll + viewportRows;
996
+ if (viewportBottom >= topic.lines.length) {
997
+ void this.loadNextTopicPage(this.nextSignal(), true);
998
+ }
999
+ }
1000
+ async jumpRelativeFloor(delta) {
1001
+ const topic = this.state.topic;
1002
+ if (!topic)
1003
+ return;
1004
+ const current = currentTopicPost(topic, topic.cursorLine);
1005
+ const currentFloor = current?.floor ?? 1;
1006
+ const targetFloor = currentFloor + delta;
1007
+ if (targetFloor < 1)
1008
+ return;
1009
+ const loaded = findTopicPostByFloor(topic, targetFloor);
1010
+ if (loaded) {
1011
+ topic.cursorLine = loaded.lineStart;
1012
+ this.state.status = "";
1013
+ this.render();
1014
+ if (delta > 0)
1015
+ void this.checkAutoLoad();
1016
+ return;
1017
+ }
1018
+ if (delta > 0 && topic.hasMore && !this.state.loadingMore) {
1019
+ await this.jumpToTopicFloor(targetFloor, this.nextSignal());
1020
+ }
1021
+ }
1022
+ async jumpToNextPage() {
1023
+ const topic = this.state.topic;
1024
+ if (!topic)
1025
+ return;
1026
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
1027
+ if (pageInfo.currentPage < pageInfo.totalPages) {
1028
+ await this.jumpToTopicPage(pageInfo.currentPage + 1, this.nextSignal());
1029
+ }
1030
+ else if (topic.hasMore && !this.state.loadingMore) {
1031
+ // 当前是最后一页,但还有更多内容,加载下一页
1032
+ await this.loadNextTopicPage(this.nextSignal());
1033
+ const newPageInfo = getTopicPageInfo(topic, topic.cursorLine);
1034
+ topic.cursorLine = jumpToPage(topic, newPageInfo.currentPage + 1);
1035
+ this.state.status = "";
1036
+ this.render();
1037
+ }
1038
+ }
1039
+ async jumpToPrevPage() {
1040
+ const topic = this.state.topic;
1041
+ if (!topic)
1042
+ return;
1043
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
1044
+ if (pageInfo.currentPage > 1) {
1045
+ topic.cursorLine = jumpToPage(topic, pageInfo.currentPage - 1);
1046
+ this.state.status = "";
1047
+ this.render();
1048
+ }
1049
+ }
1050
+ async jumpToTopicPage(page, signal) {
1051
+ const topic = this.state.topic;
1052
+ if (!topic)
1053
+ return;
1054
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
1055
+ if (page < 1 || page > pageInfo.totalPages) {
1056
+ this.state.status = `未找到第 ${page} 页`;
1057
+ this.render();
1058
+ return;
1059
+ }
1060
+ // 如果目标页未加载,需要加载到该页
1061
+ const targetFloor = (page - 1) * FLOORS_PER_PAGE + 1;
1062
+ while (topic.hasMore && !findTopicPostByFloor(topic, targetFloor)) {
1063
+ const previousLoaded = topic.loaded;
1064
+ await this.loadNextTopicPage(signal, true);
1065
+ if (topic.loaded === previousLoaded)
1066
+ break;
1067
+ }
1068
+ const post = findTopicPostByFloor(topic, targetFloor);
1069
+ if (post) {
1070
+ topic.cursorLine = post.lineStart;
1071
+ this.state.status = "";
1072
+ }
1073
+ else {
1074
+ this.state.status = `未找到第 ${page} 页`;
1075
+ }
1076
+ this.render();
1077
+ }
803
1078
  async loadNextTopicPage(signal, quiet = false) {
804
1079
  const topic = this.state.topic;
805
1080
  if (!topic?.hasMore || this.state.loadingMore)
@@ -810,7 +1085,8 @@ export class TuiController {
810
1085
  try {
811
1086
  const posts = asArray(await this.client.getTopicPosts(topic.topicId, topic.loaded, topic.size, false, signal));
812
1087
  appendTopicPosts(topic, posts);
813
- this.state.status = topic.hasMore ? "已加载下一页" : "已到底";
1088
+ this.state.status = "";
1089
+ void this.preloadTopicImages(topic);
814
1090
  }
815
1091
  catch (error) {
816
1092
  if (!isAbortError(error))
@@ -827,17 +1103,20 @@ export class TuiController {
827
1103
  return;
828
1104
  const loaded = findTopicPostByFloor(topic, floor);
829
1105
  if (loaded) {
830
- this.state.scroll = loaded.lineStart;
831
- this.state.status = getStatus(this.state);
1106
+ topic.cursorLine = loaded.lineStart;
1107
+ this.state.status = "";
832
1108
  this.render();
833
1109
  return;
834
1110
  }
835
1111
  while (topic.hasMore && !findTopicPostByFloor(topic, floor)) {
1112
+ const previousLoaded = topic.loaded;
836
1113
  await this.loadNextTopicPage(signal, true);
1114
+ if (topic.loaded === previousLoaded)
1115
+ break;
837
1116
  }
838
1117
  const post = findTopicPostByFloor(topic, floor);
839
- this.state.scroll = post?.lineStart ?? this.state.scroll;
840
- this.state.status = post ? getStatus(this.state) : `未找到 ${floor} 楼`;
1118
+ topic.cursorLine = post?.lineStart ?? topic.cursorLine;
1119
+ this.state.status = post ? "" : `未找到 ${floor} 楼`;
841
1120
  this.render();
842
1121
  }
843
1122
  async runReadOnlyAction(action, signal) {
@@ -955,7 +1234,7 @@ export class TuiController {
955
1234
  const topic = this.state.topic;
956
1235
  if (!topic)
957
1236
  return;
958
- const post = currentTopicPost(topic, this.state.scroll);
1237
+ const post = currentTopicPost(topic, topic.cursorLine);
959
1238
  if (!post?.id) {
960
1239
  this.state.status = "当前楼层没有可操作的帖子 ID";
961
1240
  this.render();
@@ -974,7 +1253,7 @@ export class TuiController {
974
1253
  const topic = this.state.topic;
975
1254
  if (!topic)
976
1255
  return;
977
- const post = currentTopicPost(topic, this.state.scroll);
1256
+ const post = currentTopicPost(topic, topic.cursorLine);
978
1257
  if (!post?.userId) {
979
1258
  this.state.status = "当前楼层没有用户 ID";
980
1259
  this.render();
@@ -1030,7 +1309,7 @@ export class TuiController {
1030
1309
  const topic = this.state.topic;
1031
1310
  if (!topic)
1032
1311
  return;
1033
- const post = currentTopicPost(topic, this.state.scroll);
1312
+ const post = currentTopicPost(topic, topic.cursorLine);
1034
1313
  if (!post?.id)
1035
1314
  return;
1036
1315
  try {
@@ -1081,12 +1360,60 @@ export class TuiController {
1081
1360
  if (!topic)
1082
1361
  return undefined;
1083
1362
  for (const post of topic.posts) {
1084
- const line = post.lines.find((entry) => entry.line === this.state.scroll);
1363
+ const line = post.lines.find((entry) => entry.line === topic.cursorLine);
1085
1364
  if (line)
1086
1365
  return line;
1087
1366
  }
1088
1367
  return undefined;
1089
1368
  }
1369
+ /**
1370
+ * Preload images for topic in background
1371
+ * Images are cached and trigger re-render when ready
1372
+ */
1373
+ async preloadTopicImages(topic) {
1374
+ const cache = getImageCache();
1375
+ const imagesToLoad = new Set();
1376
+ // Collect all unique image URLs from posts
1377
+ for (const post of topic.posts) {
1378
+ for (const imageUrl of post.images) {
1379
+ if (imageUrl && !topic.imageCache.has(imageUrl) && !topic.imageLoading.has(imageUrl)) {
1380
+ imagesToLoad.add(imageUrl);
1381
+ }
1382
+ }
1383
+ }
1384
+ if (imagesToLoad.size === 0)
1385
+ return;
1386
+ // Load images in parallel with concurrency limit
1387
+ const urls = Array.from(imagesToLoad);
1388
+ const concurrency = 3;
1389
+ for (let i = 0; i < urls.length; i += concurrency) {
1390
+ // Check if topic is still the same (user might have navigated away)
1391
+ if (this.state.topic !== topic)
1392
+ break;
1393
+ const batch = urls.slice(i, i + concurrency);
1394
+ let shouldRender = false;
1395
+ const promises = batch.map(async (url) => {
1396
+ topic.imageLoading.add(url);
1397
+ try {
1398
+ const localPath = await cache.getOrDownload(url);
1399
+ topic.imageErrors.delete(url);
1400
+ topic.imageCache.set(url, localPath);
1401
+ shouldRender = true;
1402
+ }
1403
+ catch (error) {
1404
+ topic.imageErrors.set(url, error instanceof Error ? error.message : "下载失败");
1405
+ shouldRender = true;
1406
+ }
1407
+ finally {
1408
+ topic.imageLoading.delete(url);
1409
+ }
1410
+ });
1411
+ await Promise.all(promises);
1412
+ if (shouldRender && this.state.topic === topic) {
1413
+ this.render();
1414
+ }
1415
+ }
1416
+ }
1090
1417
  async openImage(url) {
1091
1418
  this.state.status = "正在下载图片...";
1092
1419
  this.render();
@@ -1099,7 +1426,11 @@ export class TuiController {
1099
1426
  const { execFile } = await import("node:child_process");
1100
1427
  const platform = process.platform;
1101
1428
  const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
1102
- const args = platform === "win32" ? ["/c", "start", "", localPath] : [localPath];
1429
+ const args = platform === "win32"
1430
+ ? ["/c", "start", "", localPath]
1431
+ : platform === "darwin"
1432
+ ? ["-a", "Preview", localPath]
1433
+ : [localPath];
1103
1434
  execFile(command, args, (error) => {
1104
1435
  if (error) {
1105
1436
  this.state.status = `打开失败: ${error.message}`;
@@ -1112,6 +1443,58 @@ export class TuiController {
1112
1443
  this.render();
1113
1444
  }
1114
1445
  }
1446
+ async copyImageToClipboard(url) {
1447
+ this.state.status = "正在复制图片...";
1448
+ this.render();
1449
+ try {
1450
+ const cache = getImageCache();
1451
+ const localPath = await cache.getOrDownload(url);
1452
+ await this.copyImageFileToClipboard(localPath);
1453
+ this.state.status = "已复制图片到剪贴板";
1454
+ this.render();
1455
+ }
1456
+ catch (error) {
1457
+ this.state.status = error instanceof Error ? error.message : "复制图片失败";
1458
+ this.render();
1459
+ }
1460
+ }
1461
+ async copyImageFileToClipboard(localPath) {
1462
+ const platform = process.platform;
1463
+ if (platform === "darwin") {
1464
+ await this.copyImageFileToClipboardMac(localPath);
1465
+ return;
1466
+ }
1467
+ if (platform === "win32") {
1468
+ const script = [
1469
+ "Add-Type -AssemblyName System.Windows.Forms",
1470
+ "Add-Type -AssemblyName System.Drawing",
1471
+ "$image=[System.Drawing.Image]::FromFile($args[0])",
1472
+ "[System.Windows.Forms.Clipboard]::SetImage($image)",
1473
+ "$image.Dispose()"
1474
+ ].join("; ");
1475
+ await execFilePromise("powershell.exe", ["-NoProfile", "-Command", script, localPath]);
1476
+ return;
1477
+ }
1478
+ const mime = imageMimeType(localPath);
1479
+ await execFilePromise("xclip", ["-selection", "clipboard", "-t", mime, localPath]);
1480
+ }
1481
+ async copyImageFileToClipboardMac(localPath) {
1482
+ const { mkdtemp, rm } = await import("node:fs/promises");
1483
+ const { tmpdir } = await import("node:os");
1484
+ const { join } = await import("node:path");
1485
+ const dir = await mkdtemp(join(tmpdir(), "cc98-image-"));
1486
+ const tiffPath = join(dir, "clipboard.tiff");
1487
+ try {
1488
+ await execFilePromise("sips", ["-s", "format", "tiff", localPath, "--out", tiffPath]);
1489
+ await execFilePromise("osascript", [
1490
+ "-e",
1491
+ `set the clipboard to (read (POSIX file ${appleScriptString(tiffPath)}) as TIFF picture)`
1492
+ ]);
1493
+ }
1494
+ finally {
1495
+ await rm(dir, { recursive: true, force: true });
1496
+ }
1497
+ }
1115
1498
  async copyToClipboard(text) {
1116
1499
  try {
1117
1500
  const { spawn } = await import("node:child_process");
@@ -1148,6 +1531,222 @@ export class TuiController {
1148
1531
  }
1149
1532
  this.render();
1150
1533
  }
1534
+ async openKeybindingEditor() {
1535
+ // 显示快捷键配置信息
1536
+ const config = this.keybindings.getConfig();
1537
+ const lines = [
1538
+ "快捷键配置文件: ~/.cc98-cli/keybindings.json",
1539
+ "",
1540
+ "当前配置:"
1541
+ ];
1542
+ // 显示主要快捷键
1543
+ const mainActions = [
1544
+ "moveUp", "moveDown", "moveLeft", "moveRight", "confirm", "back",
1545
+ "search", "refresh", "menu", "help", "quit",
1546
+ "topicNextPage", "topicPrevPage", "topicNextFloor", "topicPrevFloor",
1547
+ "topicJumpPage", "topicJumpFloor", "topicJumpLast"
1548
+ ];
1549
+ for (const action of mainActions) {
1550
+ const keys = config[action] ?? [];
1551
+ const desc = this.keybindings.getActionDescription(action);
1552
+ const keyStr = keys.map(k => this.keybindings.formatKey(k)).join("/");
1553
+ lines.push(` ${desc}: ${keyStr}`);
1554
+ }
1555
+ lines.push("", "编辑配置文件后重启生效。", "", "按 Esc 返回设置");
1556
+ this.state.modal = "info";
1557
+ this.state.infoTitle = "快捷键设置";
1558
+ this.state.infoLines = lines;
1559
+ this.render();
1560
+ }
1561
+ openPixelLogo() {
1562
+ this.state.modal = "info";
1563
+ this.state.infoTitle = "CC98 像素 Logo";
1564
+ this.state.infoLines = [
1565
+ ...renderCc98Logo().split("\n"),
1566
+ "",
1567
+ "来源: https://www.cc98.org/static/images/98LOGO.ico",
1568
+ "渲染: 24-bit ANSI 半块像素"
1569
+ ];
1570
+ this.render();
1571
+ }
1572
+ openEmojiPreview() {
1573
+ const items = EMOJI_CATEGORIES.map((category) => ({
1574
+ title: `${category.label} (${category.codes.length})`,
1575
+ meta: `emoji-category:${category.id}`,
1576
+ detail: `来源目录: Assets/Emoji/${category.source} · ${category.codes[0]} - ${category.codes.at(-1)}`
1577
+ }));
1578
+ this.openReadOnlyList("表情包预览", items, EMOJI_CATEGORIES.map((category) => ({
1579
+ title: category.label,
1580
+ detail: `${category.codes.length} 个`
1581
+ })));
1582
+ this.state.status = "表情包预览:j/k 选择分类 Enter 进入 h 返回";
1583
+ this.render();
1584
+ }
1585
+ openEmojiCategory(categoryId) {
1586
+ const category = EMOJI_CATEGORIES.find((item) => item.id === categoryId);
1587
+ if (!category) {
1588
+ this.state.status = "未找到表情分类";
1589
+ this.render();
1590
+ return;
1591
+ }
1592
+ const items = [];
1593
+ for (let index = 0; index < category.codes.length; index += 20) {
1594
+ const batch = category.codes.slice(index, index + 20);
1595
+ const batchNumber = Math.floor(index / 20) + 1;
1596
+ items.push({
1597
+ title: `${category.label} 第 ${batchNumber} 批`,
1598
+ meta: `emoji-batch:${category.id}:${batchNumber}`,
1599
+ detail: `${batch[0]} - ${batch.at(-1)} · ${batch.length} 个`
1600
+ });
1601
+ for (const code of batch) {
1602
+ const art = getEmojiArt(code);
1603
+ items.push({
1604
+ title: `[${code}]`,
1605
+ meta: `emoji:${code}`,
1606
+ detail: art ? `${category.label} · ${art.width}x${art.height}px` : category.label
1607
+ });
1608
+ }
1609
+ }
1610
+ this.openReadOnlyList(category.label, items, [
1611
+ { title: "分类", detail: category.label },
1612
+ { title: "数量", detail: `${category.codes.length} 个` },
1613
+ { title: "来源", detail: `Assets/Emoji/${category.source}` }
1614
+ ]);
1615
+ this.state.status = `${category.label}:j/k 选择 Enter 放大 h 返回分类`;
1616
+ this.render();
1617
+ }
1618
+ openEmojiDetail(code) {
1619
+ const art = getEmojiArt(code);
1620
+ const rendered = renderEmojiCode(code);
1621
+ if (!art || !rendered) {
1622
+ this.state.status = `未找到表情 [${code}]`;
1623
+ this.render();
1624
+ return;
1625
+ }
1626
+ this.state.modal = "info";
1627
+ this.state.infoTitle = `[${code}]`;
1628
+ this.state.infoLines = [
1629
+ ...rendered.split("\n"),
1630
+ "",
1631
+ `尺寸: ${art.width}x${art.height}px`,
1632
+ `颜色: ${art.palette.length}`
1633
+ ];
1634
+ this.render();
1635
+ }
1636
+ async openAccountSwitcher() {
1637
+ try {
1638
+ const accounts = await this.tokenStore.listAccounts();
1639
+ const currentAccount = await this.tokenStore.getCurrentAccountName();
1640
+ if (accounts.length === 0) {
1641
+ this.state.modal = "info";
1642
+ this.state.infoTitle = "切换账号";
1643
+ this.state.infoLines = ["暂无保存的账号", "", "请先登录账号。"];
1644
+ this.render();
1645
+ return;
1646
+ }
1647
+ // 构建账号列表
1648
+ const items = accounts.map(account => ({
1649
+ title: account.displayName || account.username || account.account,
1650
+ meta: `account:${account.account}`,
1651
+ detail: `${account.account === currentAccount ? "✓ 当前" : "切换到此账号"}${account.userId ? ` · ID: ${account.userId}` : ""}`
1652
+ }));
1653
+ this.snapshotParent();
1654
+ this.state.viewTitle = "切换账号";
1655
+ this.state.items = items;
1656
+ this.state.stats = [{ title: "账号数", detail: `${accounts.length}` }];
1657
+ this.state.itemIndex = accounts.findIndex(a => a.account === currentAccount);
1658
+ this.state.scroll = 0;
1659
+ this.state.focus = "content";
1660
+ this.state.mode = "list";
1661
+ this.state.status = "选择账号: j/k 选择 Enter 切换 h 返回";
1662
+ this.render();
1663
+ }
1664
+ catch (error) {
1665
+ this.state.status = error instanceof Error ? error.message : "读取账号失败";
1666
+ this.render();
1667
+ }
1668
+ }
1669
+ async switchAccount(accountName) {
1670
+ try {
1671
+ await this.tokenStore.useAccount(accountName);
1672
+ this.state.status = `已切换到账号: ${accountName}`;
1673
+ this.state.parentList = undefined;
1674
+ this.state.mode = "list";
1675
+ this.state.focus = "nav";
1676
+ await this.load(true);
1677
+ }
1678
+ catch (error) {
1679
+ this.state.status = error instanceof Error ? error.message : "切换账号失败";
1680
+ this.render();
1681
+ }
1682
+ }
1683
+ async confirmLogout() {
1684
+ const account = await this.tokenStore.getCurrentAccountName();
1685
+ const lines = [
1686
+ `当前账号: ${account || "未知"}`,
1687
+ "",
1688
+ "退出登录将清除所有保存的账号信息。",
1689
+ "清除后需要重新登录。",
1690
+ "",
1691
+ `${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`
1692
+ ];
1693
+ this.state.modal = "info";
1694
+ this.state.infoTitle = "退出登录";
1695
+ this.state.infoLines = lines;
1696
+ this.state.confirmCallback = () => void this.performLogout();
1697
+ this.render();
1698
+ }
1699
+ async performLogout() {
1700
+ try {
1701
+ await this.tokenStore.clear();
1702
+ this.state.status = "已退出登录";
1703
+ this.state.parentList = undefined;
1704
+ this.state.mode = "list";
1705
+ this.state.focus = "nav";
1706
+ await this.load(true);
1707
+ }
1708
+ catch (error) {
1709
+ this.state.status = error instanceof Error ? error.message : "退出失败";
1710
+ this.render();
1711
+ }
1712
+ }
1713
+ async openCacheManager() {
1714
+ try {
1715
+ const stats = await this.client.getCacheStats();
1716
+ const cacheDir = "~/.cc98-cli/cache/";
1717
+ const lines = [
1718
+ `缓存目录: ${cacheDir}`,
1719
+ `文件数量: ${stats.fileCacheEntries}`,
1720
+ "",
1721
+ "缓存策略:",
1722
+ " 版面主题: 30s",
1723
+ " 版面信息: 24h",
1724
+ " 用户信息: 5min",
1725
+ "",
1726
+ "Enter 清理缓存 Esc 返回"
1727
+ ];
1728
+ this.state.modal = "info";
1729
+ this.state.infoTitle = "缓存管理";
1730
+ this.state.infoLines = lines;
1731
+ this.state.confirmCallback = () => void this.clearCache();
1732
+ this.render();
1733
+ }
1734
+ catch (error) {
1735
+ this.state.status = error instanceof Error ? error.message : "读取缓存信息失败";
1736
+ this.render();
1737
+ }
1738
+ }
1739
+ async clearCache() {
1740
+ try {
1741
+ await this.client.clearCache();
1742
+ this.state.status = "缓存已清理";
1743
+ await this.load(true);
1744
+ }
1745
+ catch {
1746
+ this.state.status = "缓存清理失败";
1747
+ this.render();
1748
+ }
1749
+ }
1151
1750
  async openFriendUsers(type, signal) {
1152
1751
  const ids = asArray(await this.client.getFriendIds(type, 0, 20, false, signal)).filter((id) => typeof id === "number");
1153
1752
  const users = asArray(await this.client.getUsers(ids, false, signal));
@@ -1169,15 +1768,15 @@ export class TuiController {
1169
1768
  this.render();
1170
1769
  }
1171
1770
  snapshotParent() {
1172
- if (!this.state.parentList) {
1173
- this.state.parentList = {
1174
- title: this.state.viewTitle,
1175
- items: [...this.state.items],
1176
- stats: [...this.state.stats],
1177
- itemIndex: this.state.itemIndex,
1178
- status: this.state.status
1179
- };
1180
- }
1771
+ this.state.parentList = {
1772
+ title: this.state.viewTitle,
1773
+ items: [...this.state.items],
1774
+ stats: [...this.state.stats],
1775
+ itemIndex: this.state.itemIndex,
1776
+ scroll: this.state.scroll,
1777
+ status: this.state.status,
1778
+ parent: this.state.parentList
1779
+ };
1181
1780
  }
1182
1781
  restoreParentList() {
1183
1782
  const parent = this.state.parentList;
@@ -1187,14 +1786,93 @@ export class TuiController {
1187
1786
  this.state.items = parent.items;
1188
1787
  this.state.stats = parent.stats;
1189
1788
  this.state.itemIndex = parent.itemIndex;
1789
+ this.state.scroll = parent.scroll;
1190
1790
  this.state.status = parent.status;
1191
- this.state.parentList = undefined;
1791
+ this.state.parentList = parent.parent;
1192
1792
  this.state.currentBoard = undefined;
1193
1793
  this.state.currentChat = undefined;
1194
1794
  this.state.topic = undefined;
1195
1795
  this.state.mode = "list";
1196
1796
  this.state.focus = "content";
1197
- this.state.scroll = 0;
1797
+ this.render();
1798
+ }
1799
+ async checkUpdate(forceShow = false) {
1800
+ if (forceShow) {
1801
+ this.state.status = "正在检查 GitHub Release...";
1802
+ this.render();
1803
+ }
1804
+ try {
1805
+ const result = await checkForUpdate();
1806
+ if (!result.updateAvailable || !result.latest) {
1807
+ this.state.updateAvailable = undefined;
1808
+ if (forceShow) {
1809
+ this.state.status = result.message;
1810
+ this.render();
1811
+ }
1812
+ return;
1813
+ }
1814
+ const lastSeen = await this.settingsStore.getLastSeenVersion();
1815
+ const isNew = forceShow || lastSeen !== result.latest.version;
1816
+ this.state.updateAvailable = {
1817
+ version: result.latest.version,
1818
+ tagName: result.latest.tagName,
1819
+ url: result.latest.url,
1820
+ body: result.latest.body,
1821
+ isNew
1822
+ };
1823
+ if (forceShow) {
1824
+ this.state.status = result.message;
1825
+ }
1826
+ this.render();
1827
+ }
1828
+ catch (error) {
1829
+ if (forceShow) {
1830
+ this.state.status = error instanceof Error ? error.message : "检查更新失败";
1831
+ this.render();
1832
+ }
1833
+ }
1198
1834
  }
1835
+ dismissUpdate() {
1836
+ if (this.state.updateAvailable) {
1837
+ const version = this.state.updateAvailable.version;
1838
+ this.state.updateAvailable = undefined;
1839
+ this.render();
1840
+ void this.settingsStore.setLastSeenVersion(version).catch(() => {
1841
+ // 忽略已读状态写入失败,避免影响 TUI 操作。
1842
+ });
1843
+ }
1844
+ }
1845
+ }
1846
+ async function execFilePromise(command, args) {
1847
+ const { execFile } = await import("node:child_process");
1848
+ await new Promise((resolve, reject) => {
1849
+ execFile(command, args, (error) => {
1850
+ if (error) {
1851
+ reject(error);
1852
+ }
1853
+ else {
1854
+ resolve();
1855
+ }
1856
+ });
1857
+ });
1858
+ }
1859
+ function appleScriptString(value) {
1860
+ return JSON.stringify(value);
1861
+ }
1862
+ function imageMimeType(path) {
1863
+ const lower = path.toLowerCase();
1864
+ if (lower.endsWith(".png"))
1865
+ return "image/png";
1866
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
1867
+ return "image/jpeg";
1868
+ if (lower.endsWith(".gif"))
1869
+ return "image/gif";
1870
+ if (lower.endsWith(".webp"))
1871
+ return "image/webp";
1872
+ if (lower.endsWith(".bmp"))
1873
+ return "image/bmp";
1874
+ if (lower.endsWith(".svg"))
1875
+ return "image/svg+xml";
1876
+ return "image/png";
1199
1877
  }
1200
1878
  //# sourceMappingURL=controller.js.map