cc98-cli 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/dist/api/client.d.ts +14 -0
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +83 -8
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/api/types.d.ts +13 -0
  7. package/dist/api/types.d.ts.map +1 -1
  8. package/dist/api/webvpn.d.ts +68 -0
  9. package/dist/api/webvpn.d.ts.map +1 -0
  10. package/dist/api/webvpn.js +311 -0
  11. package/dist/api/webvpn.js.map +1 -0
  12. package/dist/cli/commands/board.js +1 -1
  13. package/dist/cli/commands/board.js.map +1 -1
  14. package/dist/cli/commands/forum.js +1 -1
  15. package/dist/cli/commands/forum.js.map +1 -1
  16. package/dist/cli/commands/login.js +1 -1
  17. package/dist/cli/commands/login.js.map +1 -1
  18. package/dist/cli/commands/logout.js +2 -2
  19. package/dist/cli/commands/logout.js.map +1 -1
  20. package/dist/cli/commands/me.js +1 -1
  21. package/dist/cli/commands/me.js.map +1 -1
  22. package/dist/cli/commands/message.js +1 -1
  23. package/dist/cli/commands/message.js.map +1 -1
  24. package/dist/cli/commands/notice.js +1 -1
  25. package/dist/cli/commands/notice.js.map +1 -1
  26. package/dist/cli/commands/post.js +1 -1
  27. package/dist/cli/commands/post.js.map +1 -1
  28. package/dist/cli/commands/search.js +1 -1
  29. package/dist/cli/commands/search.js.map +1 -1
  30. package/dist/cli/commands/topic.js +1 -1
  31. package/dist/cli/commands/topic.js.map +1 -1
  32. package/dist/cli/commands/update.d.ts.map +1 -1
  33. package/dist/cli/commands/update.js +49 -3
  34. package/dist/cli/commands/update.js.map +1 -1
  35. package/dist/cli/commands/user.js +1 -1
  36. package/dist/cli/commands/user.js.map +1 -1
  37. package/dist/cli/commands/vpn.d.ts +2 -0
  38. package/dist/cli/commands/vpn.d.ts.map +1 -0
  39. package/dist/cli/commands/vpn.js +200 -0
  40. package/dist/cli/commands/vpn.js.map +1 -0
  41. package/dist/cli/context.d.ts +2 -2
  42. package/dist/cli/context.d.ts.map +1 -1
  43. package/dist/cli/context.js +20 -2
  44. package/dist/cli/context.js.map +1 -1
  45. package/dist/cli/router.d.ts.map +1 -1
  46. package/dist/cli/router.js +5 -1
  47. package/dist/cli/router.js.map +1 -1
  48. package/dist/main.js +0 -0
  49. package/dist/storage/image-cache.d.ts +32 -0
  50. package/dist/storage/image-cache.d.ts.map +1 -0
  51. package/dist/storage/image-cache.js +90 -0
  52. package/dist/storage/image-cache.js.map +1 -0
  53. package/dist/storage/settings-store.d.ts +10 -0
  54. package/dist/storage/settings-store.d.ts.map +1 -0
  55. package/dist/storage/settings-store.js +37 -0
  56. package/dist/storage/settings-store.js.map +1 -0
  57. package/dist/storage/vpn-store.d.ts +39 -0
  58. package/dist/storage/vpn-store.d.ts.map +1 -0
  59. package/dist/storage/vpn-store.js +94 -0
  60. package/dist/storage/vpn-store.js.map +1 -0
  61. package/dist/tui/app.d.ts.map +1 -1
  62. package/dist/tui/app.js +13 -1
  63. package/dist/tui/app.js.map +1 -1
  64. package/dist/tui/components/content.d.ts +1 -0
  65. package/dist/tui/components/content.d.ts.map +1 -1
  66. package/dist/tui/components/content.js +26 -8
  67. package/dist/tui/components/content.js.map +1 -1
  68. package/dist/tui/components/overview.d.ts.map +1 -1
  69. package/dist/tui/components/overview.js +18 -8
  70. package/dist/tui/components/overview.js.map +1 -1
  71. package/dist/tui/components/sidebar.d.ts.map +1 -1
  72. package/dist/tui/components/sidebar.js +0 -1
  73. package/dist/tui/components/sidebar.js.map +1 -1
  74. package/dist/tui/components/status.d.ts +3 -0
  75. package/dist/tui/components/status.d.ts.map +1 -1
  76. package/dist/tui/components/status.js +28 -9
  77. package/dist/tui/components/status.js.map +1 -1
  78. package/dist/tui/controller.d.ts +22 -0
  79. package/dist/tui/controller.d.ts.map +1 -1
  80. package/dist/tui/controller.js +573 -134
  81. package/dist/tui/controller.js.map +1 -1
  82. package/dist/tui/helpers.d.ts.map +1 -1
  83. package/dist/tui/helpers.js +19 -10
  84. package/dist/tui/helpers.js.map +1 -1
  85. package/dist/tui/keybindings.d.ts +24 -0
  86. package/dist/tui/keybindings.d.ts.map +1 -0
  87. package/dist/tui/keybindings.js +207 -0
  88. package/dist/tui/keybindings.js.map +1 -0
  89. package/dist/tui/navigation.d.ts.map +1 -1
  90. package/dist/tui/navigation.js +1 -1
  91. package/dist/tui/navigation.js.map +1 -1
  92. package/dist/tui/renderer.d.ts.map +1 -1
  93. package/dist/tui/renderer.js +50 -20
  94. package/dist/tui/renderer.js.map +1 -1
  95. package/dist/tui/state/store.d.ts.map +1 -1
  96. package/dist/tui/state/store.js +14 -2
  97. package/dist/tui/state/store.js.map +1 -1
  98. package/dist/tui/state/types.d.ts +21 -1
  99. package/dist/tui/state/types.d.ts.map +1 -1
  100. package/dist/tui/topic-reader.d.ts +9 -1
  101. package/dist/tui/topic-reader.d.ts.map +1 -1
  102. package/dist/tui/topic-reader.js +39 -16
  103. package/dist/tui/topic-reader.js.map +1 -1
  104. package/dist/tui/ubb-renderer.d.ts.map +1 -1
  105. package/dist/tui/ubb-renderer.js +176 -12
  106. package/dist/tui/ubb-renderer.js.map +1 -1
  107. package/dist/update.d.ts +4 -1
  108. package/dist/update.d.ts.map +1 -1
  109. package/dist/update.js +71 -11
  110. package/dist/update.js.map +1 -1
  111. package/dist/version.d.ts +2 -2
  112. package/dist/version.d.ts.map +1 -1
  113. package/dist/version.js +10 -2
  114. package/dist/version.js.map +1 -1
  115. package/package.json +1 -1
  116. package/dist/tui/app-new.d.ts +0 -2
  117. package/dist/tui/app-new.d.ts.map +0 -1
  118. package/dist/tui/app-new.js +0 -2589
  119. package/dist/tui/app-new.js.map +0 -1
  120. package/dist/tui/app-old.d.ts +0 -2
  121. package/dist/tui/app-old.d.ts.map +0 -1
  122. package/dist/tui/app-old.js +0 -2589
  123. package/dist/tui/app-old.js.map +0 -1
  124. package/dist/tui/components/layout.d.ts +0 -3
  125. package/dist/tui/components/layout.d.ts.map +0 -1
  126. package/dist/tui/components/layout.js +0 -464
  127. package/dist/tui/components/layout.js.map +0 -1
  128. package/dist/tui/keymap/actions.d.ts +0 -9
  129. package/dist/tui/keymap/actions.d.ts.map +0 -1
  130. package/dist/tui/keymap/actions.js +0 -208
  131. package/dist/tui/keymap/actions.js.map +0 -1
  132. package/dist/tui/keymap/bindings.d.ts +0 -5
  133. package/dist/tui/keymap/bindings.d.ts.map +0 -1
  134. package/dist/tui/keymap/bindings.js +0 -138
  135. package/dist/tui/keymap/bindings.js.map +0 -1
  136. package/dist/tui/keymap/index.d.ts +0 -4
  137. package/dist/tui/keymap/index.d.ts.map +0 -1
  138. package/dist/tui/keymap/index.js +0 -5
  139. package/dist/tui/keymap/index.js.map +0 -1
  140. package/dist/tui/keymap/types.d.ts +0 -17
  141. package/dist/tui/keymap/types.d.ts.map +0 -1
  142. package/dist/tui/keymap/types.js +0 -3
  143. package/dist/tui/keymap/types.js.map +0 -1
