cc98-cli 0.1.0 → 0.2.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 (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +45 -180
  3. package/dist/api/client.d.ts +3 -0
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +67 -1
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/api/types.d.ts +12 -0
  8. package/dist/api/types.d.ts.map +1 -1
  9. package/dist/cli/commands/cache.d.ts +2 -0
  10. package/dist/cli/commands/cache.d.ts.map +1 -0
  11. package/dist/cli/commands/cache.js +68 -0
  12. package/dist/cli/commands/cache.js.map +1 -0
  13. package/dist/cli/commands/update.d.ts +2 -0
  14. package/dist/cli/commands/update.d.ts.map +1 -0
  15. package/dist/cli/commands/update.js +24 -0
  16. package/dist/cli/commands/update.js.map +1 -0
  17. package/dist/cli/router.d.ts.map +1 -1
  18. package/dist/cli/router.js +10 -1
  19. package/dist/cli/router.js.map +1 -1
  20. package/dist/storage/cache-store.d.ts +45 -2
  21. package/dist/storage/cache-store.d.ts.map +1 -1
  22. package/dist/storage/cache-store.js +158 -5
  23. package/dist/storage/cache-store.js.map +1 -1
  24. package/dist/storage/token-store.js +1 -1
  25. package/dist/storage/token-store.js.map +1 -1
  26. package/dist/tui/app.d.ts.map +1 -1
  27. package/dist/tui/app.js +792 -123
  28. package/dist/tui/app.js.map +1 -1
  29. package/dist/tui/cached-client.d.ts +20 -1
  30. package/dist/tui/cached-client.d.ts.map +1 -1
  31. package/dist/tui/cached-client.js +20 -2
  32. package/dist/tui/cached-client.js.map +1 -1
  33. package/dist/update.d.ts +17 -0
  34. package/dist/update.d.ts.map +1 -0
  35. package/dist/update.js +88 -0
  36. package/dist/update.js.map +1 -0
  37. package/dist/version.d.ts +6 -0
  38. package/dist/version.d.ts.map +1 -0
  39. package/dist/version.js +6 -0
  40. package/dist/version.js.map +1 -0
  41. package/docs/images/tui.jpg +0 -0
  42. package/package.json +4 -2
package/dist/tui/app.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Cc98Client } from "../api/client.js";
2
2
  import { TokenStore } from "../storage/token-store.js";
3
+ import { checkForUpdate } from "../update.js";
4
+ import { appVersion } from "../version.js";
3
5
  import { ansi, bg, fg, stripAnsi } from "./ansi.js";
4
6
  import { CachedCc98Client } from "./cached-client.js";
5
7
  import { Terminal } from "./terminal.js";
@@ -12,35 +14,13 @@ const muted = fg(139, 152, 166);
12
14
  const line = fg(52, 84, 112);
13
15
  const danger = fg(245, 101, 101);
14
16
  const ok = fg(91, 207, 140);
