cc98-cli 0.4.2 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -1
  3. package/dist/api/client.d.ts +1 -0
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +3 -0
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/api/endpoints.d.ts +1 -0
  8. package/dist/api/endpoints.d.ts.map +1 -1
  9. package/dist/api/endpoints.js +1 -0
  10. package/dist/api/endpoints.js.map +1 -1
  11. package/dist/cli/commands/topic.d.ts.map +1 -1
  12. package/dist/cli/commands/topic.js +7 -0
  13. package/dist/cli/commands/topic.js.map +1 -1
  14. package/dist/cli/router.js +3 -0
  15. package/dist/cli/router.js.map +1 -1
  16. package/dist/storage/settings-store.d.ts +2 -0
  17. package/dist/storage/settings-store.d.ts.map +1 -1
  18. package/dist/storage/settings-store.js +9 -0
  19. package/dist/storage/settings-store.js.map +1 -1
  20. package/dist/tui/ansi.d.ts.map +1 -1
  21. package/dist/tui/ansi.js +5 -1
  22. package/dist/tui/ansi.js.map +1 -1
  23. package/dist/tui/app.js +1 -1
  24. package/dist/tui/app.js.map +1 -1
  25. package/dist/tui/cached-client.d.ts +1 -0
  26. package/dist/tui/cached-client.d.ts.map +1 -1
  27. package/dist/tui/cached-client.js +3 -0
  28. package/dist/tui/cached-client.js.map +1 -1
  29. package/dist/tui/components/content.d.ts +1 -0
  30. package/dist/tui/components/content.d.ts.map +1 -1
  31. package/dist/tui/components/content.js +26 -7
  32. package/dist/tui/components/content.js.map +1 -1
  33. package/dist/tui/components/sidebar.d.ts.map +1 -1
  34. package/dist/tui/components/sidebar.js +1 -11
  35. package/dist/tui/components/sidebar.js.map +1 -1
  36. package/dist/tui/components/status.d.ts.map +1 -1
  37. package/dist/tui/components/status.js +3 -0
  38. package/dist/tui/components/status.js.map +1 -1
  39. package/dist/tui/components/utils.d.ts.map +1 -1
  40. package/dist/tui/components/utils.js +33 -12
  41. package/dist/tui/components/utils.js.map +1 -1
  42. package/dist/tui/controller.d.ts +38 -1
  43. package/dist/tui/controller.d.ts.map +1 -1
  44. package/dist/tui/controller.js +731 -71
  45. package/dist/tui/controller.js.map +1 -1
  46. package/dist/tui/emoji-art.d.ts +11 -0
  47. package/dist/tui/emoji-art.d.ts.map +1 -0
  48. package/dist/tui/emoji-art.js +371 -0
  49. package/dist/tui/emoji-art.js.map +1 -0
  50. package/dist/tui/emoji-renderer.d.ts +16 -0
  51. package/dist/tui/emoji-renderer.d.ts.map +1 -0
  52. package/dist/tui/emoji-renderer.js +110 -0
  53. package/dist/tui/emoji-renderer.js.map +1 -0
  54. package/dist/tui/image-renderer.d.ts +46 -0
  55. package/dist/tui/image-renderer.d.ts.map +1 -0
  56. package/dist/tui/image-renderer.js +259 -0
  57. package/dist/tui/image-renderer.js.map +1 -0
  58. package/dist/tui/keybindings.js +1 -1
  59. package/dist/tui/keybindings.js.map +1 -1
  60. package/dist/tui/navigation.d.ts.map +1 -1
  61. package/dist/tui/navigation.js +5 -3
  62. package/dist/tui/navigation.js.map +1 -1
  63. package/dist/tui/renderer.d.ts.map +1 -1
  64. package/dist/tui/renderer.js +132 -5
  65. package/dist/tui/renderer.js.map +1 -1
  66. package/dist/tui/state/store.d.ts.map +1 -1
  67. package/dist/tui/state/store.js +2 -1
  68. package/dist/tui/state/store.js.map +1 -1
  69. package/dist/tui/state/types.d.ts +35 -2
  70. package/dist/tui/state/types.d.ts.map +1 -1
  71. package/dist/tui/terminal-capabilities.d.ts +24 -0
  72. package/dist/tui/terminal-capabilities.d.ts.map +1 -0
  73. package/dist/tui/terminal-capabilities.js +55 -0
  74. package/dist/tui/terminal-capabilities.js.map +1 -0
  75. package/dist/tui/terminal.js +1 -1
  76. package/dist/tui/terminal.js.map +1 -1
  77. package/dist/tui/topic-reader.d.ts +2 -1
  78. package/dist/tui/topic-reader.d.ts.map +1 -1
  79. package/dist/tui/topic-reader.js +25 -9
  80. package/dist/tui/topic-reader.js.map +1 -1
  81. package/dist/tui/ubb-renderer.d.ts +5 -1
  82. package/dist/tui/ubb-renderer.d.ts.map +1 -1
  83. package/dist/tui/ubb-renderer.js +71 -25
  84. package/dist/tui/ubb-renderer.js.map +1 -1
  85. package/package.json +2 -1
@@ -1,12 +1,14 @@
1
1
  import { checkForUpdate } from "../update.js";
2
2
  import { appVersion } from "../version.js";
3
+ import { Cc98Client } from "../api/client.js";
3
4
  import { getImageCache } from "../storage/image-cache.js";
4
5
  import { SettingsStore } from "../storage/settings-store.js";
5
6
  import { getKeybindingManager } from "./keybindings.js";
7
+ import { EMOJI_CATEGORIES, getEmojiArt, renderCc98Logo, renderEmojiCode } from "./emoji-renderer.js";
6
8
  import { navItems, settingsItems } from "./navigation.js";
7
9
  import { getStatus } from "./state/store.js";
8
10
  import { asArray, asNumber, asObject, chatItem, chatMessageItems, flattenBoards, genericItem, historyItem, isAbortError, jsonPreviewLines, loadChatUserNames, mapLimit, noticeItem, overviewStats, topicItem, unreadStats, userItem } from "./helpers.js";