@@ -1,9 +1,12 @@
1
1
  import { checkForUpdate } from "../update.js";
2
2
  import { appVersion } from "../version.js";
3
+ import { getImageCache } from "../storage/image-cache.js";
4
+ import { SettingsStore } from "../storage/settings-store.js";
5
+ import { getKeybindingManager } from "./keybindings.js";
3
6
  import { navItems, settingsItems } from "./navigation.js";
4
7
  import { getStatus } from "./state/store.js";
5
8
  import { asArray, asNumber, asObject, chatItem, chatMessageItems, flattenBoards, genericItem, historyItem, isAbortError, jsonPreviewLines, loadChatUserNames, mapLimit, noticeItem, overviewStats, topicItem, unreadStats, userItem } from "./helpers.js";
6
- import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, jumpRelativeTopicFloor } from "./topic-reader.js";
9
+ import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, FLOORS_PER_PAGE } from "./topic-reader.js";
7
10
  export class TuiController {
8
11
  state;
9
12
  client;
@@ -13,6 +16,9 @@ export class TuiController {
13
16
  nextSignal;
14
17
  abortCurrent;
15
18
  loadVersion = 0;
19
+ keybindings;
20
+ settingsStore = new SettingsStore();
21
+ updateChecked = false;
16
22
  constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent) {
17
23
  this.state = state;
18
24
  this.client = client;
@@ -21,6 +27,7 @@ export class TuiController {
21
27
  this.close = close;
22
28
  this.nextSignal = nextSignal;
23
29
  this.abortCurrent = abortCurrent;
30
+ this.keybindings = getKeybindingManager();
24
31
  }
25
32
  async load(force = false) {
26
33
  const version = ++this.loadVersion;
@@ -43,7 +50,14 @@ export class TuiController {
43
50
  this.state.currentChat = undefined;
44
51
  this.render();
45
52
  try {
53
+ // 加载快捷键配置
54
+ await this.keybindings.load();
46
55
  this.state.account = await this.tokenStore.getCurrentAccountName();
56
+ // 异步检查更新(不阻塞主加载)
57
+ if (!this.updateChecked) {
58
+ this.updateChecked = true;
59
+ void this.checkUpdate();
60
+ }
47
61
  const next = await this.loadView(nav.id, force, signal);
48
62
  if (version !== this.loadVersion)
49
63
  return;
@@ -74,11 +88,20 @@ export class TuiController {
74
88
  this.handleInputKey(key);
75
89
  return;
76
90
  }
77
- if (key === "\u0003" || key === "q") {
91
+ // 关闭更新通知(Esc 或任意键)
92
+ if (this.state.updateAvailable?.isNew) {
93
+ if (key === "\x1b" || key === "\r") {
94
+ this.dismissUpdate();
95
+ return;
96
+ }
97
+ // 其他按键也关闭更新通知,继续处理原按键动作
98
+ this.dismissUpdate();
99
+ }
100
+ if (this.keybindings.matches(key, "quit")) {
78
101
  this.close();
79
102
  return;
80
103
  }
81
- if (key === "?") {
104
+ if (this.keybindings.matches(key, "help")) {
82
105
  this.state.modal = this.state.modal === "help" ? null : "help";
83
106
  this.render();
84
107
  return;
@@ -102,17 +125,17 @@ export class TuiController {
102
125
  this.handleContentKey(key);
103
126
  }
104
127
  handleInputKey(key) {
105
- if (key === "\x1b") {
128
+ if (this.keybindings.matches(key, "inputCancel")) {
106
129
  this.state.inputMode = false;
107
130
  this.state.inputValue = "";
108
131
  this.render();
109
132
  return;
110
133
  }
111
- if (key === "\r") {
134
+ if (this.keybindings.matches(key, "inputConfirm")) {
112
135
  this.state.inputCallback?.(this.state.inputValue);
113
136
  return;
114
137
  }
115
- if (key === "\x7f") {
138
+ if (this.keybindings.matches(key, "inputBackspace")) {
116
139
  this.state.inputValue = this.state.inputValue.slice(0, -1);
117
140
  this.render();
118
141
  return;
@@ -123,10 +146,20 @@ export class TuiController {
123
146
  }
124
147
  }
125
148
  handleModalKey(key) {
126
- if (this.state.modal === "help" || this.state.modal === "info") {
127
- if (key === "\x1b" || key === "\r" || key === "h" || key === "\x1b[D" || key === "?") {
149
+ if (this.state.modal === "help") {
150
+ this.closeModal();
151
+ return;
152
+ }
153
+ if (this.state.modal === "info") {
154
+ // 如果有确认回调,确认键执行回调,其它键关闭。
155
+ if (this.state.confirmCallback && this.keybindings.matches(key, "confirm")) {
156
+ const callback = this.state.confirmCallback;
157
+ this.state.confirmCallback = undefined;
128
158
  this.closeModal();
159
+ callback();
160
+ return;
129
161
  }
162
+ this.closeModal();
130
163
  return;
131
164
  }
132
165
  if (this.state.modal === "search") {
@@ -142,36 +175,38 @@ export class TuiController {
142
175
  }
143
176
  }
144
177
  handleSearchKey(key) {
145
- if (key === "\x1b") {
178
+ if (this.keybindings.matches(key, "searchClose")) {
146
179
  this.state.modal = null;
147
180
  this.state.searchQuery = "";
148
181
  this.render();
149
182
  return;
150
183
  }
151
- if (key === "\t") {
184
+ if (this.keybindings.matches(key, "searchToggleMode")) {
152
185
  this.state.searchMode = this.state.searchMode === "topics" ? "users" : "topics";
153
186
  this.state.searchResults = [];
154
187
  this.state.itemIndex = 0;
155
188
  this.render();
156
189
  return;
157
190
  }
158
- if ((key === "j" || key === "\x1b[B") && this.state.searchResults.length > 0) {
191
+ if (this.keybindings.matches(key, "searchNext") && this.state.searchResults.length > 0) {
159
192
  this.state.itemIndex = Math.min(this.state.searchResults.length - 1, this.state.itemIndex + 1);
160
193
  this.render();
161
194
  return;
162
195
  }
163
- if ((key === "k" || key === "\x1b[A") && this.state.searchResults.length > 0) {
196
+ if (this.keybindings.matches(key, "searchPrev") && this.state.searchResults.length > 0) {
164
197
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
165
198
  this.render();
166
199
  return;
167
200
  }
168
- if (key === "\r") {
201
+ if (this.keybindings.matches(key, "searchExecute")) {
169
202
  const selected = this.state.searchResults[this.state.itemIndex];
170
203
  if (selected) {
204
+ // 有选中项:打开
171
205
  this.state.modal = null;
172
206
  void this.activateContentItem(selected, this.nextSignal());
173
207
  }
174
208
  else if (this.state.searchQuery.trim()) {
209
+ // 无选中项:执行搜索
175
210
  void this.performSearch(this.nextSignal());
176
211
  }
177
212
  return;
@@ -191,7 +226,8 @@ export class TuiController {
191
226
  }
192
227
  }
193
228
  handleUserModalKey(key) {
194
- if (key === "\x1b") {
229
+ if (key === "\x1b" || key === "u") {
230
+ // Esc 或 u 关闭用户详情
195
231
  this.closeModal();
196
232
  return;
197
233
  }
@@ -218,22 +254,23 @@ export class TuiController {
218
254
  }
219
255
  }
220
256
  handleMenuKey(key) {
221
- if (key === "j" || key === "\x1b[B") {
257
+ if (this.keybindings.matches(key, "menuNext")) {
222
258
  this.state.menuIndex = Math.min(Math.max(0, this.state.menuItems.length - 1), this.state.menuIndex + 1);
223
259
  this.render();
224
260
  return;
225
261
  }
226
- if (key === "k" || key === "\x1b[A") {
262
+ if (this.keybindings.matches(key, "menuPrev")) {
227
263
  this.state.menuIndex = Math.max(0, this.state.menuIndex - 1);
228
264
  this.render();
229
265
  return;
230
266
  }
231
- if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "o") {
267
+ if (this.keybindings.matches(key, "menuClose")) {
232
268
  this.state.modal = null;
233
269
  this.render();
234
270
  return;
235
271
  }
236
- if (key === "\r" || key === "l" || key === "\x1b[C") {
272
+ if (this.keybindings.matches(key, "menuExecute")) {
273
+ // Enter 或 l 执行选中项
237
274
  const selected = this.state.menuItems[this.state.menuIndex];
238
275
  this.state.modal = null;
239
276
  if (selected?.action === "refresh")
@@ -244,119 +281,190 @@ export class TuiController {
244
281
  }
245
282
  }
246
283
  handleTopicKey(key) {
284
+ // 数字输入:收集跳转目标
247
285
  if (/^\d$/.test(key) && this.state.topic) {
248
286
  this.state.topic.floorInput = `${this.state.topic.floorInput}${key}`.slice(0, 6);
249
- this.state.status = `跳转到 ${this.state.topic.floorInput} 楼:Enter 确认 Esc 取消`;
287
+ this.state.status = `输入: ${this.state.topic.floorInput}`;
250
288
  this.render();
251
289
  return;
252
290
  }
253
- if (key === "\x7f" && this.state.topic?.floorInput) {
291
+ // 退格:删除输入
292
+ if (this.keybindings.matches(key, "inputBackspace") && this.state.topic?.floorInput) {
254
293
  this.state.topic.floorInput = this.state.topic.floorInput.slice(0, -1);
255
- this.state.status = this.state.topic.floorInput ? `跳转到 ${this.state.topic.floorInput} 楼:Enter 确认 Esc 取消` : getStatus(this.state);
294
+ this.state.status = this.state.topic.floorInput ? `输入: ${this.state.topic.floorInput}` : "";
256
295
  this.render();
257
296
  return;
258
297
  }
259
- if (key === "\r" && this.state.topic?.floorInput) {
260
- const floor = Number(this.state.topic.floorInput);
298
+ // 数字 + 跳页键:跳页
299
+ if (this.keybindings.matches(key, "topicJumpPage") && this.state.topic?.floorInput) {
300
+ const page = Number(this.state.topic.floorInput);
301
+ this.state.topic.jumpTarget = { type: "page", value: page };
261
302
  this.state.topic.floorInput = "";
262
- if (Number.isInteger(floor) && floor > 0)
263
- void this.jumpToTopicFloor(floor, this.nextSignal());
303
+ this.state.status = `跳转到第 ${page} 页?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
304
+ this.render();
264
305
  return;
265
306
  }
266
- if ((key === "]" || key === "】") && this.state.topic) {
267
- this.state.scroll = jumpRelativeTopicFloor(this.state.topic, this.state.scroll, 1);
268
- this.state.status = getStatus(this.state);
307
+ // 数字 + 跳楼键:跳楼
308
+ if (this.keybindings.matches(key, "topicJumpFloor") && this.state.topic?.floorInput) {
309
+ const floor = Number(this.state.topic.floorInput);
310
+ this.state.topic.jumpTarget = { type: "floor", value: floor };
311
+ this.state.topic.floorInput = "";
312
+ this.state.status = `跳转到第 ${floor} 楼?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
269
313
  this.render();
270
314
  return;
271
315
  }
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);
316
+ // 跳到最后一页
317
+ if (this.keybindings.matches(key, "topicJumpLast") && !this.state.topic?.floorInput && this.state.topic) {
318
+ const pageInfo = getTopicPageInfo(this.state.topic, this.state.topic.cursorLine);
319
+ this.state.topic.jumpTarget = { type: "page", value: pageInfo.totalPages };
320
+ this.state.status = `跳转到最后一页(第 ${pageInfo.totalPages} 页)?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
275
321
  this.render();
276
322
  return;
277
323
  }
278
- if (key === "\x1b" && this.state.topic?.floorInput) {
324
+ // 确认跳转
325
+ if (this.keybindings.matches(key, "confirm") && this.state.topic?.jumpTarget) {
326
+ const target = this.state.topic.jumpTarget;
327
+ this.state.topic.jumpTarget = undefined;
328
+ this.state.status = "";
329
+ if (target.type === "page") {
330
+ void this.jumpToTopicPage(target.value, this.nextSignal());
331
+ }
332
+ else {
333
+ void this.jumpToTopicFloor(target.value, this.nextSignal());
334
+ }
335
+ return;
336
+ }
337
+ // 取消跳转
338
+ if (this.keybindings.matches(key, "back") && (this.state.topic?.floorInput || this.state.topic?.jumpTarget)) {
279
339
  this.state.topic.floorInput = "";
280
- this.state.status = getStatus(this.state);
340
+ this.state.topic.jumpTarget = undefined;
341
+ this.state.status = "";
281
342
  this.render();
282
343
  return;
283
344
  }
284
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
345
+ // ]:下一层
346
+ if (this.keybindings.matches(key, "topicNextFloor") && this.state.topic) {
347
+ void this.jumpRelativeFloor(1);
348
+ return;
349
+ }
350
+ // [:上一层
351
+ if (this.keybindings.matches(key, "topicPrevFloor") && this.state.topic) {
352
+ void this.jumpRelativeFloor(-1);
353
+ return;
354
+ }
355
+ // }:下一页
356
+ if (this.keybindings.matches(key, "topicNextPage") && this.state.topic) {
357
+ void this.jumpToNextPage();
358
+ return;
359
+ }
360
+ // {:上一页
361
+ if (this.keybindings.matches(key, "topicPrevPage") && this.state.topic) {
362
+ void this.jumpToPrevPage();
363
+ return;
364
+ }
365
+ // h/Esc:返回
366
+ if (this.keybindings.matches(key, "back")) {
285
367
  this.leave();
286
368
  return;
287
369
  }
288
- if (key === "j" || key === "\x1b[B") {
370
+ // j:下移
371
+ if (this.keybindings.matches(key, "topicScrollDown")) {
289
372
  const maxScroll = Math.max(0, (this.state.topic?.lines.length ?? 0) - 1);
290
- const wasAtEnd = this.state.scroll >= maxScroll;
291
- this.state.scroll = Math.min(maxScroll, this.state.scroll + 1);
292
- this.render();
293
- if (wasAtEnd && this.state.topic?.hasMore && !this.state.loadingMore) {
294
- void this.loadNextTopicPage(this.nextSignal(), true);
373
+ if (this.state.topic) {
374
+ this.state.topic.cursorLine = Math.min(maxScroll, this.state.topic.cursorLine + 1);
295
375
  }
296
- return;
297
- }
298
- if (key === "k" || key === "\x1b[A") {
299
- this.state.scroll = Math.max(0, this.state.scroll - 1);
376
+ this.state.status = "";
300
377
  this.render();
378
+ void this.checkAutoLoad();
301
379
  return;
302
380
  }
303
- if (key === "n" || key === " ") {
304
- void this.loadNextTopicPage(this.nextSignal());
381
+ // k:上移
382
+ if (this.keybindings.matches(key, "topicScrollUp")) {
383
+ if (this.state.topic) {
384
+ this.state.topic.cursorLine = Math.max(0, this.state.topic.cursorLine - 1);
385
+ }
386
+ this.state.status = "";
387
+ this.render();
305
388
  return;
306
389
  }
307
- if (key === "r" && this.state.topic) {
390
+ // r:刷新
391
+ if (this.keybindings.matches(key, "topicRefresh") && this.state.topic) {
308
392
  void this.openTopic(this.state.topic.topicId, true, this.nextSignal());
309
393
  return;
310
394
  }
311
- if (key === "s")
395
+ // s:收藏
396
+ if (this.keybindings.matches(key, "topicFavorite"))
312
397
  void this.toggleFavorite();
313
- if (key === "l")
398
+ // l:点赞
399
+ if (this.keybindings.matches(key, "topicLike"))
314
400
  void this.reactToCurrentPost(true);
315
- if (key === "d")
401
+ // d:踩
402
+ if (this.keybindings.matches(key, "topicDislike"))
316
403
  void this.reactToCurrentPost(false);
317
- if (key === "u")
404
+ // u:查看用户
405
+ if (this.keybindings.matches(key, "topicUser"))
318
406
  void this.showCurrentUser(this.nextSignal());
319
- if (key === "v")
407
+ // v:查看投票
408
+ if (this.keybindings.matches(key, "topicVote"))
320
409
  void this.showTopicVote(this.nextSignal());
321
- if (key === "a")
410
+ // a:查看评价
411
+ if (this.keybindings.matches(key, "topicReaction"))
322
412
  void this.showPostReactionState(this.nextSignal());
323
- if (key === "o")
324
- this.openMenu();
413
+ // o:打开图片/菜单
414
+ if (this.keybindings.matches(key, "topicOpenImage")) {
415
+ const currentLine = this.getCurrentTopicLine();
416
+ if (currentLine?.kind === "image" && currentLine.imageUrl) {
417
+ void this.openImage(currentLine.imageUrl);
418
+ }
419
+ else {
420
+ this.openMenu();
421
+ }
422
+ }
423
+ // c:复制链接
424
+ if (this.keybindings.matches(key, "topicCopyLink")) {
425
+ const currentLine = this.getCurrentTopicLine();
426
+ if (currentLine?.kind === "image" && currentLine.imageUrl) {
427
+ void this.copyToClipboard(currentLine.imageUrl);
428
+ }
429
+ else if (currentLine?.kind === "link" && currentLine.linkUrl) {
430
+ void this.copyToClipboard(currentLine.linkUrl);
431
+ }
432
+ }
325
433
  }
326
434
  handleSettingsKey(key) {
327
- if (key === "j" || key === "\x1b[B") {
435
+ if (this.keybindings.matches(key, "moveDown")) {
328
436
  this.state.itemIndex = Math.min(settingsItems.length - 1, this.state.itemIndex + 1);
329
437
  this.render();
330
438
  return;
331
439
  }
332
- if (key === "k" || key === "\x1b[A") {
440
+ if (this.keybindings.matches(key, "moveUp")) {
333
441
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
334
442
  this.render();
335
443
  return;
336
444
  }
337
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
445
+ if (this.keybindings.matches(key, "back")) {
338
446
  this.state.mode = "list";
339
447
  this.state.focus = "nav";
340
448
  this.state.status = getStatus(this.state);
341
449
  this.render();
342
450
  return;
343
451
  }
344
- if (key === "l" || key === "\x1b[C" || key === "\r") {
452
+ if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
345
453
  void this.activateSetting(settingsItems[this.state.itemIndex]);
346
454
  }
347
455
  }
348
456
  handleNavKey(key) {
349
- if (key === "j" || key === "\x1b[B") {
457
+ if (this.keybindings.matches(key, "moveDown")) {
350
458
  this.state.navIndex = Math.min(navItems.length - 1, this.state.navIndex + 1);
351
459
  void this.load();
352
460
  return;
353
461
  }
354
- if (key === "k" || key === "\x1b[A") {
462
+ if (this.keybindings.matches(key, "moveUp")) {
355
463
  this.state.navIndex = Math.max(0, this.state.navIndex - 1);
356
464
  void this.load();
357
465
  return;
358
466
  }
359
- if (key === "l" || key === "\x1b[C" || key === "\r") {
467
+ if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
360
468
  if (!this.state.loading && this.state.items.length > 0) {
361
469
  if (navItems[this.state.navIndex]?.id === "settings")
362
470
  this.state.mode = "settings";
@@ -367,25 +475,25 @@ export class TuiController {
367
475
  }
368
476
  return;
369
477
  }
370
- if (key === "r")
478
+ if (this.keybindings.matches(key, "refresh"))
371
479
  void this.load(true);
372
480
  }
373
481
  handleContentKey(key) {
374
- if (key === "j" || key === "\x1b[B") {
482
+ if (this.keybindings.matches(key, "listNext")) {
375
483
  this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), this.state.itemIndex + 1);
376
484
  this.render();
377
485
  return;
378
486
  }
379
- if (key === "k" || key === "\x1b[A") {
487
+ if (this.keybindings.matches(key, "listPrev")) {
380
488
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
381
489
  this.render();
382
490
  return;
383
491
  }
384
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
492
+ if (this.keybindings.matches(key, "listBack")) {
385
493
  this.leave();
386
494
  return;
387
495
  }
388
- if (key === "l" || key === "\x1b[C" || key === "\r") {
496
+ if (this.keybindings.matches(key, "listOpen")) {
389
497
  const selected = this.state.items[this.state.itemIndex];
390
498
  if (selected) {
391
499
  void this.activateContentItem(selected, this.nextSignal());
@@ -396,15 +504,11 @@ export class TuiController {
396
504
  }
397
505
  return;
398
506
  }
399
- if ((key === "n" || key === " ") && this.state.currentChat) {
400
- void this.loadNextChatPage(this.nextSignal());
401
- return;
402
- }
403
- if (key === "r")
507
+ if (this.keybindings.matches(key, "listRefresh"))
404
508
  void this.refresh();
405
- if (key === "/")
509
+ if (this.keybindings.matches(key, "search"))
406
510
  this.openSearch();
407
- if (key === "o")
511
+ if (this.keybindings.matches(key, "menu"))
408
512
  this.openMenu();
409
513
  }
410
514
  leave() {
@@ -441,9 +545,17 @@ export class TuiController {
441
545
  this.state.searchQuery = "";
442
546
  this.state.searchResults = [];
443
547
  this.state.searchMode = "topics";
548
+ this.state.searchScope = this.getSearchScope();
444
549
  this.state.itemIndex = 0;
445
550
  this.render();
446
551
  }
552
+ getSearchScope() {
553
+ // 根据当前位置确定搜索范围
554
+ if (this.state.currentBoard) {
555
+ return { label: this.state.currentBoard.title, boardId: this.state.currentBoard.boardId };
556
+ }
557
+ return { label: "全站" };
558
+ }
447
559
  openMenu() {
448
560
  this.state.modal = "menu";
449
561
  this.state.menuItems = this.getMenuItems();
@@ -500,7 +612,7 @@ export class TuiController {
500
612
  title: "版面",
501
613
  items: boards.slice(0, 24),
502
614
  stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${boards.length}` }],
503
- status: "版面:j/k 选择 l 进入版面 h 返回 r 刷新"
615
+ status: "版面:j/k 选择 Enter 进入版面 h 返回 r 刷新"
504
616
  };
505
617
  }
506
618
  case "following": {
@@ -509,7 +621,7 @@ export class TuiController {
509
621
  title: "关注",
510
622
  items: topics.map((topic) => topicItem(topic)),
511
623
  stats: [{ title: "关注动态", detail: `${topics.length} 条` }, { title: "缓存", detail: "30s" }],
512
- status: "关注:j/k 选择 l 打开帖子 h 返回 r 刷新"
624
+ status: "关注:j/k 选择 Enter 打开帖子 h 返回 r 刷新"
513
625
  };
514
626
  }
515
627
  case "favorite": {
@@ -530,7 +642,8 @@ export class TuiController {
530
642
  return {
531
643
  title: "收藏",
532
644
  items: [
533
- { title: "收藏主题", meta: "topic/me/favorite", detail: "打开收藏夹主题列表", action: "favorite-topics" },
645
+ { title: "收藏主题", meta: "topic/me/favorite", detail: "查看收藏夹主题列表", action: "favorite-topics" },
646
+ { title: "收藏更新", meta: "topic/me/favorite?order=1", detail: "查看收藏主题更新", action: "favorite-updates" },
534
647
  { title: "收藏分组", meta: "me/favorite-topic-group", detail: "查看收藏夹分组", action: "favorite-groups" },
535
648
  ...asArray(topicFavorites).slice(0, 6).map((topic) => topicItem(topic)),
536
649
  ...boardTopics
@@ -540,7 +653,7 @@ export class TuiController {
540
653
  { title: "收藏主题", detail: `${asArray(topicFavorites).length} 条` },
541
654
  { title: "版面主题", detail: `${boardTopics.length} 条` }
542
655
  ],
543
- status: "收藏:j/k 选择 l 打开 h 返回 r 刷新"
656
+ status: "收藏:j/k 选择 Enter 打开 h 返回 r 刷新"
544
657
  };
545
658
  }
546
659
  case "messages": {
@@ -554,7 +667,7 @@ export class TuiController {
554
667
  title: "消息",
555
668
  items: chats.length > 0 ? chats.map((chat) => chatItem(chat, names)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
556
669
  stats: unreadStats(asObject(unread)),
557
- status: "消息:j/k 选择 l 打开会话 h 返回 r 刷新"
670
+ status: "消息:j/k 选择 Enter 打开会话 h 返回 r 刷新"
558
671
  };
559
672
  }
560
673
  case "notices": {
@@ -567,7 +680,7 @@ export class TuiController {
567
680
  { title: "回复通知", meta: `${unread.replyCount ?? 0} 未读`, detail: "查看回复我的通知", action: "notices:reply" }
568
681
  ],
569
682
  stats: unreadStats(unread),
570
- status: "通知:j/k 选择 l 打开列表 h 返回 r 刷新"
683
+ status: "通知:j/k 选择 Enter 打开列表 h 返回 r 刷新"
571
684
  };
572
685
  }
573
686
  case "me": {
@@ -579,41 +692,25 @@ export class TuiController {
579
692
  { title: String(meObject.name ?? "当前账号"), meta: `#${meObject.id ?? "?"}`, detail: String(meObject.levelTitle ?? meObject.groupName ?? ""), userId: asNumber(meObject.id) },
580
693
  { title: "我的最近主题", meta: "me/recent-topic", detail: "查看自己最近发布或回复的主题", action: "recent-topics" },
581
694
  { title: "浏览历史", meta: "me/browsing-record", detail: "查看最近浏览过的主题", action: "browse-history" },
695
+ { title: "粉丝列表", meta: "me/follower", detail: "查看关注我的用户", action: "followers" },
696
+ { title: "关注列表", meta: "me/followee", detail: "查看我关注的用户", action: "followees" },
697
+ { title: "随机主题", meta: "topic/random-recent", detail: "随机读取一组最近主题", action: "random-topics" },
582
698
  { title: "每日签到", meta: "me/signin", detail: "执行签到", action: "signin" }
583
699
  ],
584
700
  stats: [
585
701
  { title: "登录状态", detail: "已登录" },
586
702
  { title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` }
587
703
  ],
588
- status: "我的:j/k 选择 l 打开 h 返回 r 刷新"
704
+ status: "我的:j/k 选择 Enter 打开 h 返回 r 刷新"
589
705
  };
590
706
  }
591
- case "more":
592
- return {
593
- title: "更多",
594
- items: [
595
- { title: "随机主题", meta: "topic/random-recent", detail: "随机读取一组最近主题", action: "random-topics" },
596
- { title: "我的最近主题", meta: "me/recent-topic", detail: "查看自己最近发布或回复的主题", action: "recent-topics" },
597
- { title: "浏览历史", meta: "me/browsing-record", detail: "查看最近浏览过的主题", action: "browse-history" },
598
- { title: "收藏主题", meta: "topic/me/favorite", detail: "查看收藏夹主题", action: "favorite-topics" },
599
- { title: "收藏更新", meta: "topic/me/favorite?order=1", detail: "查看收藏主题更新", action: "favorite-updates" },
600
- { title: "收藏分组", meta: "me/favorite-topic-group", detail: "查看收藏夹分组", action: "favorite-groups" },
601
- { title: "粉丝列表", meta: "me/follower", detail: "查看关注我的用户", action: "followers" },
602
- { title: "关注列表", meta: "me/followee", detail: "查看我关注的用户", action: "followees" },
603
- { title: "全站统计", meta: "card.cc98.org/api/collection/stat", detail: "查看论坛全站统计", action: "card-stat" },
604
- { title: "评分原因: 普通", meta: "post/rating-reason?type=0", detail: "查看普通评分原因", action: "rate-reasons:0" },
605
- { title: "评分原因: 管理", meta: "post/rating-reason?type=1", detail: "查看管理评分原因", action: "rate-reasons:1" }
606
- ],
607
- stats: [{ title: "只读入口", detail: "11 个" }, { title: "写入", detail: "不含发帖/回帖" }],
608
- status: "更多:j/k 选择 l 打开只读内容 h 返回 r 刷新"
609
- };
610
707
  case "settings": {
611
708
  const cacheStats = await this.client.getCacheStats();
612
709
  return {
613
710
  title: "设置",
614
711
  items: [...settingsItems],
615
712
  stats: [{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` }, { title: "版本", detail: `v${appVersion}` }],
616
- status: "设置:j/k 选择 l 执行 h 返回"
713
+ status: "设置:j/k 选择 Enter 执行 h 返回"
617
714
  };
618
715
  }
619
716
  }
@@ -626,34 +723,27 @@ export class TuiController {
626
723
  this.render();
627
724
  return;
628
725
  }
726
+ if (selected.meta === "keybindings") {
727
+ void this.openKeybindingEditor();
728
+ return;
729
+ }
629
730
  if (selected.meta === "cache") {
630
- this.state.status = "正在清理缓存...";
631
- this.render();
632
- try {
633
- await this.client.clearCache();
634
- this.state.status = "缓存已清理";
635
- await this.load(true);
636
- }
637
- catch {
638
- this.state.status = "缓存清理失败";
639
- this.render();
640
- }
731
+ void this.openCacheManager();
641
732
  return;
642
733
  }
643
734
  if (selected.meta === "update") {
644
- this.state.status = "正在检查 GitHub Release...";
645
- this.render();
646
- try {
647
- const result = await checkForUpdate();
648
- this.state.status = result.message;
649
- }
650
- catch (error) {
651
- this.state.status = error instanceof Error ? error.message : "检查更新失败";
652
- }
653
- this.render();
735
+ void this.checkUpdate(true);
736
+ return;
737
+ }
738
+ if (selected.meta === "account") {
739
+ void this.openAccountSwitcher();
654
740
  return;
655
741
  }
656
- this.state.status = selected.meta === "logout" ? "退出登录功能开发中..." : "账号切换功能开发中...";
742
+ if (selected.meta === "logout") {
743
+ void this.confirmLogout();
744
+ return;
745
+ }
746
+ this.state.status = "功能开发中...";
657
747
  this.render();
658
748
  }
659
749
  async activateContentItem(selected, signal) {
@@ -673,6 +763,12 @@ export class TuiController {
673
763
  await this.showUserDetailById(selected.userId, signal);
674
764
  return;
675
765
  }
766
+ // 账号切换
767
+ if (selected.meta?.startsWith("account:")) {
768
+ const accountName = selected.meta.slice(8);
769
+ await this.switchAccount(accountName);
770
+ return;
771
+ }
676
772
  if (selected.action?.startsWith("notices:")) {
677
773
  await this.openNoticeList(selected.action.split(":")[1], signal);
678
774
  return;
@@ -724,7 +820,7 @@ export class TuiController {
724
820
  const topics = asArray(await this.client.getBoardTopics(boardId, 0, 20, false, force, signal));
725
821
  this.state.items = topics.map((topic) => topicItem(topic, { title, boardId }));
726
822
  this.state.stats = [{ title: "主题", detail: `${topics.length} 条` }];
727
- this.state.status = `版面 ${title}: j/k 选择 l 打开帖子 h 返回`;
823
+ this.state.status = `版面 ${title}: j/k 选择 Enter 打开帖子 h 返回`;
728
824
  }
729
825
  catch (error) {
730
826
  if (!isAbortError(error))
@@ -783,6 +879,94 @@ export class TuiController {
783
879
  this.render();
784
880
  }
785
881
  }
882
+ async checkAutoLoad() {
883
+ const topic = this.state.topic;
884
+ if (!topic?.hasMore || this.state.loadingMore)
885
+ return;
886
+ const viewportRows = Math.max(1, topic.viewportRows);
887
+ const viewportBottom = this.state.scroll + viewportRows;
888
+ if (viewportBottom >= topic.lines.length) {
889
+ void this.loadNextTopicPage(this.nextSignal(), true);
890
+ }
891
+ }
892
+ async jumpRelativeFloor(delta) {
893
+ const topic = this.state.topic;
894
+ if (!topic)
895
+ return;
896
+ const current = currentTopicPost(topic, topic.cursorLine);
897
+ const currentFloor = current?.floor ?? 1;
898
+ const targetFloor = currentFloor + delta;
899
+ if (targetFloor < 1)
900
+ return;
901
+ const loaded = findTopicPostByFloor(topic, targetFloor);
902
+ if (loaded) {
903
+ topic.cursorLine = loaded.lineStart;
904
+ this.state.status = "";
905
+ this.render();
906
+ if (delta > 0)
907
+ void this.checkAutoLoad();
908
+ return;
909
+ }
910
+ if (delta > 0 && topic.hasMore && !this.state.loadingMore) {
911
+ await this.jumpToTopicFloor(targetFloor, this.nextSignal());
912
+ }
913
+ }
914
+ async jumpToNextPage() {
915
+ const topic = this.state.topic;
916
+ if (!topic)
917
+ return;
918
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
919
+ if (pageInfo.currentPage < pageInfo.totalPages) {
920
+ await this.jumpToTopicPage(pageInfo.currentPage + 1, this.nextSignal());
921
+ }
922
+ else if (topic.hasMore && !this.state.loadingMore) {
923
+ // 当前是最后一页,但还有更多内容,加载下一页
924
+ await this.loadNextTopicPage(this.nextSignal());
925
+ const newPageInfo = getTopicPageInfo(topic, topic.cursorLine);
926
+ topic.cursorLine = jumpToPage(topic, newPageInfo.currentPage + 1);
927
+ this.state.status = "";
928
+ this.render();
929
+ }
930
+ }
931
+ async jumpToPrevPage() {
932
+ const topic = this.state.topic;
933
+ if (!topic)
934
+ return;
935
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
936
+ if (pageInfo.currentPage > 1) {
937
+ topic.cursorLine = jumpToPage(topic, pageInfo.currentPage - 1);
938
+ this.state.status = "";
939
+ this.render();
940
+ }
941
+ }
942
+ async jumpToTopicPage(page, signal) {
943
+ const topic = this.state.topic;
944
+ if (!topic)
945
+ return;
946
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
947
+ if (page < 1 || page > pageInfo.totalPages) {
948
+ this.state.status = `未找到第 ${page} 页`;
949
+ this.render();
950
+ return;
951
+ }
952
+ // 如果目标页未加载,需要加载到该页
953
+ const targetFloor = (page - 1) * FLOORS_PER_PAGE + 1;
954
+ while (topic.hasMore && !findTopicPostByFloor(topic, targetFloor)) {
955
+ const previousLoaded = topic.loaded;
956
+ await this.loadNextTopicPage(signal, true);
957
+ if (topic.loaded === previousLoaded)
958
+ break;
959
+ }
960
+ const post = findTopicPostByFloor(topic, targetFloor);
961
+ if (post) {
962
+ topic.cursorLine = post.lineStart;
963
+ this.state.status = "";
964
+ }
965
+ else {
966
+ this.state.status = `未找到第 ${page} 页`;
967
+ }
968
+ this.render();
969
+ }
786
970
  async loadNextTopicPage(signal, quiet = false) {
787
971
  const topic = this.state.topic;
788
972
  if (!topic?.hasMore || this.state.loadingMore)
@@ -793,7 +977,7 @@ export class TuiController {
793
977
  try {
794
978
  const posts = asArray(await this.client.getTopicPosts(topic.topicId, topic.loaded, topic.size, false, signal));
795
979
  appendTopicPosts(topic, posts);
796
- this.state.status = topic.hasMore ? "已加载下一页" : "已到底";
980
+ this.state.status = "";
797
981
  }
798
982
  catch (error) {
799
983
  if (!isAbortError(error))
@@ -810,17 +994,20 @@ export class TuiController {
810
994
  return;
811
995
  const loaded = findTopicPostByFloor(topic, floor);
812
996
  if (loaded) {
813
- this.state.scroll = loaded.lineStart;
814
- this.state.status = getStatus(this.state);
997
+ topic.cursorLine = loaded.lineStart;
998
+ this.state.status = "";
815
999
  this.render();
816
1000
  return;
817
1001
  }
818
1002
  while (topic.hasMore && !findTopicPostByFloor(topic, floor)) {
1003
+ const previousLoaded = topic.loaded;
819
1004
  await this.loadNextTopicPage(signal, true);
1005
+ if (topic.loaded === previousLoaded)
1006
+ break;
820
1007
  }
821
1008
  const post = findTopicPostByFloor(topic, floor);
822
- this.state.scroll = post?.lineStart ?? this.state.scroll;
823
- this.state.status = post ? getStatus(this.state) : `未找到 ${floor} 楼`;
1009
+ topic.cursorLine = post?.lineStart ?? topic.cursorLine;
1010
+ this.state.status = post ? "" : `未找到 ${floor} 楼`;
824
1011
  this.render();
825
1012
  }
826
1013
  async runReadOnlyAction(action, signal) {
@@ -893,7 +1080,7 @@ export class TuiController {
893
1080
  this.render();
894
1081
  try {
895
1082
  const results = this.state.searchMode === "topics"
896
- ? asArray(await this.client.searchTopics(query, 0, 20, true, signal)).map((topic) => topicItem(topic))
1083
+ ? this.filterSearchTopicScope(asArray(await this.client.searchTopics(query, 0, 20, true, signal)).map((topic) => topicItem(topic)))
897
1084
  : asArray(await this.client.searchUsers(query, true, signal)).map((user) => userItem(user));
898
1085
  this.state.searchResults = results;
899
1086
  this.state.itemIndex = 0;
@@ -907,6 +1094,13 @@ export class TuiController {
907
1094
  this.render();
908
1095
  }
909
1096
  }
1097
+ filterSearchTopicScope(items) {
1098
+ const boardId = this.state.searchScope.boardId;
1099
+ if (boardId === undefined) {
1100
+ return items;
1101
+ }
1102
+ return items.filter((item) => item.boardId === boardId);
1103
+ }
910
1104
  async toggleFavorite() {
911
1105
  const topic = this.state.topic;
912
1106
  if (!topic)
@@ -931,7 +1125,7 @@ export class TuiController {
931
1125
  const topic = this.state.topic;
932
1126
  if (!topic)
933
1127
  return;
934
- const post = currentTopicPost(topic, this.state.scroll);
1128
+ const post = currentTopicPost(topic, topic.cursorLine);
935
1129
  if (!post?.id) {
936
1130
  this.state.status = "当前楼层没有可操作的帖子 ID";
937
1131
  this.render();
@@ -950,7 +1144,7 @@ export class TuiController {
950
1144
  const topic = this.state.topic;
951
1145
  if (!topic)
952
1146
  return;
953
- const post = currentTopicPost(topic, this.state.scroll);
1147
+ const post = currentTopicPost(topic, topic.cursorLine);
954
1148
  if (!post?.userId) {
955
1149
  this.state.status = "当前楼层没有用户 ID";
956
1150
  this.render();
@@ -1006,7 +1200,7 @@ export class TuiController {
1006
1200
  const topic = this.state.topic;
1007
1201
  if (!topic)
1008
1202
  return;
1009
- const post = currentTopicPost(topic, this.state.scroll);
1203
+ const post = currentTopicPost(topic, topic.cursorLine);
1010
1204
  if (!post?.id)
1011
1205
  return;
1012
1206
  try {
@@ -1052,6 +1246,64 @@ export class TuiController {
1052
1246
  }
1053
1247
  this.render();
1054
1248
  }
1249
+ getCurrentTopicLine() {
1250
+ const topic = this.state.topic;
1251
+ if (!topic)
1252
+ return undefined;
1253
+ for (const post of topic.posts) {
1254
+ const line = post.lines.find((entry) => entry.line === topic.cursorLine);
1255
+ if (line)
1256
+ return line;
1257
+ }
1258
+ return undefined;
1259
+ }
1260
+ async openImage(url) {
1261
+ this.state.status = "正在下载图片...";
1262
+ this.render();
1263
+ try {
1264
+ const cache = getImageCache();
1265
+ const localPath = await cache.getOrDownload(url);
1266
+ this.state.status = `已缓存: ${localPath}`;
1267
+ this.render();
1268
+ // 用系统默认程序打开图片
1269
+ const { execFile } = await import("node:child_process");
1270
+ const platform = process.platform;
1271
+ const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
1272
+ const args = platform === "win32" ? ["/c", "start", "", localPath] : [localPath];
1273
+ execFile(command, args, (error) => {
1274
+ if (error) {
1275
+ this.state.status = `打开失败: ${error.message}`;
1276
+ this.render();
1277
+ }
1278
+ });
1279
+ }
1280
+ catch (error) {
1281
+ this.state.status = error instanceof Error ? error.message : "图片下载失败";
1282
+ this.render();
1283
+ }
1284
+ }
1285
+ async copyToClipboard(text) {
1286
+ try {
1287
+ const { spawn } = await import("node:child_process");
1288
+ const platform = process.platform;
1289
+ const command = platform === "win32" ? "clip" : platform === "darwin" ? "pbcopy" : "xclip";
1290
+ const args = platform === "linux" ? ["-selection", "clipboard"] : [];
1291
+ const child = spawn(command, args);
1292
+ child.stdin.end(text);
1293
+ child.on("error", () => {
1294
+ this.state.status = "复制失败";
1295
+ this.render();
1296
+ });
1297
+ child.on("close", (code) => {
1298
+ this.state.status = code === 0 ? "已复制到剪贴板" : "复制失败";
1299
+ this.render();
1300
+ });
1301
+ }
1302
+ catch {
1303
+ this.state.status = "复制失败";
1304
+ this.render();
1305
+ }
1306
+ }
1055
1307
  async signin() {
1056
1308
  this.state.status = "正在签到...";
1057
1309
  this.render();
@@ -1066,6 +1318,147 @@ export class TuiController {
1066
1318
  }
1067
1319
  this.render();
1068
1320
  }
1321
+ async openKeybindingEditor() {
1322
+ // 显示快捷键配置信息
1323
+ const config = this.keybindings.getConfig();
1324
+ const lines = [
1325
+ "快捷键配置文件: ~/.cc98-cli/keybindings.json",
1326
+ "",
1327
+ "当前配置:"
1328
+ ];
1329
+ // 显示主要快捷键
1330
+ const mainActions = [
1331
+ "moveUp", "moveDown", "moveLeft", "moveRight", "confirm", "back",
1332
+ "search", "refresh", "menu", "help", "quit",
1333
+ "topicNextPage", "topicPrevPage", "topicNextFloor", "topicPrevFloor",
1334
+ "topicJumpPage", "topicJumpFloor", "topicJumpLast"
1335
+ ];
1336
+ for (const action of mainActions) {
1337
+ const keys = config[action] ?? [];
1338
+ const desc = this.keybindings.getActionDescription(action);
1339
+ const keyStr = keys.map(k => this.keybindings.formatKey(k)).join("/");
1340
+ lines.push(` ${desc}: ${keyStr}`);
1341
+ }
1342
+ lines.push("", "编辑配置文件后重启生效。", "", "按 Esc 返回设置");
1343
+ this.state.modal = "info";
1344
+ this.state.infoTitle = "快捷键设置";
1345
+ this.state.infoLines = lines;
1346
+ this.render();
1347
+ }
1348
+ async openAccountSwitcher() {
1349
+ try {
1350
+ const accounts = await this.tokenStore.listAccounts();
1351
+ const currentAccount = await this.tokenStore.getCurrentAccountName();
1352
+ if (accounts.length === 0) {
1353
+ this.state.modal = "info";
1354
+ this.state.infoTitle = "切换账号";
1355
+ this.state.infoLines = ["暂无保存的账号", "", "请先登录账号。"];
1356
+ this.render();
1357
+ return;
1358
+ }
1359
+ // 构建账号列表
1360
+ const items = accounts.map(account => ({
1361
+ title: account.displayName || account.username || account.account,
1362
+ meta: `account:${account.account}`,
1363
+ detail: `${account.account === currentAccount ? "✓ 当前" : "切换到此账号"}${account.userId ? ` · ID: ${account.userId}` : ""}`
1364
+ }));
1365
+ this.snapshotParent();
1366
+ this.state.viewTitle = "切换账号";
1367
+ this.state.items = items;
1368
+ this.state.stats = [{ title: "账号数", detail: `${accounts.length}` }];
1369
+ this.state.itemIndex = accounts.findIndex(a => a.account === currentAccount);
1370
+ this.state.scroll = 0;
1371
+ this.state.focus = "content";
1372
+ this.state.mode = "list";
1373
+ this.state.status = "选择账号: j/k 选择 Enter 切换 h 返回";
1374
+ this.render();
1375
+ }
1376
+ catch (error) {
1377
+ this.state.status = error instanceof Error ? error.message : "读取账号失败";
1378
+ this.render();
1379
+ }
1380
+ }
1381
+ async switchAccount(accountName) {
1382
+ try {
1383
+ await this.tokenStore.useAccount(accountName);
1384
+ this.state.status = `已切换到账号: ${accountName}`;
1385
+ this.state.parentList = undefined;
1386
+ this.state.mode = "list";
1387
+ this.state.focus = "nav";
1388
+ await this.load(true);
1389
+ }
1390
+ catch (error) {
1391
+ this.state.status = error instanceof Error ? error.message : "切换账号失败";
1392
+ this.render();
1393
+ }
1394
+ }
1395
+ async confirmLogout() {
1396
+ const account = await this.tokenStore.getCurrentAccountName();
1397
+ const lines = [
1398
+ `当前账号: ${account || "未知"}`,
1399
+ "",
1400
+ "退出登录将清除所有保存的账号信息。",
1401
+ "清除后需要重新登录。",
1402
+ "",
1403
+ `${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`
1404
+ ];
1405
+ this.state.modal = "info";
1406
+ this.state.infoTitle = "退出登录";
1407
+ this.state.infoLines = lines;
1408
+ this.state.confirmCallback = () => void this.performLogout();
1409
+ this.render();
1410
+ }
1411
+ async performLogout() {
1412
+ try {
1413
+ await this.tokenStore.clear();
1414
+ this.state.status = "已退出登录";
1415
+ this.state.parentList = undefined;
1416
+ this.state.mode = "list";
1417
+ this.state.focus = "nav";
1418
+ await this.load(true);
1419
+ }
1420
+ catch (error) {
1421
+ this.state.status = error instanceof Error ? error.message : "退出失败";
1422
+ this.render();
1423
+ }
1424
+ }
1425
+ async openCacheManager() {
1426
+ try {
1427
+ const stats = await this.client.getCacheStats();
1428
+ const cacheDir = "~/.cc98-cli/cache/";
1429
+ const lines = [
1430
+ `缓存目录: ${cacheDir}`,
1431
+ `文件数量: ${stats.fileCacheEntries}`,
1432
+ "",
1433
+ "缓存策略:",
1434
+ " 版面主题: 30s",
1435
+ " 版面信息: 24h",
1436
+ " 用户信息: 5min",
1437
+ "",
1438
+ "Enter 清理缓存 Esc 返回"
1439
+ ];
1440
+ this.state.modal = "info";
1441
+ this.state.infoTitle = "缓存管理";
1442
+ this.state.infoLines = lines;
1443
+ this.state.confirmCallback = () => void this.clearCache();
1444
+ this.render();
1445
+ }
1446
+ catch (error) {
1447
+ this.state.status = error instanceof Error ? error.message : "读取缓存信息失败";
1448
+ this.render();
1449
+ }
1450
+ }
1451
+ async clearCache() {
1452
+ try {
1453
+ await this.client.clearCache();
1454
+ this.state.status = "缓存已清理";
1455
+ await this.load(true);
1456
+ }
1457
+ catch {
1458
+ this.state.status = "缓存清理失败";
1459
+ this.render();
1460
+ }
1461
+ }
1069
1462
  async openFriendUsers(type, signal) {
1070
1463
  const ids = asArray(await this.client.getFriendIds(type, 0, 20, false, signal)).filter((id) => typeof id === "number");
1071
1464
  const users = asArray(await this.client.getUsers(ids, false, signal));
@@ -1083,7 +1476,7 @@ export class TuiController {
1083
1476
  this.state.currentChat = undefined;
1084
1477
  this.state.topic = undefined;
1085
1478
  this.state.mode = "list";
1086
- this.state.status = `${title}: j/k 选择 l 打开 h 返回`;
1479
+ this.state.status = `${title}: j/k 选择 Enter 打开 h 返回`;
1087
1480
  this.render();
1088
1481
  }
1089
1482
  snapshotParent() {
@@ -1114,5 +1507,51 @@ export class TuiController {
1114
1507
  this.state.focus = "content";
1115
1508
  this.state.scroll = 0;
1116
1509
  }
1510
+ async checkUpdate(forceShow = false) {
1511
+ if (forceShow) {
1512
+ this.state.status = "正在检查 GitHub Release...";
1513
+ this.render();
1514
+ }
1515
+ try {
1516
+ const result = await checkForUpdate();
1517
+ if (!result.updateAvailable || !result.latest) {
1518
+ this.state.updateAvailable = undefined;
1519
+ if (forceShow) {
1520
+ this.state.status = result.message;
1521
+ this.render();
1522
+ }
1523
+ return;
1524
+ }
1525
+ const lastSeen = await this.settingsStore.getLastSeenVersion();
1526
+ const isNew = forceShow || lastSeen !== result.latest.version;
1527
+ this.state.updateAvailable = {
1528
+ version: result.latest.version,
1529
+ tagName: result.latest.tagName,
1530
+ url: result.latest.url,
1531
+ body: result.latest.body,
1532
+ isNew
1533
+ };
1534
+ if (forceShow) {
1535
+ this.state.status = result.message;
1536
+ }
1537
+ this.render();
1538
+ }
1539
+ catch (error) {
1540
+ if (forceShow) {
1541
+ this.state.status = error instanceof Error ? error.message : "检查更新失败";
1542
+ this.render();
1543
+ }
1544
+ }
1545
+ }
1546
+ dismissUpdate() {
1547
+ if (this.state.updateAvailable) {
1548
+ const version = this.state.updateAvailable.version;
1549
+ this.state.updateAvailable = undefined;
1550
+ this.render();
1551
+ void this.settingsStore.setLastSeenVersion(version).catch(() => {
1552
+ // 忽略已读状态写入失败,避免影响 TUI 操作。
1553
+ });
1554
+ }
1555
+ }
1117
1556
  }
1118
1557
  //# sourceMappingURL=controller.js.map