15
- const mascot = [
16
- " ▄▄▄ ▄▄▄ ▄████▄███▄▄",
17
- " ███▀█▄▄▄██▀██ ▀██ ██▄ ██▄▄▄",
18
- " ▄██ ▀▀▀▀▀ ▀██▄ ████▀▀▀▀▀▀▀▀███▄",
19
- " █▀ ██▄██▀ ▀██▄",
20
- " █▀ ███▀ ▀██▄",
21
- "██ ██ ██ ██ ████ ███",
22
- "██ ▄██▀ ▀▀ ▄▄█ ██ ▀ ▀ ███",
23
- "██▄ ▀ ▀▀ ██ ▄███",
24
- " ▀██▄▄ ███▄▄ ████▀",
25
- " ▀██▀▄▄▄▄ ████▀ ▀██",
26
- " ██▀ █▀▀█ ▄█████▄ ██▄",
27
- " ▄██ █▄ ▀█▄▄▄▄ ████▀▄█▀ ██▀",
28
- " ███▄▄ ▀▀▄█████▀▄▄█████▄▄▄ ▄▄▄▄▄██",
29
- " ▀▀█████████████▀▀ ▀▀███████████▀"
30
- ];
31
- const mascotCompact = [
32
- " ▄▄▄ ▄▄▄ ▄███▄▀█▄▄",
33
- " ██▀█████▀██ ▀█▄ ██▄▄███▄▄",
34
- " ▄█▀ ▀ ▀██ ▄██▀▀ ▀▀▀ ▀▀██",
35
- " █▀ ███▀ ██▄",
36
- "█▀ ██▄█ ██ █▄▄ ██",
37
- "█ ▄█▀▀▀ ▄█ ██ ▀▀▀ ██",
38
- "██ ▀ ▀ ██▄ ▄▄██",
39
- " ▀██▄▄ ████ ███▀",
40
- " ▄██▀▄█▄ ███▄ ██",
41
- " ██ ▄ █▄ ▄ ████▀█ ██▀",
42
- " ▀██ ▀█▄████ ████▀▀ ██",
43
- " ▀██▄███████▀▀ ▀▀███▄█████▀"
17
+ const mascotMini = [
18
+ " ▄▄▄ ▄▄▄ ▄███",
19
+ " ██▀█████▀█▄ ██",
20
+ "█▀ ▀ ▀ ██ ██",
21
+ " ██▄█ █▄▄ ██",
22
+ "██ ▀ ████▄██",
23
+ " ▀██▄▄██████▀"
44
24
  ];
45
25
  const navItems = [
46
26
  { id: "hot", label: "十大", hint: "热门话题" },
@@ -49,7 +29,15 @@ const navItems = [
49
29
  { id: "boards", label: "版面", hint: "所有分区" },
50
30
  { id: "following", label: "关注", hint: "用户动态" },
51
31
  { id: "messages", label: "消息", hint: "未读与私信" },
52
- { id: "me", label: "我的", hint: "当前账号" }
32
+ { id: "me", label: "我的", hint: "当前账号" },
33
+ { id: "settings", label: "设置", hint: "账号与配置" }
34
+ ];
35
+ const settingsItems = [
36
+ { title: "切换账号", meta: "account", detail: "选择或管理登录账号" },
37
+ { title: "检查更新", meta: "update", detail: "检查 CC98-CLI 新版本" },
38
+ { title: "缓存管理", meta: "cache", detail: "查看和清理本地缓存" },
39
+ { title: "快捷键帮助", meta: "help", detail: "查看所有可用快捷键" },
40
+ { title: "退出登录", meta: "logout", detail: "清除本地登录信息" }
53
41
  ];
54
42
  export async function runTui() {
55
43
  const terminal = new Terminal();
@@ -64,11 +52,14 @@ export async function runTui() {
64
52
  scroll: 0,
65
53
  loading: true,
66
54
  loadingMore: false,
67
- status: "左栏:j/k 选栏目 l/Enter 进入内容 r 刷新 q 退出",
55
+ status: "",
68
56
  viewTitle: "十大",
69
57
  items: [],
70
58
  stats: [],
71
- overview: []
59
+ overview: [],
60
+ modal: null,
61
+ menuIndex: 0,
62
+ menuItems: []
72
63
  };
73
64
  terminal.enter();
74
65
  try {
@@ -91,7 +82,10 @@ export async function runTui() {
91
82
  state.error = undefined;
92
83
  state.itemIndex = 0;
93
84
  state.scroll = 0;
94
- state.mode = "list";
85
+ state.mode = nav.id === "settings" && state.mode === "settings" ? "settings" : "list";
86
+ if (state.mode === "settings") {
87
+ state.focus = "content";
88
+ }
95
89
  state.items = [];
96
90
  state.stats = [];
97
91
  state.topic = undefined;
@@ -111,7 +105,7 @@ export async function runTui() {
111
105
  if (next.overview) {
112
106
  state.overview = next.overview;
113
107
  }
114
- state.status = next.status ?? listStatus(state);
108
+ state.status = next.status ?? getStatus(state);
115
109
  }
116
110
  catch (error) {
117
111
  if (isAbortError(error)) {
@@ -143,23 +137,139 @@ export async function runTui() {
143
137
  resolve();
144
138
  };
145
139
  const offResize = terminal.onResize(render);
140
+ // Helper to get menu items for current context
141
+ const getMenuItems = () => {
142
+ const items = [];
143
+ if (state.mode === "topic") {
144
+ items.push({ label: "刷新", key: "r", action: "refresh" });
145
+ items.push({ label: "返回列表", key: "h", action: "back" });
146
+ }
147
+ else if (state.mode === "list") {
148
+ items.push({ label: "刷新", key: "r", action: "refresh" });
149
+ if (state.currentBoard) {
150
+ items.push({ label: "返回版面列表", key: "h", action: "back" });
151
+ }
152
+ }
153
+ return items;
154
+ };
146
155
  const offKey = terminal.onKey((key) => {
156
+ // Global: Ctrl+C or q to quit
147
157
  if (key === "\u0003" || key === "q") {
148
158
  close();
149
159
  return;
150
160
  }
161
+ // Global: ? for help
162
+ if (key === "?") {
163
+ state.modal = state.modal === "help" ? null : "help";
164
+ render();
165
+ return;
166
+ }
167
+ // Handle modal states
168
+ if (state.modal === "help") {
169
+ if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "?" || key === "\r") {
170
+ state.modal = null;
171
+ render();
172
+ }
173
+ return;
174
+ }
175
+ if (state.modal === "menu") {
176
+ if (key === "j" || key === "\x1b[B") {
177
+ state.menuIndex = Math.min(state.menuItems.length - 1, state.menuIndex + 1);
178
+ render();
179
+ return;
180
+ }
181
+ if (key === "k" || key === "\x1b[A") {
182
+ state.menuIndex = Math.max(0, state.menuIndex - 1);
183
+ render();
184
+ return;
185
+ }
186
+ if (key === "\r" || key === "l" || key === "\x1b[C") {
187
+ const selected = state.menuItems[state.menuIndex];
188
+ state.modal = null;
189
+ if (selected?.action === "refresh") {
190
+ void load(true);
191
+ }
192
+ else if (selected?.action === "back") {
193
+ if (state.mode === "topic") {
194
+ currentAbort?.abort();
195
+ state.mode = "list";
196
+ state.focus = "content";
197
+ state.status = getStatus(state);
198
+ render();
199
+ }
200
+ else if (state.parentList) {
201
+ currentAbort?.abort();
202
+ restoreParentList(state);
203
+ render();
204
+ }
205
+ }
206
+ return;
207
+ }
208
+ if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "o") {
209
+ state.modal = null;
210
+ render();
211
+ return;
212
+ }
213
+ return;
214
+ }
215
+ // Topic mode
151
216
  if (state.mode === "topic") {
152
- if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "\x7f") {
217
+ if (/^\d$/.test(key) && state.topic) {
218
+ state.topic.floorInput = `${state.topic.floorInput}${key}`.slice(0, 6);
219
+ state.status = `跳转到 ${state.topic.floorInput} 楼:Enter 确认 Esc 取消`;
220
+ render();
221
+ return;
222
+ }
223
+ if (key === "\x7f" && state.topic?.floorInput) {
224
+ state.topic.floorInput = state.topic.floorInput.slice(0, -1);
225
+ state.status = state.topic.floorInput
226
+ ? `跳转到 ${state.topic.floorInput} 楼:Enter 确认 Esc 取消`
227
+ : getStatus(state);
228
+ render();
229
+ return;
230
+ }
231
+ if (key === "\r" && state.topic?.floorInput) {
232
+ const floor = Number(state.topic.floorInput);
233
+ state.topic.floorInput = "";
234
+ if (Number.isInteger(floor) && floor > 0) {
235
+ void jumpToTopicFloor(client, state, floor, render, nextSignal());
236
+ }
237
+ return;
238
+ }
239
+ if ((key === "]" || key === "】") && state.topic) {
240
+ jumpRelativeTopicFloor(state, 1);
241
+ state.status = getStatus(state);
242
+ render();
243
+ return;
244
+ }
245
+ if ((key === "[" || key === "【") && state.topic) {
246
+ jumpRelativeTopicFloor(state, -1);
247
+ state.status = getStatus(state);
248
+ render();
249
+ return;
250
+ }
251
+ if (key === "h" || key === "\x1b[D") {
153
252
  currentAbort?.abort();
154
253
  state.mode = "list";
155
254
  state.focus = "content";
156
- state.status = listStatus(state);
255
+ state.status = getStatus(state);
256
+ render();
257
+ return;
258
+ }
259
+ if (key === "\x1b" && state.topic?.floorInput) {
260
+ state.topic.floorInput = "";
261
+ state.status = getStatus(state);
157
262
  render();
158
263
  return;
159
264
  }
160
265
  if (key === "j" || key === "\x1b[B") {
161
- state.scroll = Math.min(Math.max(0, (state.topic?.lines.length ?? 0) - 1), state.scroll + 1);
266
+ const maxScroll = Math.max(0, (state.topic?.lines.length ?? 0) - 1);
267
+ const wasAtEnd = state.scroll >= maxScroll;
268
+ state.scroll = Math.min(maxScroll, state.scroll + 1);
162
269
  render();
270
+ if (wasAtEnd && state.topic?.hasMore && !state.loadingMore) {
271
+ void loadNextTopicPage(client, state, render, nextSignal(), true);
272
+ }
163
273
  return;
164
274
  }
165
275
  if (key === "k" || key === "\x1b[A") {
@@ -177,8 +287,75 @@ export async function runTui() {
177
287
  }
178
288
  return;
179
289
  }
290
+ if (key === "o") {
291
+ state.modal = "menu";
292
+ state.menuItems = getMenuItems();
293
+ state.menuIndex = 0;
294
+ render();
295
+ return;
296
+ }
297
+ return;
298
+ }
299
+ // Settings mode
300
+ if (state.mode === "settings") {
301
+ if (key === "j" || key === "\x1b[B") {
302
+ state.itemIndex = Math.min(settingsItems.length - 1, state.itemIndex + 1);
303
+ render();
304
+ return;
305
+ }
306
+ if (key === "k" || key === "\x1b[A") {
307
+ state.itemIndex = Math.max(0, state.itemIndex - 1);
308
+ render();
309
+ return;
310
+ }
311
+ if (key === "h" || key === "\x1b[D") {
312
+ state.mode = "list";
313
+ state.focus = "nav";
314
+ state.status = getStatus(state);
315
+ render();
316
+ return;
317
+ }
318
+ if (key === "l" || key === "\x1b[C" || key === "\r") {
319
+ const selected = settingsItems[state.itemIndex];
320
+ if (selected?.meta === "help") {
321
+ state.modal = "help";
322
+ render();
323
+ }
324
+ else if (selected?.meta === "cache") {
325
+ state.status = "正在清理缓存...";
326
+ render();
327
+ void client.clearCache().then(() => {
328
+ state.status = "缓存已清理";
329
+ void load(true);
330
+ }).catch(() => {
331
+ state.status = "缓存清理失败";
332
+ render();
333
+ });
334
+ }
335
+ else if (selected?.meta === "logout") {
336
+ state.status = "退出登录功能开发中...";
337
+ render();
338
+ }
339
+ else if (selected?.meta === "account") {
340
+ state.status = "账号切换功能开发中...";
341
+ render();
342
+ }
343
+ else if (selected?.meta === "update") {
344
+ state.status = "正在检查 GitHub Release...";
345
+ render();
346
+ void checkForUpdate().then((result) => {
347
+ state.status = result.message;
348
+ render();
349
+ }).catch((error) => {
350
+ state.status = error instanceof Error ? error.message : "检查更新失败";
351
+ render();
352
+ });
353
+ }
354
+ return;
355
+ }
180
356
  return;
181
357
  }
358
+ // Nav focus
182
359
  if (state.focus === "nav") {
183
360
  if (key === "j" || key === "\x1b[B") {
184
361
  state.navIndex = Math.min(navItems.length - 1, state.navIndex + 1);
@@ -190,17 +367,27 @@ export async function runTui() {
190
367
  void load();
191
368
  return;
192
369
  }
193
- if (key === "l" || key === "\x1b[C" || key === "\t" || key === "\r") {
370
+ if (key === "l" || key === "\x1b[C") {
194
371
  if (!state.loading && state.items.length > 0) {
372
+ if (navItems[state.navIndex]?.id === "settings") {
373
+ state.mode = "settings";
374
+ }
195
375
  state.focus = "content";
196
- state.status = listStatus(state);
376
+ state.status = getStatus(state);
197
377
  render();
198
378
  }
199
379
  return;
200
380
  }
201
- if (key === "h" || key === "\x1b[D") {
202
- state.status = listStatus(state);
203
- render();
381
+ if (key === "\r") {
382
+ if (!state.loading && state.items.length > 0) {
383
+ if (navItems[state.navIndex]?.id === "settings") {
384
+ state.mode = "settings";
385
+ }
386
+ state.focus = "content";
387
+ state.itemIndex = 0;
388
+ state.status = getStatus(state);
389
+ render();
390
+ }
204
391
  return;
205
392
  }
206
393
  if (key === "r") {
@@ -209,6 +396,7 @@ export async function runTui() {
209
396
  }
210
397
  return;
211
398
  }
399
+ // Content focus
212
400
  if (key === "j" || key === "\x1b[B") {
213
401
  state.itemIndex = Math.min(Math.max(0, state.items.length - 1), state.itemIndex + 1);
214
402
  render();
@@ -219,20 +407,53 @@ export async function runTui() {
219
407
  render();
220
408
  return;
221
409
  }
222
- if ((key === "\x7f" || key === "\x1b") && state.parentList) {
223
- currentAbort?.abort();
224
- restoreParentList(state);
225
- render();
410
+ if (key === "h" || key === "\x1b[D") {
411
+ if (state.parentList) {
412
+ currentAbort?.abort();
413
+ restoreParentList(state);
414
+ render();
415
+ }
416
+ else {
417
+ currentAbort?.abort();
418
+ state.focus = "nav";
419
+ state.status = getStatus(state);
420
+ render();
421
+ }
422
+ return;
423
+ }
424
+ if (key === "\x1b") {
425
+ if (state.parentList) {
426
+ currentAbort?.abort();
427
+ restoreParentList(state);
428
+ render();
429
+ }
430
+ else {
431
+ currentAbort?.abort();
432
+ state.focus = "nav";
433
+ state.status = getStatus(state);
434
+ render();
435
+ }
226
436
  return;
227
437
  }
228
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
229
- currentAbort?.abort();
230
- state.focus = "nav";
231
- state.status = listStatus(state);
438
+ if (key === "l" || key === "\x1b[C") {
439
+ const selected = state.items[state.itemIndex];
440
+ if (selected?.topicId !== undefined) {
441
+ void openTopic(client, state, selected.topicId, render, false, nextSignal());
442
+ return;
443
+ }
444
+ if (selected?.boardId !== undefined) {
445
+ void openBoard(client, state, selected.boardId, selected.title, render, false, nextSignal());
446
+ return;
447
+ }
448
+ if (selected?.chatUserId !== undefined) {
449
+ void openChat(client, state, selected.chatUserId, selected.title, render, false, nextSignal());
450
+ return;
451
+ }
452
+ state.status = "当前条目不可进入";
232
453
  render();
233
454
  return;
234
455
  }
235
- if (key === "l" || key === "\r" || key === "\x1b[C") {
456
+ if (key === "\r") {
236
457
  const selected = state.items[state.itemIndex];
237
458
  if (selected?.topicId !== undefined) {
238
459
  void openTopic(client, state, selected.topicId, render, false, nextSignal());
@@ -246,7 +467,7 @@ export async function runTui() {
246
467
  void openChat(client, state, selected.chatUserId, selected.title, render, false, nextSignal());
247
468
  return;
248
469
  }
249
- state.status = "当前条目不可继续打开;h 返回左栏,j/k 继续选择";
470
+ state.status = "当前条目不可进入";
250
471
  render();
251
472
  return;
252
473
  }
@@ -266,6 +487,13 @@ export async function runTui() {
266
487
  void load(true);
267
488
  return;
268
489
  }
490
+ if (key === "o") {
491
+ state.modal = "menu";
492
+ state.menuItems = getMenuItems();
493
+ state.menuIndex = 0;
494
+ render();
495
+ return;
496
+ }
269
497
  });
270
498
  render();
271
499
  void load();
@@ -290,11 +518,13 @@ async function openTopic(client, state, topicId, render, force = false, signal)
290
518
  title: `#${topicId}`,
291
519
  meta: "",
292
520
  lines: [],
521
+ posts: [],
293
522
  loaded: 0,
294
523
  size: 10,
295
524
  hasMore: true,
296
525
  imageCount: 0,
297
- linkCount: 0
526
+ linkCount: 0,
527
+ floorInput: ""
298
528
  };
299
529
  state.status = "正在打开帖子...";
300
530
  render();
@@ -361,7 +591,7 @@ async function openBoard(client, state, boardId, boardTitle, render, force = fal
361
591
  { title: "主题", detail: `${topics.length} 条` },
362
592
  { title: "缓存", detail: "topics 30s" }
363
593
  ];
364
- state.status = "版面帖子:j/k 选择 l/Enter 打开帖子 Esc/Backspace 返回版面列表 h 返回左栏";
594
+ state.status = "版面帖子:j/k 选择 l 打开帖子 h 返回 r 刷新";
365
595
  }
366
596
  catch (error) {
367
597
  if (isAbortError(error)) {
@@ -485,7 +715,7 @@ function restoreParentList(state) {
485
715
  state.itemIndex = parent.itemIndex;
486
716
  state.status = parent.status;
487
717
  }
488
- async function loadNextTopicPage(client, state, render, signal) {
718
+ async function loadNextTopicPage(client, state, render, signal, advanceAfterLoad = false) {
489
719
  if (!state.topic || state.loadingMore || !state.topic.hasMore) {
490
720
  return;
491
721
  }
@@ -494,12 +724,16 @@ async function loadNextTopicPage(client, state, render, signal) {
494
724
  render();
495
725
  try {
496
726
  const posts = asArray(await client.getTopicPosts(state.topic.topicId, state.topic.loaded, state.topic.size, false, signal));
497
- const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()));
727
+ const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), state.topic.lines.length);
498
728
  state.topic.lines.push(...next.lines);
729
+ state.topic.posts.push(...next.posts);
499
730
  state.topic.imageCount += next.imageCount;
500
731
  state.topic.linkCount += next.linkCount;
501
732
  state.topic.loaded += posts.length;
502
733
  state.topic.hasMore = posts.length === state.topic.size;
734
+ if (advanceAfterLoad && posts.length > 0) {
735
+ state.scroll = Math.min(Math.max(0, state.topic.lines.length - 1), state.scroll + 1);
736
+ }
503
737
  state.status = state.topic.hasMore
504
738
  ? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
505
739
  : "已到最后一页 j/k 滚动 h/Esc 返回 r 刷新";
@@ -531,33 +765,197 @@ function buildTopicReader(topicId, topic, posts, size) {
531
765
  title,
532
766
  meta,
533
767
  lines: rendered.lines,
768
+ posts: rendered.posts,
534
769
  loaded: posts.length,
535
770
  size,
536
771
  hasMore: posts.length === size,
537
772
  imageCount: rendered.imageCount,
538
- linkCount: rendered.linkCount
773
+ linkCount: rendered.linkCount,
774
+ floorInput: ""
539
775
  };
540
776
  }
541
- function renderPosts(posts, width) {
777
+ function renderPosts(posts, width, lineOffset = 0) {
542
778
  const lines = [];
779
+ const entries = [];
543
780
  let imageCount = 0;
544
781
  let linkCount = 0;
545
782
  posts.forEach((postRaw) => {
546
783
  const post = asObject(postRaw);
547
- const floor = post.floor !== undefined ? `#${post.floor}` : "#?";
784
+ const lineStart = lineOffset + lines.length;
785
+ const postLines = [];
786
+ const floorNumber = asNumber(post.floor);
787
+ const floor = floorNumber !== undefined ? `#${floorNumber}` : "#?";
548
788
  const author = String(post.userName ?? "匿名");
549
789
  const time = typeof post.time === "string" ? post.time.replace("T", " ").slice(0, 16) : "";
550
- const like = post.likeCount !== undefined ? ` · ${post.likeCount} 赞` : "";
551
- lines.push(`${floor} ${author}${time ? ` · ${time}` : ""}${like}`);
552
- lines.push("".repeat(Math.max(8, width)));
790
+ const likeCount = asNumber(post.likeCount) ?? 0;
791
+ const dislikeCount = asNumber(post.dislikeCount) ?? 0;
792
+ const like = likeCount > 0 ? ` · ${likeCount} 赞` : "";
793
+ const push = (text, kind, extra = {}) => {
794
+ const line = lineOffset + lines.length;
795
+ lines.push(text);
796
+ postLines.push({
797
+ line,
798
+ row: postLines.length,
799
+ floor: floorNumber,
800
+ kind,
801
+ text,
802
+ ...extra
803
+ });
804
+ };
805
+ push(`${floor} ${author}${time ? ` · ${time}` : ""}${like}`, "header");
806
+ push("─".repeat(Math.max(8, width)), "divider");
553
807
  const content = typeof post.content === "string" ? post.content : "";
554
808
  const rendered = renderUbbToLines(content, width);
555
- lines.push(...rendered.lines);
556
- lines.push("");
809
+ rendered.lines.forEach((renderedLine) => {
810
+ const imageIndex = parseBracketIndex(renderedLine, "image");
811
+ const linkIndex = parseBracketIndex(renderedLine, "link");
812
+ const kind = renderedLine.trim() === ""
813
+ ? "blank"
814
+ : imageIndex !== undefined
815
+ ? "image"
816
+ : linkIndex !== undefined
817
+ ? "link"
818
+ : renderedLine.startsWith("│ ")
819
+ ? "quote"
820
+ : "text";
821
+ push(renderedLine, kind, {
822
+ imageIndex,
823
+ imageUrl: imageIndex !== undefined ? rendered.images[imageIndex - 1] : undefined,
824
+ linkIndex,
825
+ linkUrl: linkIndex !== undefined ? rendered.links[linkIndex - 1] : undefined
826
+ });
827
+ });
828
+ push("", "blank");
829
+ const preview = rendered.lines.find((value) => value.trim() &&
830
+ !value.startsWith("[image ") &&
831
+ !value.startsWith("[link ")) ?? "";
832
+ entries.push({
833
+ id: asNumber(post.id),
834
+ floor: floorNumber,
835
+ author,
836
+ time,
837
+ likeCount,
838
+ dislikeCount,
839
+ rating: formatRating(post),
840
+ preview,
841
+ lineStart,
842
+ lineEnd: lineOffset + lines.length - 1,
843
+ imageCount: rendered.images.length,
844
+ linkCount: rendered.links.length,
845
+ images: rendered.images,
846
+ links: rendered.links,
847
+ lines: postLines
848
+ });
557
849
  imageCount += rendered.images.length;
558
850
  linkCount += rendered.links.length;
559
851
  });
560
- return { lines, imageCount, linkCount };
852
+ return { lines, posts: entries, imageCount, linkCount };
853
+ }
854
+ async function jumpToTopicFloor(client, state, floor, render, signal) {
855
+ const topic = state.topic;
856
+ if (!topic) {
857
+ return;
858
+ }
859
+ const loaded = findTopicPostByFloor(topic, floor);
860
+ if (loaded) {
861
+ state.scroll = loaded.lineStart;
862
+ state.status = getStatus(state);
863
+ render();
864
+ return;
865
+ }
866
+ const from = Math.floor((floor - 1) / topic.size) * topic.size;
867
+ state.loadingMore = true;
868
+ state.status = `正在读取 ${floor} 楼...`;
869
+ render();
870
+ try {
871
+ const posts = asArray(await client.getTopicPosts(topic.topicId, from, topic.size, false, signal));
872
+ const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), topic.lines.length);
873
+ topic.lines.push(...next.lines);
874
+ topic.posts.push(...next.posts);
875
+ topic.posts.sort((left, right) => (left.floor ?? 0) - (right.floor ?? 0));
876
+ topic.imageCount += next.imageCount;
877
+ topic.linkCount += next.linkCount;
878
+ topic.loaded = Math.max(topic.loaded, from + posts.length);
879
+ topic.hasMore = posts.length === topic.size;
880
+ const target = findTopicPostByFloor(topic, floor);
881
+ if (target) {
882
+ state.scroll = target.lineStart;
883
+ state.status = getStatus(state);
884
+ }
885
+ else {
886
+ state.status = `未找到 ${floor} 楼`;
887
+ }
888
+ }
889
+ catch (error) {
890
+ if (!isAbortError(error)) {
891
+ state.error = error instanceof Error ? error.message : String(error);
892
+ }
893
+ }
894
+ finally {
895
+ state.loadingMore = false;
896
+ render();
897
+ }
898
+ }
899
+ function jumpRelativeTopicFloor(state, delta) {
900
+ const topic = state.topic;
901
+ if (!topic || topic.posts.length === 0) {
902
+ return;
903
+ }
904
+ const current = currentTopicPost(topic, state.scroll);
905
+ const currentIndex = current ? topic.posts.indexOf(current) : 0;
906
+ const next = topic.posts[Math.min(topic.posts.length - 1, Math.max(0, currentIndex + delta))];
907
+ if (next) {
908
+ state.scroll = next.lineStart;
909
+ }
910
+ }
911
+ function findTopicPostByFloor(topic, floor) {
912
+ return topic.posts.find((entry) => entry.floor === floor);
913
+ }
914
+ function currentTopicPost(topic, scroll) {
915
+ return topic.posts.find((entry) => scroll >= entry.lineStart && scroll <= entry.lineEnd) ??
916
+ [...topic.posts].reverse().find((entry) => entry.lineStart <= scroll) ??
917
+ topic.posts[0];
918
+ }
919
+ function currentTopicLine(topic, scroll) {
920
+ const post = currentTopicPost(topic, scroll);
921
+ if (!post) {
922
+ return undefined;
923
+ }
924
+ return post.lines.find((entry) => entry.line === scroll) ??
925
+ post.lines.find((entry) => entry.line > scroll && entry.kind !== "blank") ??
926
+ post.lines.at(-1);
927
+ }
928
+ function lineKindLabel(kind) {
929
+ switch (kind) {
930
+ case "header":
931
+ return "楼层标题";
932
+ case "divider":
933
+ return "分隔线";
934
+ case "quote":
935
+ return "引用";
936
+ case "image":
937
+ return "图片";
938
+ case "link":
939
+ return "链接";
940
+ case "blank":
941
+ return "空行";
942
+ case "text":
943
+ return "正文";
944
+ }
945
+ }
946
+ function parseBracketIndex(value, label) {
947
+ const match = new RegExp(`\\[${label} (\\d+)`).exec(value);
948
+ return match ? Number(match[1]) : undefined;
949
+ }
950
+ function formatRating(post) {
951
+ const value = post.rating ?? post.ratingCount ?? post.wealth ?? post.score;
952
+ if (typeof value === "number" && Number.isFinite(value)) {
953
+ return String(value);
954
+ }
955
+ if (typeof value === "string" && value.trim()) {
956
+ return value.trim();
957
+ }
958
+ return undefined;
561
959
  }
562
960
  async function loadView(client, view, force, signal) {
563
961
  switch (view) {
@@ -591,7 +989,7 @@ async function loadView(client, view, force, signal) {
591
989
  title: "版面",
592
990
  items: boards,
593
991
  stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${flattenBoards(sections).length}` }],
594
- status: "版面:j/k 选择 l/Enter 读取该版主题 h 返回左栏 r 刷新"
992
+ status: "版面:j/k 选择 l 进入版面 h 返回 r 刷新"
595
993
  };
596
994
  }
597
995
  case "following": {
@@ -603,7 +1001,7 @@ async function loadView(client, view, force, signal) {
603
1001
  { title: "关注动态", detail: `${topics.length} 条` },
604
1002
  { title: "缓存", detail: "30s" }
605
1003
  ],
606
- status: "关注用户动态:j/k 选择 l/Enter 打开帖子 h 返回左栏 r 刷新"
1004
+ status: "关注:j/k 选择 l 打开帖子 h 返回 r 刷新"
607
1005
  };
608
1006
  }
609
1007
  case "favorite": {
@@ -628,7 +1026,7 @@ async function loadView(client, view, force, signal) {
628
1026
  { title: "主题", detail: `${items.length} 条` },
629
1027
  { title: "缓存", detail: "boards 24h / topics 30s" }
630
1028
  ],
631
- status: "收藏版面帖子:j/k 选择 l/Enter 打开帖子 h 返回左栏 r 刷新"
1029
+ status: "收藏:j/k 选择 l 打开帖子 h 返回 r 刷新"
632
1030
  };
633
1031
  }
634
1032
  case "messages": {
@@ -643,23 +1041,41 @@ async function loadView(client, view, force, signal) {
643
1041
  title: "消息",
644
1042
  items: chats.length > 0 ? chats.map((chat) => chatItem(chat, userNames)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
645
1043
  stats: unreadStats(unreadObject),
646
- status: "私信联系人:j/k 选择 l/Enter 打开会话 h 返回左栏 r 刷新"
1044
+ status: "消息:j/k 选择 l 打开会话 h 返回 r 刷新"
647
1045
  };
648
1046
  }
649
1047
  case "me": {
650
- const me = asObject(await client.getMe(force, signal));
1048
+ const [me, cacheStats] = await Promise.all([
1049
+ client.getMe(force, signal),
1050
+ client.getCacheStats()
1051
+ ]);
1052
+ const meObject = asObject(me);
651
1053
  return {
652
1054
  title: "我的",
653
1055
  items: [
654
- item("昵称", me.name),
655
- item("用户 ID", me.id),
656
- item("等级", me.levelTitle ?? me.groupName),
657
- item("发帖数", me.postCount),
658
- item("财富", me.wealth),
659
- item("关注", me.followCount),
660
- item("粉丝", me.fanCount)
1056
+ item("昵称", meObject.name),
1057
+ item("用户 ID", meObject.id),
1058
+ item("等级", meObject.levelTitle ?? meObject.groupName),
1059
+ item("发帖数", meObject.postCount),
1060
+ item("财富", meObject.wealth),
1061
+ item("关注", meObject.followCount),
1062
+ item("粉丝", meObject.fanCount)
661
1063
  ],
662
- stats: [{ title: "登录状态", detail: "已登录" }]
1064
+ stats: [
1065
+ { title: "登录状态", detail: "已登录" }
1066
+ ]
1067
+ };
1068
+ }
1069
+ case "settings": {
1070
+ const cacheStats = await client.getCacheStats();
1071
+ return {
1072
+ title: "设置",
1073
+ items: settingsItems,
1074
+ stats: [
1075
+ { title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` },
1076
+ { title: "版本", detail: `v${appVersion}` }
1077
+ ],
1078
+ status: "设置:j/k 选择 l 执行 h 返回"
663
1079
  };
664
1080
  }
665
1081
  }
@@ -681,24 +1097,31 @@ function draw(state, size) {
681
1097
  const right = rightWidth > 0 ? drawRight(state, rightWidth, bodyHeight) : [];
682
1098
  for (let row = 0; row < bodyHeight; row += 1) {
683
1099
  const parts = [
684
- sidebar[row] ?? " ".repeat(sidebarWidth),
1100
+ fit(sidebar[row] ?? "", sidebarWidth),
685
1101
  `${line}│${ansi.reset}`,
686
- main[row] ?? " ".repeat(mainWidth)
1102
+ fit(main[row] ?? "", mainWidth)
687
1103
  ];
688
1104
  if (rightWidth > 0) {
689
- parts.push(`${line}│${ansi.reset}`, right[row] ?? " ".repeat(rightWidth));
1105
+ parts.push(`${line}│${ansi.reset}`, fit(right[row] ?? "", rightWidth));
690
1106
  }
691
1107
  lines.push(parts.join(""));
692
1108
  }
693
1109
  lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
694
- lines.push(fit(`${muted}${state.status}${ansi.reset}`, width));
1110
+ lines.push(drawStatusBar(state, width));
1111
+ // Draw modal overlays
1112
+ if (state.modal === "help") {
1113
+ return drawHelpModal(lines, width, height);
1114
+ }
1115
+ if (state.modal === "menu") {
1116
+ return drawMenuModal(lines, state, width, height);
1117
+ }
695
1118
  return lines.slice(0, height).join("\n");
696
1119
  }
697
1120
  function header(width, state) {
698
1121
  const account = state.account ? `@${state.account}` : "未登录";
699
- const title = `${cc98BlueBg}${white}${ansi.bold} CC98 ${ansi.reset}${cc98BlueBg}${white} ${state.viewTitle} ${ansi.reset}`;
700
- const right = `${muted}${account}${ansi.reset}`;
701
- return fit(`${title}${" ".repeat(Math.max(1, width - cellWidth(title) - cellWidth(right)))}${right}`, width);
1122
+ const title = ` CC98 ${state.viewTitle} `;
1123
+ const padding = Math.max(1, width - cellWidth(title) - cellWidth(account));
1124
+ return `${cc98BlueBg}${white}${ansi.bold}${fit(`${title}${" ".repeat(padding)}${account}`, width)}${ansi.reset}`;
702
1125
  }
703
1126
  function drawOverview(state, width, height) {
704
1127
  const rows = [];
@@ -758,7 +1181,6 @@ function drawMain(state, width, height) {
758
1181
  }
759
1182
  const rows = [];
760
1183
  rows.push(`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`);
761
- rows.push(fit(`${muted} ${state.focus === "content" ? "内容栏" : "按 l/Enter 进入内容栏"}${ansi.reset}`, width));
762
1184
  rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
763
1185
  const visibleCapacity = Math.max(1, Math.floor(Math.max(1, height - 3) / 3));
764
1186
  if (state.itemIndex < state.scroll) {
@@ -773,16 +1195,14 @@ function drawMain(state, width, height) {
773
1195
  return;
774
1196
  }
775
1197
  const index = state.scroll + offset;
776
- const active = index === state.itemIndex && state.focus === "content";
1198
+ const active = index === state.itemIndex && (state.focus === "content" || state.mode === "settings");
777
1199
  const prefix = active ? `${ok}●${ansi.reset}` : `${muted}•${ansi.reset}`;
778
1200
  const title = fit(` ${itemValue.title}`, Math.max(10, width - 2));
779
1201
  rows.push(active ? `${bg(5, 46, 74)}${prefix}${title}${ansi.reset}` : fit(`${prefix}${title}`, width));
780
1202
  if (itemValue.meta && rows.length < height) {
781
1203
  rows.push(fit(` ${muted}${itemValue.meta}${ansi.reset}`, width));
782
1204
  }
783
- if (itemValue.detail && rows.length < height) {
784
- rows.push(fit(` ${itemValue.detail}`, width));
785
- }
1205
+ // Note: detail is shown in right panel, not here
786
1206
  });
787
1207
  if (visible.length === 0) {
788
1208
  rows.push(`${muted} 暂无数据${ansi.reset}`);
@@ -840,38 +1260,272 @@ function drawTopic(state, width, height) {
840
1260
  rows.push(fit(`${muted}${pageInfo}${state.loadingMore ? " · 加载中" : ""}${ansi.reset}`, width));
841
1261
  return rows.concat(blank(height - rows.length, width)).slice(0, height);
842
1262
  }
1263
+ function drawStatusBar(state, width) {
1264
+ const left = getStatus(state);
1265
+ const right = getKeyHints(state);
1266
+ const padding = Math.max(1, width - cellWidth(left) - cellWidth(right) - 2);
1267
+ return fit(`${muted} ${left}${" ".repeat(padding)}${right} `, width);
1268
+ }
1269
+ function getKeyHints(state) {
1270
+ const hints = [];
1271
+ hints.push("j/k ↑↓ 移动");
1272
+ hints.push("h← 返回");
1273
+ hints.push("l→ 进入");
1274
+ hints.push("Enter 确认");
1275
+ if (state.mode === "topic") {
1276
+ hints.push("n 下页");
1277
+ hints.push("【/】楼层");
1278
+ hints.push("数字跳楼");
1279
+ }
1280
+ else if (state.currentChat) {
1281
+ hints.push("n 更多");
1282
+ }
1283
+ hints.push("r 刷新");
1284
+ hints.push("o 操作");
1285
+ hints.push("? 帮助");
1286
+ hints.push("q 退出");
1287
+ return hints.join(" ");
1288
+ }
1289
+ function drawHelpModal(baseLines, width, height) {
1290
+ const modalWidth = Math.min(50, width - 4);
1291
+ const modalHeight = Math.min(22, height - 4);
1292
+ const startRow = Math.floor((height - modalHeight) / 2);
1293
+ const startCol = Math.floor((width - modalWidth) / 2);
1294
+ const helpContent = [
1295
+ "",
1296
+ `${cc98Blue}${ansi.bold} 快捷键帮助${ansi.reset}`,
1297
+ "",
1298
+ " 导航",
1299
+ " j/k, ↑/↓ 上下移动",
1300
+ " l, → 进入下一层",
1301
+ " h, ← 返回上一层",
1302
+ " Enter 确认/执行",
1303
+ "",
1304
+ " 操作",
1305
+ " r 刷新当前视图",
1306
+ " n, Space 加载更多",
1307
+ " o 打开操作菜单",
1308
+ " ? 显示/关闭帮助",
1309
+ " q 退出程序",
1310
+ "",
1311
+ " 按任意键关闭"
1312
+ ];
1313
+ const result = [...baseLines];
1314
+ for (let i = 0; i < modalHeight && i < helpContent.length; i++) {
1315
+ const row = startRow + i;
1316
+ if (row >= 0 && row < result.length) {
1317
+ const line = helpContent[i] ?? "";
1318
+ const padded = fit(line, modalWidth);
1319
+ const bgStr = i === 0 || i === modalHeight - 1 ? `${line}${"─".repeat(modalWidth)}${ansi.reset}` : `${bg(5, 46, 74)}${padded}${ansi.reset}`;
1320
+ const before = result[row].slice(0, startCol);
1321
+ const after = " ".repeat(Math.max(0, width - startCol - modalWidth));
1322
+ result[row] = `${before}${bgStr}${after}`;
1323
+ }
1324
+ }
1325
+ return result.slice(0, height).join("\n");
1326
+ }
1327
+ function drawMenuModal(baseLines, state, width, height) {
1328
+ const modalWidth = Math.min(30, width - 4);
1329
+ const modalHeight = state.menuItems.length + 4;
1330
+ const startRow = Math.floor((height - modalHeight) / 2);
1331
+ const startCol = Math.floor((width - modalWidth) / 2);
1332
+ const result = [...baseLines];
1333
+ // Title
1334
+ const titleRow = startRow;
1335
+ if (titleRow >= 0 && titleRow < result.length) {
1336
+ const title = fit(`${cc98Blue}${ansi.bold} 操作菜单${ansi.reset}`, modalWidth);
1337
+ result[titleRow] = replaceAt(result[titleRow], startCol, `${bg(5, 46, 74)}${title}${ansi.reset}`);
1338
+ }
1339
+ // Separator
1340
+ const sepRow = startRow + 1;
1341
+ if (sepRow >= 0 && sepRow < result.length) {
1342
+ result[sepRow] = replaceAt(result[sepRow], startCol, `${line}${"─".repeat(modalWidth)}${ansi.reset}`);
1343
+ }
1344
+ // Menu items
1345
+ state.menuItems.forEach((item, i) => {
1346
+ const row = startRow + 2 + i;
1347
+ if (row >= 0 && row < result.length) {
1348
+ const active = i === state.menuIndex;
1349
+ const label = ` ${item.label}`;
1350
+ const key = `[${item.key}]`;
1351
+ const padding = Math.max(0, modalWidth - label.length - key.length - 1);
1352
+ const content = `${label}${" ".repeat(padding)}${key}`;
1353
+ const styled = active
1354
+ ? `${bg(0, 130, 202)}${white}${fit(content, modalWidth)}${ansi.reset}`
1355
+ : `${bg(5, 46, 74)}${fit(content, modalWidth)}${ansi.reset}`;
1356
+ result[row] = replaceAt(result[row], startCol, styled);
1357
+ }
1358
+ });
1359
+ return result.slice(0, height).join("\n");
1360
+ }
1361
+ function replaceAt(str, index, replacement) {
1362
+ const before = str.slice(0, index);
1363
+ const afterWidth = Math.max(0, cellWidth(str) - index - cellWidth(replacement));
1364
+ const after = " ".repeat(afterWidth);
1365
+ return `${before}${replacement}${after}`;
1366
+ }
843
1367
  function drawRight(state, width, height) {
1368
+ if (state.mode === "topic" && state.topic) {
1369
+ return drawTopicRight(state.topic, state.scroll, width, height);
1370
+ }
1371
+ if (state.focus === "nav") {
1372
+ return drawNavRight(state, width, height);
1373
+ }
1374
+ return drawItemRight(state, width, height);
1375
+ }
1376
+ function drawNavRight(state, width, height) {
844
1377
  const rows = [];
845
- rows.push(`${cc98Blue}${ansi.bold} CC98${ansi.reset}`);
846
- const art = width < 40 ? mascotCompact : mascot;
847
- rows.push(...art.map((row) => fit(`${white}${row}${ansi.reset}`, width)));
1378
+ const nav = navItems[state.navIndex];
1379
+ rows.push(...mascotMini.map((row) => fit(`${white}${row}${ansi.reset}`, width)));
848
1380
  rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
849
- const stats = state.mode === "topic" && state.topic
850
- ? [
851
- { title: "楼层", detail: String(state.topic.loaded) },
852
- { title: "图片", detail: String(state.topic.imageCount) },
853
- { title: "链接", detail: String(state.topic.linkCount) },
854
- { title: "缓存", detail: "meta 60s / posts 60s-10m" }
855
- ]
856
- : state.stats;
857
- stats.slice(0, Math.max(0, height - rows.length)).forEach((stat) => {
858
- rows.push(fit(`${muted}${stat.title}${ansi.reset}`, width));
859
- if (stat.detail) {
860
- rows.push(fit(`${cc98BlueSoft}${stat.detail}${ansi.reset}`, width));
1381
+ rows.push(fit(`${cc98Blue}${ansi.bold} ${nav.label}${ansi.reset}`, width));
1382
+ rows.push(fit(`${muted} ${nav.hint}${ansi.reset}`, width));
1383
+ rows.push(`${line}${"".repeat(width)}${ansi.reset}`);
1384
+ if (state.loading) {
1385
+ rows.push(fit(`${muted} 正在读取栏目...${ansi.reset}`, width));
1386
+ }
1387
+ else if (state.error) {
1388
+ rows.push(fit(`${danger} 栏目读取失败${ansi.reset}`, width));
1389
+ rows.push(fit(` ${state.error}`, width));
1390
+ }
1391
+ else {
1392
+ rows.push(fit(`${muted} 当前内容${ansi.reset}`, width));
1393
+ rows.push(fit(`${cc98BlueSoft} ${state.items.length} 项${ansi.reset}`, width));
1394
+ if (state.stats.length > 0) {
1395
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1396
+ state.stats.slice(0, 5).forEach((stat) => {
1397
+ rows.push(fit(`${muted} ${stat.title}${ansi.reset}`, width));
1398
+ rows.push(fit(`${cc98BlueSoft} ${stat.detail ?? "-"}${ansi.reset}`, width));
1399
+ });
861
1400
  }
862
- });
863
- const selected = state.mode === "list" ? state.items[state.itemIndex] : undefined;
864
- if (selected && height - rows.length >= 4) {
865
- while (height - rows.length > 4) {
866
- rows.push(" ".repeat(width));
1401
+ }
1402
+ rows.push(`${line}${"".repeat(width)}${ansi.reset}`);
1403
+ rows.push(fit(`${muted} j/k 切换栏目${ansi.reset}`, width));
1404
+ rows.push(fit(`${muted} l/Enter 进入内容${ansi.reset}`, width));
1405
+ rows.push(fit(`${muted} r 刷新当前栏目${ansi.reset}`, width));
1406
+ return rows.concat(blank(height - rows.length, width)).slice(0, height);
1407
+ }
1408
+ function drawItemRight(state, width, height) {
1409
+ const rows = [];
1410
+ const selected = state.items[state.itemIndex];
1411
+ if (!selected) {
1412
+ rows.push(fit(`${muted} 暂无选中项${ansi.reset}`, width));
1413
+ return rows.concat(blank(height - rows.length, width)).slice(0, height);
1414
+ }
1415
+ rows.push(fit(`${cc98Blue}${ansi.bold} ${selected.title}${ansi.reset}`, width));
1416
+ if (selected.meta) {
1417
+ wrapText(selected.meta, width - 2).slice(0, 3).forEach((row) => {
1418
+ rows.push(fit(`${muted} ${row}${ansi.reset}`, width));
1419
+ });
1420
+ }
1421
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1422
+ if (selected.detail) {
1423
+ wrapText(selected.detail, width - 2).slice(0, Math.max(0, height - rows.length - 8)).forEach((row) => {
1424
+ rows.push(fit(` ${row}`, width));
1425
+ });
1426
+ }
1427
+ else {
1428
+ rows.push(fit(`${muted} 没有摘要内容${ansi.reset}`, width));
1429
+ }
1430
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1431
+ if (selected.topicId !== undefined) {
1432
+ rows.push(fit(`${muted} 主题 #${selected.topicId}${ansi.reset}`, width));
1433
+ if (selected.boardId !== undefined) {
1434
+ rows.push(fit(`${muted} 版面 #${selected.boardId}${ansi.reset}`, width));
1435
+ }
1436
+ rows.push(fit(`${cc98BlueSoft} l 打开阅读${ansi.reset}`, width));
1437
+ }
1438
+ else if (selected.boardId !== undefined) {
1439
+ rows.push(fit(`${muted} 版面 #${selected.boardId}${ansi.reset}`, width));
1440
+ rows.push(fit(`${cc98BlueSoft} l 读取主题${ansi.reset}`, width));
1441
+ }
1442
+ else if (selected.chatUserId !== undefined) {
1443
+ rows.push(fit(`${muted} 用户 #${selected.chatUserId}${ansi.reset}`, width));
1444
+ rows.push(fit(`${cc98BlueSoft} l 打开会话${ansi.reset}`, width));
1445
+ }
1446
+ else if (state.mode === "settings") {
1447
+ rows.push(fit(`${cc98BlueSoft} l/Enter 执行${ansi.reset}`, width));
1448
+ }
1449
+ return rows.concat(blank(height - rows.length, width)).slice(0, height);
1450
+ }
1451
+ function drawTopicRight(topic, scroll, width, height) {
1452
+ const rows = [];
1453
+ const post = currentTopicPost(topic, scroll);
1454
+ const lineEntry = currentTopicLine(topic, scroll);
1455
+ rows.push(fit(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`, width));
1456
+ if (topic.meta) {
1457
+ wrapText(topic.meta, width - 2).slice(0, 2).forEach((row) => {
1458
+ rows.push(fit(`${muted} ${row}${ansi.reset}`, width));
1459
+ });
1460
+ }
1461
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1462
+ if (post) {
1463
+ const floor = post.floor !== undefined ? `${post.floor} 楼` : "未知楼层";
1464
+ rows.push(fit(`${cc98BlueSoft} ${floor}${ansi.reset}`, width));
1465
+ rows.push(fit(`${muted} ${post.author}${post.time ? ` · ${post.time}` : ""}${ansi.reset}`, width));
1466
+ rows.push(fit(`${muted} 赞 ${post.likeCount} 踩 ${post.dislikeCount}${post.rating ? ` 评分 ${post.rating}` : ""}${ansi.reset}`, width));
1467
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1468
+ if (lineEntry) {
1469
+ rows.push(fit(`${muted} 当前行 ${lineEntry.row + 1}/${post.lines.length}${ansi.reset}`, width));
1470
+ rows.push(fit(`${cc98BlueSoft} ${lineKindLabel(lineEntry.kind)}${ansi.reset}`, width));
1471
+ if (lineEntry.imageUrl) {
1472
+ rows.push(fit(`${muted} 图片 ${lineEntry.imageIndex}${ansi.reset}`, width));
1473
+ wrapText(lineEntry.imageUrl, width - 2).slice(0, 2).forEach((row) => rows.push(fit(` ${row}`, width)));
1474
+ }
1475
+ else if (lineEntry.linkUrl) {
1476
+ rows.push(fit(`${muted} 链接 ${lineEntry.linkIndex}${ansi.reset}`, width));
1477
+ wrapText(lineEntry.linkUrl, width - 2).slice(0, 2).forEach((row) => rows.push(fit(` ${row}`, width)));
1478
+ }
1479
+ else if (lineEntry.text.trim()) {
1480
+ wrapText(lineEntry.text, width - 2).slice(0, 3).forEach((row) => rows.push(fit(` ${row}`, width)));
1481
+ }
867
1482
  }
868
1483
  rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
869
- rows.push(fit(`${muted}选中${ansi.reset}`, width));
870
- rows.push(fit(`${cc98BlueSoft}${selected.title}${ansi.reset}`, width));
871
- rows.push(fit(`${muted}${selected.meta ? `归属 ${selected.meta}` : selected.boardId ? `版面 #${selected.boardId}` : ""}${ansi.reset}`, width));
1484
+ rows.push(fit(`${muted} 本楼 图片 ${post.imageCount} 链接 ${post.linkCount}${ansi.reset}`, width));
1485
+ }
1486
+ const hot = topic.posts
1487
+ .filter((entry) => entry.likeCount > 0)
1488
+ .sort((left, right) => right.likeCount - left.likeCount)
1489
+ .slice(0, 3);
1490
+ if (hot.length > 0 && rows.length < height - 5) {
1491
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1492
+ rows.push(fit(`${cc98Blue}${ansi.bold} 热门回复${ansi.reset}`, width));
1493
+ hot.forEach((entry) => {
1494
+ rows.push(fit(`${muted} #${entry.floor ?? "?"} ${entry.author} · ${entry.likeCount} 赞${ansi.reset}`, width));
1495
+ if (entry.preview) {
1496
+ rows.push(fit(` ${truncate(entry.preview, width - 2)}`, width));
1497
+ }
1498
+ });
1499
+ }
1500
+ rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1501
+ rows.push(fit(`${muted} j/k 行滚动 【/】楼层切换${ansi.reset}`, width));
1502
+ rows.push(fit(`${muted} 数字+Enter 跳楼 n 下一页${ansi.reset}`, width));
1503
+ if (topic.floorInput) {
1504
+ rows.push(fit(`${ok} 跳转:${topic.floorInput} 楼${ansi.reset}`, width));
872
1505
  }
873
1506
  return rows.concat(blank(height - rows.length, width)).slice(0, height);
874
1507
  }
1508
+ function wrapText(text, maxWidth) {
1509
+ const lines = [];
1510
+ let current = "";
1511
+ let currentWidth = 0;
1512
+ for (const char of text) {
1513
+ const charW = charCellWidth(char);
1514
+ if (currentWidth + charW > maxWidth) {
1515
+ lines.push(current);
1516
+ current = char;
1517
+ currentWidth = charW;
1518
+ }
1519
+ else {
1520
+ current += char;
1521
+ currentWidth += charW;
1522
+ }
1523
+ }
1524
+ if (current) {
1525
+ lines.push(current);
1526
+ }
1527
+ return lines;
1528
+ }
875
1529
  function item(title, value, meta) {
876
1530
  return {
877
1531
  title,
@@ -973,19 +1627,34 @@ async function mapLimit(values, limit, mapper) {
973
1627
  await Promise.all(workers);
974
1628
  return results;
975
1629
  }
976
- function listStatus(state) {
977
- if (state.currentBoard) {
978
- return "版面帖子:j/k 选择 l/Enter 打开帖子 Esc/Backspace 返回版面列表 h 返回左栏";
1630
+ function getStatus(state) {
1631
+ // Left part: context status
1632
+ let left = "";
1633
+ if (state.loading) {
1634
+ left = "加载中...";
979
1635
  }
980
- if (state.currentChat) {
981
- return state.currentChat.hasMore
982
- ? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
983
- : "私信:j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
1636
+ else if (state.loadingMore) {
1637
+ left = "加载更多...";
984
1638
  }
985
- if (state.focus === "nav") {
986
- return "左栏:j/k 选栏目 l/Enter 进入内容 r 刷新 q 退出";
1639
+ else if (state.error) {
1640
+ left = "出错了";
1641
+ }
1642
+ else if (state.mode === "topic") {
1643
+ if (state.topic) {
1644
+ const post = currentTopicPost(state.topic, state.scroll);
1645
+ const line = currentTopicLine(state.topic, state.scroll);
1646
+ left = post
1647
+ ? `${post.floor ?? "?"} 楼 · 第 ${line ? line.row + 1 : 1} 行`
1648
+ : `${state.topic.loaded} 楼已加载`;
1649
+ }
1650
+ }
1651
+ else if (state.mode === "settings") {
1652
+ left = "设置";
1653
+ }
1654
+ else {
1655
+ left = `${state.items.length} 项`;
987
1656
  }
988
- return "内容:j/k 选择 l/Enter 打开帖子/版面/私信 h 返回左栏 r 刷新 q 退出";
1657
+ return left;
989
1658
  }
990
1659
  function flattenBoards(sections) {
991
1660
  const boards = [];