9
- import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, FLOORS_PER_PAGE } from "./topic-reader.js";
11
+ import { appendTopicPosts, buildTopicReader, currentTopicLine, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, replaceTopicPosts, FLOORS_PER_PAGE } from "./topic-reader.js";
10
12
  export class TuiController {
11
13
  state;
12
14
  client;
@@ -15,11 +17,14 @@ export class TuiController {
15
17
  close;
16
18
  nextSignal;
17
19
  abortCurrent;
20
+ webVpnOptions;
18
21
  loadVersion = 0;
19
22
  keybindings;
20
23
  settingsStore = new SettingsStore();
21
24
  updateChecked = false;
22
- constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent) {
25
+ autoSigninChecked = false;
26
+ listReturnState;
27
+ constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent, webVpnOptions) {
23
28
  this.state = state;
24
29
  this.client = client;
25
30
  this.tokenStore = tokenStore;
@@ -27,10 +32,12 @@ export class TuiController {
27
32
  this.close = close;
28
33
  this.nextSignal = nextSignal;
29
34
  this.abortCurrent = abortCurrent;
35
+ this.webVpnOptions = webVpnOptions;
30
36
  this.keybindings = getKeybindingManager();
31
37
  }
32
38
  async load(force = false) {
33
39
  const version = ++this.loadVersion;
40
+ let shouldAutoSignin = false;
34
41
  const signal = this.nextSignal();
35
42
  const nav = navItems[this.state.navIndex] ?? navItems[0];
36
43
  this.state.viewTitle = nav.label;
@@ -48,6 +55,7 @@ export class TuiController {
48
55
  this.state.parentList = undefined;
49
56
  this.state.currentBoard = undefined;
50
57
  this.state.currentChat = undefined;
58
+ this.state.listPaging = undefined;
51
59
  this.render();
52
60
  try {
53
61
  // 加载快捷键配置
@@ -58,12 +66,17 @@ export class TuiController {
58
66
  this.updateChecked = true;
59
67
  void this.checkUpdate();
60
68
  }
69
+ if (!this.autoSigninChecked) {
70
+ this.autoSigninChecked = true;
71
+ shouldAutoSignin = true;
72
+ }
61
73
  const next = await this.loadView(nav.id, force, signal);
62
74
  if (version !== this.loadVersion)
63
75
  return;
64
76
  this.state.viewTitle = next.title;
65
77
  this.state.items = next.items;
66
78
  this.state.stats = next.stats;
79
+ this.state.listPaging = next.paging;
67
80
  if (next.overview) {
68
81
  this.state.overview = next.overview;
69
82
  }
@@ -80,6 +93,12 @@ export class TuiController {
80
93
  if (version === this.loadVersion) {
81
94
  this.state.loading = false;
82
95
  this.render();
96
+ if (this.state.listPaging?.hasMore) {
97
+ void this.ensureListWindowFilled(this.nextSignal());
98
+ }
99
+ if (shouldAutoSignin) {
100
+ void this.runAutoSignin();
101
+ }
83
102
  }
84
103
  }
85
104
  }
@@ -176,9 +195,7 @@ export class TuiController {
176
195
  }
177
196
  handleSearchKey(key) {
178
197
  if (this.keybindings.matches(key, "searchClose")) {
179
- this.state.modal = null;
180
- this.state.searchQuery = "";
181
- this.render();
198
+ this.closeSearch();
182
199
  return;
183
200
  }
184
201
  if (this.keybindings.matches(key, "searchToggleMode")) {
@@ -202,7 +219,7 @@ export class TuiController {
202
219
  const selected = this.state.searchResults[this.state.itemIndex];
203
220
  if (selected) {
204
221
  // 有选中项:打开
205
- this.state.modal = null;
222
+ this.restoreSearchOriginForActivation();
206
223
  void this.activateContentItem(selected, this.nextSignal());
207
224
  }
208
225
  else if (this.state.searchQuery.trim()) {
@@ -389,7 +406,7 @@ export class TuiController {
389
406
  }
390
407
  // r:刷新
391
408
  if (this.keybindings.matches(key, "topicRefresh") && this.state.topic) {
392
- void this.openTopic(this.state.topic.topicId, true, this.nextSignal());
409
+ void this.refreshCurrentTopic(this.nextSignal());
393
410
  return;
394
411
  }
395
412
  // s:收藏
@@ -420,11 +437,11 @@ export class TuiController {
420
437
  this.openMenu();
421
438
  }
422
439
  }
423
- // c:复制链接
440
+ // c:图片复制图片本体,链接复制 URL
424
441
  if (this.keybindings.matches(key, "topicCopyLink")) {
425
442
  const currentLine = this.getCurrentTopicLine();
426
443
  if (currentLine?.kind === "image" && currentLine.imageUrl) {
427
- void this.copyToClipboard(currentLine.imageUrl);
444
+ void this.copyImageToClipboard(currentLine.imageUrl);
428
445
  }
429
446
  else if (currentLine?.kind === "link" && currentLine.linkUrl) {
430
447
  void this.copyToClipboard(currentLine.linkUrl);
@@ -432,8 +449,9 @@ export class TuiController {
432
449
  }
433
450
  }
434
451
  handleSettingsKey(key) {
452
+ const itemCount = this.state.items.length || settingsItems.length;
435
453
  if (this.keybindings.matches(key, "moveDown")) {
436
- this.state.itemIndex = Math.min(settingsItems.length - 1, this.state.itemIndex + 1);
454
+ this.state.itemIndex = Math.min(itemCount - 1, this.state.itemIndex + 1);
437
455
  this.render();
438
456
  return;
439
457
  }
@@ -450,7 +468,7 @@ export class TuiController {
450
468
  return;
451
469
  }
452
470
  if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
453
- void this.activateSetting(settingsItems[this.state.itemIndex]);
471
+ void this.activateSetting(this.state.items[this.state.itemIndex] ?? settingsItems[this.state.itemIndex]);
454
472
  }
455
473
  }
456
474
  handleNavKey(key) {
@@ -479,9 +497,20 @@ export class TuiController {
479
497
  void this.load(true);
480
498
  }
481
499
  handleContentKey(key) {
500
+ if ((key === "\t" || key === "\x1b[Z") && this.state.tabs.length > 1) {
501
+ this.switchTab(key === "\x1b[Z" ? -1 : 1);
502
+ return;
503
+ }
504
+ if (/^[1-9]$/.test(key) && this.state.tabs.length > 1) {
505
+ this.switchTabToIndex(Number(key) - 1);
506
+ return;
507
+ }
482
508
  if (this.keybindings.matches(key, "listNext")) {
509
+ const previousIndex = this.state.itemIndex;
510
+ const shouldAdvanceAfterLoad = previousIndex >= this.state.items.length - 1;
483
511
  this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), this.state.itemIndex + 1);
484
512
  this.render();
513
+ void this.checkListAutoLoad(shouldAdvanceAfterLoad ? previousIndex + 1 : undefined);
485
514
  return;
486
515
  }
487
516
  if (this.keybindings.matches(key, "listPrev")) {
@@ -511,13 +540,22 @@ export class TuiController {
511
540
  if (this.keybindings.matches(key, "menu"))
512
541
  this.openMenu();
513
542
  }
543
+ switchTab(delta) {
544
+ const current = Math.max(0, this.state.tabs.findIndex((tab) => tab.id === this.state.tabId));
545
+ const next = (current + delta + this.state.tabs.length) % this.state.tabs.length;
546
+ this.switchTabToIndex(next);
547
+ }
548
+ switchTabToIndex(index) {
549
+ const tab = this.state.tabs[index];
550
+ if (!tab || tab.id === this.state.tabId)
551
+ return;
552
+ this.state.tabId = tab.id;
553
+ void this.load(true);
554
+ }
514
555
  leave() {
515
556
  this.abortCurrent();
516
557
  if (this.state.mode === "topic") {
517
- this.state.mode = "list";
518
- this.state.focus = "content";
519
- this.state.status = "";
520
- this.render();
558
+ void this.leaveTopic();
521
559
  return;
522
560
  }
523
561
  if (this.state.parentList) {
@@ -529,8 +567,139 @@ export class TuiController {
529
567
  this.state.status = "";
530
568
  this.render();
531
569
  }
570
+ rememberListReturnState() {
571
+ if (this.state.mode !== "list")
572
+ return;
573
+ this.listReturnState = {
574
+ itemIndex: this.state.itemIndex,
575
+ scroll: this.state.scroll,
576
+ paging: this.cloneListPaging(this.state.listPaging)
577
+ };
578
+ }
579
+ async leaveTopic() {
580
+ const listReturn = this.listReturnState;
581
+ this.state.mode = "list";
582
+ this.state.focus = "content";
583
+ this.state.status = "";
584
+ this.state.topic = undefined;
585
+ if (listReturn) {
586
+ this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), listReturn.itemIndex);
587
+ this.state.listPaging = this.cloneListPaging(listReturn.paging);
588
+ this.state.scroll = listReturn.paging?.anchorOnReturn ? this.state.itemIndex : listReturn.scroll;
589
+ this.listReturnState = undefined;
590
+ }
591
+ this.render();
592
+ if (this.state.listPaging?.anchorOnReturn) {
593
+ await this.ensureListWindowFilled(this.nextSignal());
594
+ }
595
+ }
596
+ async ensureListWindowFilled(signal) {
597
+ const paging = this.state.listPaging;
598
+ if (!paging?.hasMore || this.state.loadingMore)
599
+ return;
600
+ const capacity = Math.max(1, this.state.listViewportCapacity || 10);
601
+ const targetLength = this.state.scroll + capacity;
602
+ while (this.state.items.length < targetLength && paging.hasMore && !this.state.loadingMore) {
603
+ const previousLength = this.state.items.length;
604
+ await this.loadNextListPage(signal);
605
+ if (this.state.items.length === previousLength)
606
+ break;
607
+ }
608
+ }
609
+ async checkListAutoLoad(advanceToIndex) {
610
+ const paging = this.state.listPaging;
611
+ if (!paging?.hasMore || this.state.loadingMore)
612
+ return;
613
+ const capacity = Math.max(1, this.state.listViewportCapacity || 10);
614
+ const visibleEnd = this.state.scroll + capacity;
615
+ if (this.state.items.length <= visibleEnd || this.state.itemIndex >= this.state.items.length - 2) {
616
+ await this.loadNextListPage(this.nextSignal());
617
+ if (advanceToIndex !== undefined && advanceToIndex < this.state.items.length) {
618
+ this.state.itemIndex = advanceToIndex;
619
+ this.state.status = "";
620
+ this.render();
621
+ }
622
+ }
623
+ }
624
+ async loadNextListPage(signal) {
625
+ const paging = this.state.listPaging;
626
+ if (!paging?.hasMore || this.state.loadingMore)
627
+ return;
628
+ this.state.loadingMore = true;
629
+ this.render();
630
+ try {
631
+ const nextItems = await this.fetchNextListPage(paging, signal);
632
+ this.state.items.push(...nextItems);
633
+ paging.loaded += nextItems.length;
634
+ if (paging.kind !== "favorite-board-topics") {
635
+ paging.hasMore = nextItems.length >= paging.size;
636
+ }
637
+ this.state.stats = this.state.stats.map((item) => {
638
+ if (item.title === "版面主题" && paging.kind === "favorite-board-topics") {
639
+ return { ...item, detail: `${paging.loaded} 条` };
640
+ }
641
+ if (["主题", "新帖流", "最新新帖", "关注用户", "收藏更新"].includes(item.title)) {
642
+ return { ...item, detail: `${this.state.items.length} 条` };
643
+ }
644
+ return item;
645
+ });
646
+ this.state.status = paging.hasMore ? "已加载更多" : "已到底";
647
+ }
648
+ catch (error) {
649
+ if (!isAbortError(error))
650
+ this.state.status = error instanceof Error ? error.message : "加载失败";
651
+ }
652
+ finally {
653
+ this.state.loadingMore = false;
654
+ this.render();
655
+ }
656
+ }
657
+ async fetchNextListPage(paging, signal) {
658
+ if (paging.kind === "new-topics") {
659
+ const topics = asArray(await this.client.getNewTopics(paging.loaded, paging.size, false, signal));
660
+ return topics.map((topic) => topicItem(topic));
661
+ }
662
+ if (paging.kind === "followee-topics") {
663
+ const topics = asArray(await this.client.getFolloweeTopics(paging.loaded, paging.size, false, signal));
664
+ return topics.map((topic) => topicItem(topic));
665
+ }
666
+ if (paging.kind === "favorite-board-topics") {
667
+ return this.fetchNextFavoriteBoardTopics(paging, signal);
668
+ }
669
+ const order = paging.kind === "favorite-updates" ? 1 : 0;
670
+ const topics = asArray(await this.client.getFavoriteTopics(paging.loaded, paging.size, order, 0, false, signal));
671
+ return topics.map((topic) => topicItem(topic));
672
+ }
673
+ async fetchNextFavoriteBoardTopics(paging, signal) {
674
+ const take = Math.max(1, Math.min(20, paging.size));
675
+ if ((paging.buffer?.length ?? 0) >= take) {
676
+ const items = paging.buffer?.splice(0, take) ?? [];
677
+ paging.hasMore = (paging.buffer?.length ?? 0) > 0 || (paging.boardCursors?.some((cursor) => cursor.hasMore) ?? false);
678
+ return items;
679
+ }
680
+ const cursors = paging.boardCursors?.filter((cursor) => cursor.hasMore) ?? [];
681
+ if (cursors.length === 0) {
682
+ const items = paging.buffer?.splice(0, take) ?? [];
683
+ paging.hasMore = (paging.buffer?.length ?? 0) > 0;
684
+ return items;
685
+ }
686
+ const batches = await mapLimit(cursors, 3, async (cursor) => {
687
+ const topics = asArray(await this.client.getBoardTopics(cursor.boardId, cursor.loaded, cursor.size, false, false, signal));
688
+ cursor.loaded += topics.length;
689
+ cursor.hasMore = topics.length >= cursor.size;
690
+ return topics.map((topic) => topicItem(topic, { title: cursor.title, boardId: cursor.boardId }));
691
+ });
692
+ const merged = [...(paging.buffer ?? []), ...batches.flat()]
693
+ .sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0));
694
+ paging.buffer = merged;
695
+ paging.hasMore = paging.buffer.length > 0 || cursors.some((cursor) => cursor.hasMore);
696
+ return paging.buffer.splice(0, take);
697
+ }
532
698
  refresh() {
533
- if (this.state.currentBoard) {
699
+ if (this.state.mode === "topic" && this.state.topic) {
700
+ void this.refreshCurrentTopic(this.nextSignal());
701
+ }
702
+ else if (this.state.currentBoard) {
534
703
  void this.openBoard(this.state.currentBoard.boardId, this.state.currentBoard.title, true, this.nextSignal(), false);
535
704
  }
536
705
  else if (this.state.currentChat) {
@@ -542,6 +711,7 @@ export class TuiController {
542
711
  }
543
712
  openSearch() {
544
713
  this.state.modal = "search";
714
+ this.state.searchOrigin = { itemIndex: this.state.itemIndex, scroll: this.state.scroll };
545
715
  this.state.searchQuery = "";
546
716
  this.state.searchResults = [];
547
717
  this.state.searchMode = "topics";
@@ -549,6 +719,29 @@ export class TuiController {
549
719
  this.state.itemIndex = 0;
550
720
  this.render();
551
721
  }
722
+ closeSearch() {
723
+ const origin = this.state.searchOrigin;
724
+ this.state.modal = null;
725
+ this.state.searchOrigin = undefined;
726
+ this.state.searchQuery = "";
727
+ this.state.searchResults = [];
728
+ if (origin) {
729
+ this.state.itemIndex = origin.itemIndex;
730
+ this.state.scroll = origin.scroll;
731
+ }
732
+ this.render();
733
+ }
734
+ restoreSearchOriginForActivation() {
735
+ const origin = this.state.searchOrigin;
736
+ this.state.modal = null;
737
+ this.state.searchOrigin = undefined;
738
+ this.state.searchQuery = "";
739
+ this.state.searchResults = [];
740
+ if (origin) {
741
+ this.state.itemIndex = origin.itemIndex;
742
+ this.state.scroll = origin.scroll;
743
+ }
744
+ }
552
745
  getSearchScope() {
553
746
  // 根据当前位置确定搜索范围
554
747
  if (this.state.currentBoard) {
@@ -584,9 +777,19 @@ export class TuiController {
584
777
  this.state.userDetail = undefined;
585
778
  this.render();
586
779
  }
780
+ setTabs(tabs, defaultId) {
781
+ this.state.tabs = tabs;
782
+ if (!tabs.some((tab) => tab.id === this.state.tabId)) {
783
+ this.state.tabId = defaultId;
784
+ }
785
+ }
587
786
  async loadView(view, force, signal) {
787
+ if (view !== "new" && view !== "following") {
788
+ this.setTabs([{ id: "default", label: "" }], "default");
789
+ }
588
790
  switch (view) {
589
791
  case "hot": {
792
+ this.setTabs([{ id: "default", label: "" }], "default");
590
793
  const [index, unread] = await Promise.all([
591
794
  this.client.getForumIndex(force, signal),
592
795
  this.client.getUnreadCount(force, signal)
@@ -598,14 +801,46 @@ export class TuiController {
598
801
  title: "十大",
599
802
  items: hotTopics.map((topic) => topicItem(topic)),
600
803
  stats: unreadStats(unreadObject),
601
- overview: overviewStats(indexObject, unreadObject)
804
+ overview: overviewStats(indexObject, unreadObject),
805
+ status: "十大:j/k 选择 Enter 打开 r 刷新"
602
806
  };
603
807
  }
604
808
  case "new": {
605
- const topics = asArray(await this.client.getNewTopics(0, 12, force, signal));
606
- return { title: "最新", items: topics.map((topic) => topicItem(topic)), stats: [{ title: "新帖流", detail: `${topics.length} 条` }] };
809
+ this.setTabs([
810
+ { id: "new-latest", label: "最新" },
811
+ { id: "new-random", label: "随机" },
812
+ { id: "new-recommendation", label: "推荐" }
813
+ ], "new-latest");
814
+ if (this.state.tabId === "new-recommendation") {
815
+ const topics = asArray(await this.client.getRandomRecommendations(10, true, signal));
816
+ return {
817
+ title: "新帖 · 推荐",
818
+ items: topics.map((topic) => topicItem(topic)),
819
+ stats: [{ title: "推荐", detail: `${topics.length} 条` }],
820
+ status: "新帖:Tab 切换 j/k 选择 Enter 打开 r 换一批"
821
+ };
822
+ }
823
+ if (this.state.tabId === "new-random") {
824
+ const topics = asArray(await this.client.getRandomTopics(20, true, signal));
825
+ return {
826
+ title: "新帖 · 随机",
827
+ items: topics.map((topic) => topicItem(topic)),
828
+ stats: [{ title: "随机新帖", detail: `${topics.length} 条` }],
829
+ status: "新帖:Tab 切换 j/k 选择 Enter 打开 r 换一批"
830
+ };
831
+ }
832
+ const size = 20;
833
+ const topics = asArray(await this.client.getNewTopics(0, size, force, signal));
834
+ return {
835
+ title: "新帖 · 最新",
836
+ items: topics.map((topic) => topicItem(topic)),
837
+ stats: [{ title: "最新新帖", detail: `${topics.length} 条` }],
838
+ status: "新帖:Tab 切换 j/k 选择 Enter 打开 r 刷新",
839
+ paging: { kind: "new-topics", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true }
840
+ };
607
841
  }
608
842
  case "boards": {
843
+ this.setTabs([{ id: "default", label: "" }], "default");
609
844
  const sections = asArray(await this.client.getAllBoards(force, signal));
610
845
  const boards = flattenBoards(sections);
611
846
  return {
@@ -616,15 +851,37 @@ export class TuiController {
616
851
  };
617
852
  }
618
853
  case "following": {
619
- const topics = asArray(await this.client.getFolloweeTopics(0, 12, force, signal));
620
- return {
621
- title: "关注",
622
- items: topics.map((topic) => topicItem(topic)),
623
- stats: [{ title: "关注动态", detail: `${topics.length} 条` }, { title: "缓存", detail: "30s" }],
624
- status: "关注:j/k 选择 Enter 打开帖子 h 返回 r 刷新"
625
- };
854
+ this.setTabs([
855
+ { id: "follow-boards", label: "版面" },
856
+ { id: "follow-users", label: "用户" },
857
+ { id: "follow-favorites", label: "追踪" }
858
+ ], "follow-boards");
859
+ if (this.state.tabId === "follow-users") {
860
+ const size = 20;
861
+ const topics = asArray(await this.client.getFolloweeTopics(0, size, force, signal));
862
+ return {
863
+ title: "关注 · 用户",
864
+ items: topics.map((topic) => topicItem(topic)),
865
+ stats: [{ title: "关注用户", detail: `${topics.length} 条` }],
866
+ status: "关注:Tab 切换 j/k 选择 Enter 打开 r 刷新",
867
+ paging: { kind: "followee-topics", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true }
868
+ };
869
+ }
870
+ if (this.state.tabId === "follow-favorites") {
871
+ const size = 20;
872
+ const topics = asArray(await this.client.getFavoriteTopics(0, size, 1, 0, force, signal));
873
+ return {
874
+ title: "关注 · 追踪",
875
+ items: topics.map((topic) => topicItem(topic)),
876
+ stats: [{ title: "收藏更新", detail: `${topics.length} 条` }],
877
+ status: "关注:Tab 切换 j/k 选择 Enter 打开 r 刷新",
878
+ paging: { kind: "favorite-updates", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true }
879
+ };
880
+ }
881
+ return this.loadFavoriteBoardTopics(force, signal);
626
882
  }
627
883
  case "favorite": {
884
+ this.setTabs([{ id: "default", label: "" }], "default");
628
885
  const [meRaw, sectionsRaw, topicFavorites] = await Promise.all([
629
886
  this.client.getMe(force, signal),
630
887
  this.client.getAllBoards(false, signal),
@@ -633,12 +890,20 @@ export class TuiController {
633
890
  const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
634
891
  const allBoards = flattenBoards(asArray(sectionsRaw));
635
892
  const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
893
+ const boardPageSize = 5;
636
894
  const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
637
895
  const board = boardById.get(boardId);
638
- const topics = asArray(await this.client.getBoardTopics(boardId, 0, 3, false, force, signal));
639
- return topics.map((topic) => topicItem(topic, board));
896
+ const topics = asArray(await this.client.getBoardTopics(boardId, 0, boardPageSize, false, force, signal));
897
+ return { boardId, board, topics: topics.map((topic) => topicItem(topic, board)), hasMore: topics.length >= boardPageSize };
640
898
  });
641
- const boardTopics = topicGroups.flat().sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0)).slice(0, 12);
899
+ const boardTopics = topicGroups.flatMap((group) => group.topics).sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0));
900
+ const boardCursors = topicGroups.map((group) => ({
901
+ boardId: group.boardId,
902
+ title: group.board?.title ?? `#${group.boardId}`,
903
+ loaded: group.topics.length,
904
+ size: boardPageSize,
905
+ hasMore: group.hasMore
906
+ }));
642
907
  return {
643
908
  title: "收藏",
644
909
  items: [
@@ -653,7 +918,15 @@ export class TuiController {
653
918
  { title: "收藏主题", detail: `${asArray(topicFavorites).length} 条` },
654
919
  { title: "版面主题", detail: `${boardTopics.length} 条` }
655
920
  ],
656
- status: "收藏:j/k 选择 Enter 打开 h 返回 r 刷新"
921
+ status: "收藏:j/k 选择 Enter 打开 h 返回 r 刷新",
922
+ paging: {
923
+ kind: "favorite-board-topics",
924
+ loaded: boardTopics.length,
925
+ size: Math.max(12, customBoards.length * boardPageSize),
926
+ hasMore: boardCursors.some((cursor) => cursor.hasMore),
927
+ anchorOnReturn: true,
928
+ boardCursors
929
+ }
657
930
  };
658
931
  }
659
932
  case "messages": {
@@ -705,16 +978,63 @@ export class TuiController {
705
978
  };
706
979
  }
707
980
  case "settings": {
708
- const cacheStats = await this.client.getCacheStats();
981
+ const [cacheStats, autoSignin] = await Promise.all([
982
+ this.client.getCacheStats(),
983
+ this.settingsStore.isAutoSigninEnabled()
984
+ ]);
709
985
  return {
710
986
  title: "设置",
711
- items: [...settingsItems],
712
- stats: [{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` }, { title: "版本", detail: `v${appVersion}` }],
987
+ items: this.renderSettingsItems(autoSignin),
988
+ stats: [
989
+ { title: "自动签到", detail: autoSignin ? "已开启" : "已关闭" },
990
+ { title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` },
991
+ { title: "版本", detail: `v${appVersion}` }
992
+ ],
713
993
  status: "设置:j/k 选择 Enter 执行 h 返回"
714
994
  };
715
995
  }
716
996
  }
717
997
  }
998
+ async loadFavoriteBoardTopics(force, signal) {
999
+ const [meRaw, sectionsRaw] = await Promise.all([
1000
+ this.client.getMe(force, signal),
1001
+ this.client.getAllBoards(false, signal)
1002
+ ]);
1003
+ const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
1004
+ const allBoards = flattenBoards(asArray(sectionsRaw));
1005
+ const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
1006
+ const boardPageSize = 5;
1007
+ const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
1008
+ const board = boardById.get(boardId);
1009
+ const topics = asArray(await this.client.getBoardTopics(boardId, 0, boardPageSize, false, force, signal));
1010
+ return { boardId, board, topics: topics.map((topic) => topicItem(topic, board)), hasMore: topics.length >= boardPageSize };
1011
+ });
1012
+ const boardTopics = topicGroups.flatMap((group) => group.topics).sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0));
1013
+ const boardCursors = topicGroups.map((group) => ({
1014
+ boardId: group.boardId,
1015
+ title: group.board?.title ?? `#${group.boardId}`,
1016
+ loaded: group.topics.length,
1017
+ size: boardPageSize,
1018
+ hasMore: group.hasMore
1019
+ }));
1020
+ return {
1021
+ title: "关注 · 版面",
1022
+ items: boardTopics,
1023
+ stats: [
1024
+ { title: "关注版面", detail: `${customBoards.length} 个` },
1025
+ { title: "版面主题", detail: `${boardTopics.length} 条` }
1026
+ ],
1027
+ status: "关注:Tab 切换 j/k 选择 Enter 打开 r 刷新",
1028
+ paging: {
1029
+ kind: "favorite-board-topics",
1030
+ loaded: boardTopics.length,
1031
+ size: Math.max(12, customBoards.length * boardPageSize),
1032
+ hasMore: boardCursors.some((cursor) => cursor.hasMore),
1033
+ anchorOnReturn: true,
1034
+ boardCursors
1035
+ }
1036
+ };
1037
+ }
718
1038
  async activateSetting(selected) {
719
1039
  if (!selected)
720
1040
  return;
@@ -731,6 +1051,14 @@ export class TuiController {
731
1051
  void this.openCacheManager();
732
1052
  return;
733
1053
  }
1054
+ if (selected.meta === "pixel-logo") {
1055
+ this.openPixelLogo();
1056
+ return;
1057
+ }
1058
+ if (selected.meta === "emoji-preview") {
1059
+ this.openEmojiPreview();
1060
+ return;
1061
+ }
734
1062
  if (selected.meta === "update") {
735
1063
  void this.checkUpdate(true);
736
1064
  return;
@@ -739,6 +1067,10 @@ export class TuiController {
739
1067
  void this.openAccountSwitcher();
740
1068
  return;
741
1069
  }
1070
+ if (selected.meta === "auto-signin") {
1071
+ void this.toggleAutoSignin();
1072
+ return;
1073
+ }
742
1074
  if (selected.meta === "logout") {
743
1075
  void this.confirmLogout();
744
1076
  return;
@@ -746,8 +1078,67 @@ export class TuiController {
746
1078
  this.state.status = "功能开发中...";
747
1079
  this.render();
748
1080
  }
1081
+ renderSettingsItems(autoSignin) {
1082
+ return settingsItems.map((item) => {
1083
+ if (item.meta !== "auto-signin") {
1084
+ return { ...item };
1085
+ }
1086
+ return {
1087
+ ...item,
1088
+ title: `自动签到: ${autoSignin ? "开启" : "关闭"}`,
1089
+ detail: autoSignin
1090
+ ? "启动后为所有账号执行每日签到"
1091
+ : "默认关闭,启动时不自动签到"
1092
+ };
1093
+ });
1094
+ }
1095
+ async toggleAutoSignin() {
1096
+ const enabled = await this.settingsStore.isAutoSigninEnabled();
1097
+ const next = !enabled;
1098
+ await this.settingsStore.setAutoSigninEnabled(next);
1099
+ this.state.items = this.renderSettingsItems(next);
1100
+ this.state.stats = [
1101
+ { title: "自动签到", detail: next ? "已开启" : "已关闭" },
1102
+ ...this.state.stats.filter((item) => item.title !== "自动签到")
1103
+ ];
1104
+ this.state.status = next ? "已开启自动签到" : "已关闭自动签到";
1105
+ this.render();
1106
+ }
1107
+ async runAutoSignin() {
1108
+ const enabled = await this.settingsStore.isAutoSigninEnabled();
1109
+ if (!enabled)
1110
+ return;
1111
+ const accounts = await this.tokenStore.listAccounts();
1112
+ if (accounts.length === 0)
1113
+ return;
1114
+ let success = 0;
1115
+ let failed = 0;
1116
+ this.state.status = `自动签到: 0/${accounts.length}`;
1117
+ this.render();
1118
+ for (const account of accounts) {
1119
+ try {
1120
+ const tokenStore = this.tokenStore.withAccount(account.account);
1121
+ const client = new Cc98Client({ tokenStore, webVpn: this.webVpnOptions });
1122
+ if (this.webVpnOptions) {
1123
+ await client.initWebVpn();
1124
+ }
1125
+ await client.signin();
1126
+ success += 1;
1127
+ }
1128
+ catch {
1129
+ failed += 1;
1130
+ }
1131
+ this.state.status = `自动签到: ${success + failed}/${accounts.length}`;
1132
+ this.render();
1133
+ }
1134
+ this.state.status = failed > 0
1135
+ ? `自动签到完成: ${success} 成功,${failed} 失败`
1136
+ : `自动签到完成: ${success} 个账号`;
1137
+ this.render();
1138
+ }
749
1139
  async activateContentItem(selected, signal) {
750
1140
  if (selected.topicId !== undefined) {
1141
+ this.rememberListReturnState();
751
1142
  await this.openTopic(selected.topicId, false, signal);
752
1143
  return;
753
1144
  }
@@ -769,6 +1160,14 @@ export class TuiController {
769
1160
  await this.switchAccount(accountName);
770
1161
  return;
771
1162
  }
1163
+ if (selected.meta?.startsWith("emoji-category:")) {
1164
+ this.openEmojiCategory(selected.meta.slice("emoji-category:".length));
1165
+ return;
1166
+ }
1167
+ if (selected.meta?.startsWith("emoji:")) {
1168
+ this.openEmojiDetail(selected.meta.slice("emoji:".length));
1169
+ return;
1170
+ }
772
1171
  if (selected.action?.startsWith("notices:")) {
773
1172
  await this.openNoticeList(selected.action.split(":")[1], signal);
774
1173
  return;
@@ -780,7 +1179,7 @@ export class TuiController {
780
1179
  this.state.status = "当前条目不可进入";
781
1180
  this.render();
782
1181
  }
783
- async openTopic(topicId, force, signal) {
1182
+ async openTopic(topicId, force, signal, restore) {
784
1183
  this.state.mode = "topic";
785
1184
  this.state.loading = true;
786
1185
  this.state.error = undefined;
@@ -789,11 +1188,15 @@ export class TuiController {
789
1188
  this.state.scroll = 0;
790
1189
  this.render();
791
1190
  try {
1191
+ const from = restore ? Math.max(0, restore.floor - 1) : 0;
1192
+ const size = 10;
792
1193
  const [topicRaw, postsRaw] = await Promise.all([
793
1194
  this.client.getTopic(topicId, force, signal),
794
- this.client.getTopicPosts(topicId, 0, 10, force, signal)
1195
+ this.client.getTopicPosts(topicId, from, size, force, signal)
795
1196
  ]);
796
- this.state.topic = buildTopicReader(topicId, asObject(topicRaw), asArray(postsRaw), 10);
1197
+ this.state.topic = buildTopicReader(topicId, asObject(topicRaw), asArray(postsRaw), size, from);
1198
+ if (restore)
1199
+ this.restoreTopicPosition(restore);
797
1200
  this.state.loading = false;
798
1201
  this.state.status = "";
799
1202
  }
@@ -804,6 +1207,35 @@ export class TuiController {
804
1207
  }
805
1208
  }
806
1209
  this.render();
1210
+ // Start background image preloading so preview/open/copy can reuse the local cache.
1211
+ if (this.state.topic) {
1212
+ void this.preloadTopicImages(this.state.topic);
1213
+ }
1214
+ }
1215
+ async refreshCurrentTopic(signal) {
1216
+ const topic = this.state.topic;
1217
+ if (!topic)
1218
+ return;
1219
+ const restore = this.getTopicRestoreTarget(topic);
1220
+ await this.openTopic(topic.topicId, true, signal, restore);
1221
+ }
1222
+ getTopicRestoreTarget(topic) {
1223
+ const post = currentTopicPost(topic, topic.cursorLine);
1224
+ return {
1225
+ floor: post?.floor ?? 1,
1226
+ lineOffset: post ? Math.max(0, topic.cursorLine - post.lineStart) : 0,
1227
+ loaded: topic.loaded
1228
+ };
1229
+ }
1230
+ restoreTopicPosition(target) {
1231
+ const topic = this.state.topic;
1232
+ if (!topic)
1233
+ return;
1234
+ const post = findTopicPostByFloor(topic, target.floor);
1235
+ if (!post)
1236
+ return;
1237
+ topic.cursorLine = Math.min(post.lineEnd, post.lineStart + target.lineOffset);
1238
+ this.state.scroll = topic.cursorLine;
807
1239
  }
808
1240
  async openBoard(boardId, title, force, signal, pushParent = true) {
809
1241
  if (pushParent)
@@ -813,6 +1245,7 @@ export class TuiController {
813
1245
  this.state.viewTitle = title;
814
1246
  this.state.focus = "content";
815
1247
  this.state.currentBoard = { boardId, title };
1248
+ this.state.listPaging = undefined;
816
1249
  this.state.itemIndex = 0;
817
1250
  this.state.scroll = 0;
818
1251
  this.render();
@@ -838,6 +1271,7 @@ export class TuiController {
838
1271
  this.state.error = undefined;
839
1272
  this.state.viewTitle = `私信: ${title}`;
840
1273
  this.state.focus = "content";
1274
+ this.state.listPaging = undefined;
841
1275
  this.state.itemIndex = 0;
842
1276
  this.state.scroll = 0;
843
1277
  this.render();
@@ -934,9 +1368,7 @@ export class TuiController {
934
1368
  return;
935
1369
  const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
936
1370
  if (pageInfo.currentPage > 1) {
937
- topic.cursorLine = jumpToPage(topic, pageInfo.currentPage - 1);
938
- this.state.status = "";
939
- this.render();
1371
+ await this.jumpToTopicPage(pageInfo.currentPage - 1, this.nextSignal());
940
1372
  }
941
1373
  }
942
1374
  async jumpToTopicPage(page, signal) {
@@ -949,13 +1381,9 @@ export class TuiController {
949
1381
  this.render();
950
1382
  return;
951
1383
  }
952
- // 如果目标页未加载,需要加载到该页
953
1384
  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;
1385
+ if (!findTopicPostByFloor(topic, targetFloor)) {
1386
+ await this.loadTopicWindow(targetFloor, signal);
959
1387
  }
960
1388
  const post = findTopicPostByFloor(topic, targetFloor);
961
1389
  if (post) {
@@ -967,6 +1395,30 @@ export class TuiController {
967
1395
  }
968
1396
  this.render();
969
1397
  }
1398
+ async loadTopicWindow(startFloor, signal) {
1399
+ const topic = this.state.topic;
1400
+ if (!topic || this.state.loadingMore)
1401
+ return;
1402
+ const from = Math.max(0, startFloor - 1);
1403
+ this.state.loadingMore = true;
1404
+ this.render();
1405
+ try {
1406
+ const posts = asArray(await this.client.getTopicPosts(topic.topicId, from, topic.size, false, signal));
1407
+ replaceTopicPosts(topic, posts, from);
1408
+ topic.hasMore = from + posts.length < topic.totalFloors;
1409
+ this.state.scroll = 0;
1410
+ this.state.status = "";
1411
+ void this.preloadTopicImages(topic);
1412
+ }
1413
+ catch (error) {
1414
+ if (!isAbortError(error))
1415
+ this.state.status = error instanceof Error ? error.message : "加载失败";
1416
+ }
1417
+ finally {
1418
+ this.state.loadingMore = false;
1419
+ this.render();
1420
+ }
1421
+ }
970
1422
  async loadNextTopicPage(signal, quiet = false) {
971
1423
  const topic = this.state.topic;
972
1424
  if (!topic?.hasMore || this.state.loadingMore)
@@ -978,6 +1430,7 @@ export class TuiController {
978
1430
  const posts = asArray(await this.client.getTopicPosts(topic.topicId, topic.loaded, topic.size, false, signal));
979
1431
  appendTopicPosts(topic, posts);
980
1432
  this.state.status = "";
1433
+ void this.preloadTopicImages(topic);
981
1434
  }
982
1435
  catch (error) {
983
1436
  if (!isAbortError(error))
@@ -999,11 +1452,8 @@ export class TuiController {
999
1452
  this.render();
1000
1453
  return;
1001
1454
  }
1002
- while (topic.hasMore && !findTopicPostByFloor(topic, floor)) {
1003
- const previousLoaded = topic.loaded;
1004
- await this.loadNextTopicPage(signal, true);
1005
- if (topic.loaded === previousLoaded)
1006
- break;
1455
+ if (!findTopicPostByFloor(topic, floor)) {
1456
+ await this.loadTopicWindow(floor, signal);
1007
1457
  }
1008
1458
  const post = findTopicPostByFloor(topic, floor);
1009
1459
  topic.cursorLine = post?.lineStart ?? topic.cursorLine;
@@ -1030,8 +1480,9 @@ export class TuiController {
1030
1480
  case "favorite-topics":
1031
1481
  case "favorite-updates": {
1032
1482
  const order = action === "favorite-updates" ? 1 : 0;
1033
- const topics = asArray(await this.client.getFavoriteTopics(0, 11, order, 0, false, signal));
1034
- this.openReadOnlyList(action === "favorite-updates" ? "收藏更新" : "收藏主题", topics.map((topic) => topicItem(topic)), [{ title: "主题", detail: `${topics.length}` }]);
1483
+ const size = 20;
1484
+ const topics = asArray(await this.client.getFavoriteTopics(0, size, order, 0, false, signal));
1485
+ this.openReadOnlyList(action === "favorite-updates" ? "收藏更新" : "收藏主题", topics.map((topic) => topicItem(topic)), [{ title: "主题", detail: `${topics.length}` }], { kind: action === "favorite-updates" ? "favorite-updates" : "favorite-topics", loaded: topics.length, size, hasMore: topics.length >= size, anchorOnReturn: true });
1035
1486
  return;
1036
1487
  }
1037
1488
  case "favorite-groups": {
@@ -1250,12 +1701,55 @@ export class TuiController {
1250
1701
  const topic = this.state.topic;
1251
1702
  if (!topic)
1252
1703
  return undefined;
1704
+ return currentTopicLine(topic, topic.cursorLine);
1705
+ }
1706
+ /**
1707
+ * Preload images for topic in background
1708
+ * Images are cached and trigger re-render when ready
1709
+ */
1710
+ async preloadTopicImages(topic) {
1711
+ const cache = getImageCache();
1712
+ const imagesToLoad = new Set();
1713
+ // Collect all unique image URLs from posts
1253
1714
  for (const post of topic.posts) {
1254
- const line = post.lines.find((entry) => entry.line === topic.cursorLine);
1255
- if (line)
1256
- return line;
1715
+ for (const imageUrl of post.images) {
1716
+ if (imageUrl && !topic.imageCache.has(imageUrl) && !topic.imageLoading.has(imageUrl)) {
1717
+ imagesToLoad.add(imageUrl);
1718
+ }
1719
+ }
1720
+ }
1721
+ if (imagesToLoad.size === 0)
1722
+ return;
1723
+ // Load images in parallel with concurrency limit
1724
+ const urls = Array.from(imagesToLoad);
1725
+ const concurrency = 3;
1726
+ for (let i = 0; i < urls.length; i += concurrency) {
1727
+ // Check if topic is still the same (user might have navigated away)
1728
+ if (this.state.topic !== topic)
1729
+ break;
1730
+ const batch = urls.slice(i, i + concurrency);
1731
+ let shouldRender = false;
1732
+ const promises = batch.map(async (url) => {
1733
+ topic.imageLoading.add(url);
1734
+ try {
1735
+ const localPath = await cache.getOrDownload(url);
1736
+ topic.imageErrors.delete(url);
1737
+ topic.imageCache.set(url, localPath);
1738
+ shouldRender = true;
1739
+ }
1740
+ catch (error) {
1741
+ topic.imageErrors.set(url, error instanceof Error ? error.message : "下载失败");
1742
+ shouldRender = true;
1743
+ }
1744
+ finally {
1745
+ topic.imageLoading.delete(url);
1746
+ }
1747
+ });
1748
+ await Promise.all(promises);
1749
+ if (shouldRender && this.state.topic === topic) {
1750
+ this.render();
1751
+ }
1257
1752
  }
1258
- return undefined;
1259
1753
  }
1260
1754
  async openImage(url) {
1261
1755
  this.state.status = "正在下载图片...";
@@ -1269,7 +1763,11 @@ export class TuiController {
1269
1763
  const { execFile } = await import("node:child_process");
1270
1764
  const platform = process.platform;
1271
1765
  const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
1272
- const args = platform === "win32" ? ["/c", "start", "", localPath] : [localPath];
1766
+ const args = platform === "win32"
1767
+ ? ["/c", "start", "", localPath]
1768
+ : platform === "darwin"
1769
+ ? ["-a", "Preview", localPath]
1770
+ : [localPath];
1273
1771
  execFile(command, args, (error) => {
1274
1772
  if (error) {
1275
1773
  this.state.status = `打开失败: ${error.message}`;
@@ -1282,6 +1780,58 @@ export class TuiController {
1282
1780
  this.render();
1283
1781
  }
1284
1782
  }
1783
+ async copyImageToClipboard(url) {
1784
+ this.state.status = "正在复制图片...";
1785
+ this.render();
1786
+ try {
1787
+ const cache = getImageCache();
1788
+ const localPath = await cache.getOrDownload(url);
1789
+ await this.copyImageFileToClipboard(localPath);
1790
+ this.state.status = "已复制图片到剪贴板";
1791
+ this.render();
1792
+ }
1793
+ catch (error) {
1794
+ this.state.status = error instanceof Error ? error.message : "复制图片失败";
1795
+ this.render();
1796
+ }
1797
+ }
1798
+ async copyImageFileToClipboard(localPath) {
1799
+ const platform = process.platform;
1800
+ if (platform === "darwin") {
1801
+ await this.copyImageFileToClipboardMac(localPath);
1802
+ return;
1803
+ }
1804
+ if (platform === "win32") {
1805
+ const script = [
1806
+ "Add-Type -AssemblyName System.Windows.Forms",
1807
+ "Add-Type -AssemblyName System.Drawing",
1808
+ "$image=[System.Drawing.Image]::FromFile($args[0])",
1809
+ "[System.Windows.Forms.Clipboard]::SetImage($image)",
1810
+ "$image.Dispose()"
1811
+ ].join("; ");
1812
+ await execFilePromise("powershell.exe", ["-NoProfile", "-Command", script, localPath]);
1813
+ return;
1814
+ }
1815
+ const mime = imageMimeType(localPath);
1816
+ await execFilePromise("xclip", ["-selection", "clipboard", "-t", mime, localPath]);
1817
+ }
1818
+ async copyImageFileToClipboardMac(localPath) {
1819
+ const { mkdtemp, rm } = await import("node:fs/promises");
1820
+ const { tmpdir } = await import("node:os");
1821
+ const { join } = await import("node:path");
1822
+ const dir = await mkdtemp(join(tmpdir(), "cc98-image-"));
1823
+ const tiffPath = join(dir, "clipboard.tiff");
1824
+ try {
1825
+ await execFilePromise("sips", ["-s", "format", "tiff", localPath, "--out", tiffPath]);
1826
+ await execFilePromise("osascript", [
1827
+ "-e",
1828
+ `set the clipboard to (read (POSIX file ${appleScriptString(tiffPath)}) as TIFF picture)`
1829
+ ]);
1830
+ }
1831
+ finally {
1832
+ await rm(dir, { recursive: true, force: true });
1833
+ }
1834
+ }
1285
1835
  async copyToClipboard(text) {
1286
1836
  try {
1287
1837
  const { spawn } = await import("node:child_process");
@@ -1345,9 +1895,74 @@ export class TuiController {
1345
1895
  this.state.infoLines = lines;
1346
1896
  this.render();
1347
1897
  }
1898
+ openPixelLogo() {
1899
+ this.state.modal = "info";
1900
+ this.state.infoTitle = "CC98 像素 Logo";
1901
+ this.state.infoLines = [
1902
+ ...renderCc98Logo().split("\n"),
1903
+ "",
1904
+ "来源: https://www.cc98.org/static/images/98LOGO.ico",
1905
+ "渲染: 24-bit ANSI 半块像素"
1906
+ ];
1907
+ this.render();
1908
+ }
1909
+ openEmojiPreview() {
1910
+ const items = EMOJI_CATEGORIES.map((category) => ({
1911
+ title: `${category.label} (${category.codes.length})`,
1912
+ meta: `emoji-category:${category.id}`,
1913
+ detail: `来源目录: Assets/Emoji/${category.source} · ${category.codes[0]} - ${category.codes.at(-1)}`
1914
+ }));
1915
+ this.openReadOnlyList("表情包预览", items, EMOJI_CATEGORIES.map((category) => ({
1916
+ title: category.label,
1917
+ detail: `${category.codes.length} 个`
1918
+ })));
1919
+ this.state.status = "表情包预览:j/k 选择分类 Enter 进入 h 返回";
1920
+ this.render();
1921
+ }
1922
+ openEmojiCategory(categoryId) {
1923
+ const category = EMOJI_CATEGORIES.find((item) => item.id === categoryId);
1924
+ if (!category) {
1925
+ this.state.status = "未找到表情分类";
1926
+ this.render();
1927
+ return;
1928
+ }
1929
+ const items = category.codes.map((code) => {
1930
+ const art = getEmojiArt(code);
1931
+ return {
1932
+ title: `[${code}]`,
1933
+ meta: `emoji:${code}`,
1934
+ detail: art ? `${category.label} · ${art.width}x${art.height}px` : category.label
1935
+ };
1936
+ });
1937
+ this.openReadOnlyList(category.label, items, [
1938
+ { title: "分类", detail: category.label },
1939
+ { title: "数量", detail: `${category.codes.length} 个` },
1940
+ { title: "来源", detail: `Assets/Emoji/${category.source}` }
1941
+ ]);
1942
+ this.state.status = `${category.label}:j/k 选择 Enter 放大 h 返回分类`;
1943
+ this.render();
1944
+ }
1945
+ openEmojiDetail(code) {
1946
+ const art = getEmojiArt(code);
1947
+ const rendered = renderEmojiCode(code);
1948
+ if (!art || !rendered) {
1949
+ this.state.status = `未找到表情 [${code}]`;
1950
+ this.render();
1951
+ return;
1952
+ }
1953
+ this.state.modal = "info";
1954
+ this.state.infoTitle = `[${code}]`;
1955
+ this.state.infoLines = [
1956
+ ...rendered.split("\n"),
1957
+ "",
1958
+ `尺寸: ${art.width}x${art.height}px`,
1959
+ `颜色: ${art.palette.length}`
1960
+ ];
1961
+ this.render();
1962
+ }
1348
1963
  async openAccountSwitcher() {
1349
1964
  try {
1350
- const accounts = await this.tokenStore.listAccounts();
1965
+ const accounts = (await this.tokenStore.listAccounts()).filter((account) => account.account !== "default");
1351
1966
  const currentAccount = await this.tokenStore.getCurrentAccountName();
1352
1967
  if (accounts.length === 0) {
1353
1968
  this.state.modal = "info";
@@ -1366,7 +1981,7 @@ export class TuiController {
1366
1981
  this.state.viewTitle = "切换账号";
1367
1982
  this.state.items = items;
1368
1983
  this.state.stats = [{ title: "账号数", detail: `${accounts.length}` }];
1369
- this.state.itemIndex = accounts.findIndex(a => a.account === currentAccount);
1984
+ this.state.itemIndex = Math.max(0, accounts.findIndex(a => a.account === currentAccount));
1370
1985
  this.state.scroll = 0;
1371
1986
  this.state.focus = "content";
1372
1987
  this.state.mode = "list";
@@ -1464,11 +2079,12 @@ export class TuiController {
1464
2079
  const users = asArray(await this.client.getUsers(ids, false, signal));
1465
2080
  this.openReadOnlyList(type === "follower" ? "粉丝列表" : "关注列表", users.map((user) => userItem(user)), [{ title: "用户", detail: `${users.length}` }]);
1466
2081
  }
1467
- openReadOnlyList(title, items, stats) {
2082
+ openReadOnlyList(title, items, stats, paging) {
1468
2083
  this.snapshotParent();
1469
2084
  this.state.viewTitle = title;
1470
2085
  this.state.items = items;
1471
2086
  this.state.stats = stats;
2087
+ this.state.listPaging = paging;
1472
2088
  this.state.itemIndex = 0;
1473
2089
  this.state.scroll = 0;
1474
2090
  this.state.focus = "content";
@@ -1480,15 +2096,16 @@ export class TuiController {
1480
2096
  this.render();
1481
2097
  }
1482
2098
  snapshotParent() {
1483
- if (!this.state.parentList) {
1484
- this.state.parentList = {
1485
- title: this.state.viewTitle,
1486
- items: [...this.state.items],
1487
- stats: [...this.state.stats],
1488
- itemIndex: this.state.itemIndex,
1489
- status: this.state.status
1490
- };
1491
- }
2099
+ this.state.parentList = {
2100
+ title: this.state.viewTitle,
2101
+ items: [...this.state.items],
2102
+ stats: [...this.state.stats],
2103
+ itemIndex: this.state.itemIndex,
2104
+ scroll: this.state.scroll,
2105
+ status: this.state.status,
2106
+ paging: this.cloneListPaging(this.state.listPaging),
2107
+ parent: this.state.parentList
2108
+ };
1492
2109
  }
1493
2110
  restoreParentList() {
1494
2111
  const parent = this.state.parentList;
@@ -1498,14 +2115,25 @@ export class TuiController {
1498
2115
  this.state.items = parent.items;
1499
2116
  this.state.stats = parent.stats;
1500
2117
  this.state.itemIndex = parent.itemIndex;
2118
+ this.state.scroll = parent.scroll;
1501
2119
  this.state.status = parent.status;
1502
- this.state.parentList = undefined;
2120
+ this.state.listPaging = this.cloneListPaging(parent.paging);
2121
+ this.state.parentList = parent.parent;
1503
2122
  this.state.currentBoard = undefined;
1504
2123
  this.state.currentChat = undefined;
1505
2124
  this.state.topic = undefined;
1506
2125
  this.state.mode = "list";
1507
2126
  this.state.focus = "content";
1508
- this.state.scroll = 0;
2127
+ this.render();
2128
+ }
2129
+ cloneListPaging(paging) {
2130
+ if (!paging)
2131
+ return undefined;
2132
+ return {
2133
+ ...paging,
2134
+ boardCursors: paging.boardCursors?.map((cursor) => ({ ...cursor })),
2135
+ buffer: paging.buffer?.map((itemValue) => ({ ...itemValue }))
2136
+ };
1509
2137
  }
1510
2138
  async checkUpdate(forceShow = false) {
1511
2139
  if (forceShow) {
@@ -1554,4 +2182,36 @@ export class TuiController {
1554
2182
  }
1555
2183
  }
1556
2184
  }
2185
+ async function execFilePromise(command, args) {
2186
+ const { execFile } = await import("node:child_process");
2187
+ await new Promise((resolve, reject) => {
2188
+ execFile(command, args, (error) => {
2189
+ if (error) {
2190
+ reject(error);
2191
+ }
2192
+ else {
2193
+ resolve();
2194
+ }
2195
+ });
2196
+ });
2197
+ }
2198
+ function appleScriptString(value) {
2199
+ return JSON.stringify(value);
2200
+ }
2201
+ function imageMimeType(path) {
2202
+ const lower = path.toLowerCase();
2203
+ if (lower.endsWith(".png"))
2204
+ return "image/png";
2205
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
2206
+ return "image/jpeg";
2207
+ if (lower.endsWith(".gif"))
2208
+ return "image/gif";
2209
+ if (lower.endsWith(".webp"))
2210
+ return "image/webp";
2211
+ if (lower.endsWith(".bmp"))
2212
+ return "image/bmp";
2213
+ if (lower.endsWith(".svg"))
2214
+ return "image/svg+xml";
2215
+ return "image/png";
2216
+ }
1557
2217
  //# sourceMappingURL=controller.js.map