cc98-cli 0.2.0 → 0.4.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 (150) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -2
  3. package/dist/api/client.d.ts +40 -15
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +179 -38
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/api/endpoints.d.ts +14 -0
  8. package/dist/api/endpoints.d.ts.map +1 -1
  9. package/dist/api/endpoints.js +21 -1
  10. package/dist/api/endpoints.js.map +1 -1
  11. package/dist/api/types.d.ts +13 -0
  12. package/dist/api/types.d.ts.map +1 -1
  13. package/dist/api/webvpn.d.ts +68 -0
  14. package/dist/api/webvpn.d.ts.map +1 -0
  15. package/dist/api/webvpn.js +311 -0
  16. package/dist/api/webvpn.js.map +1 -0
  17. package/dist/cli/commands/board.d.ts.map +1 -1
  18. package/dist/cli/commands/board.js +16 -1
  19. package/dist/cli/commands/board.js.map +1 -1
  20. package/dist/cli/commands/forum.js +1 -1
  21. package/dist/cli/commands/forum.js.map +1 -1
  22. package/dist/cli/commands/login.js +1 -1
  23. package/dist/cli/commands/login.js.map +1 -1
  24. package/dist/cli/commands/logout.js +2 -2
  25. package/dist/cli/commands/logout.js.map +1 -1
  26. package/dist/cli/commands/me.d.ts.map +1 -1
  27. package/dist/cli/commands/me.js +18 -3
  28. package/dist/cli/commands/me.js.map +1 -1
  29. package/dist/cli/commands/message.d.ts.map +1 -1
  30. package/dist/cli/commands/message.js +11 -1
  31. package/dist/cli/commands/message.js.map +1 -1
  32. package/dist/cli/commands/notice.js +1 -1
  33. package/dist/cli/commands/notice.js.map +1 -1
  34. package/dist/cli/commands/post.d.ts.map +1 -1
  35. package/dist/cli/commands/post.js +13 -1
  36. package/dist/cli/commands/post.js.map +1 -1
  37. package/dist/cli/commands/search.js +1 -1
  38. package/dist/cli/commands/search.js.map +1 -1
  39. package/dist/cli/commands/topic.d.ts.map +1 -1
  40. package/dist/cli/commands/topic.js +42 -6
  41. package/dist/cli/commands/topic.js.map +1 -1
  42. package/dist/cli/commands/user.d.ts.map +1 -1
  43. package/dist/cli/commands/user.js +13 -1
  44. package/dist/cli/commands/user.js.map +1 -1
  45. package/dist/cli/commands/vpn.d.ts +2 -0
  46. package/dist/cli/commands/vpn.d.ts.map +1 -0
  47. package/dist/cli/commands/vpn.js +200 -0
  48. package/dist/cli/commands/vpn.js.map +1 -0
  49. package/dist/cli/context.d.ts +2 -2
  50. package/dist/cli/context.d.ts.map +1 -1
  51. package/dist/cli/context.js +20 -2
  52. package/dist/cli/context.js.map +1 -1
  53. package/dist/cli/router.d.ts.map +1 -1
  54. package/dist/cli/router.js +13 -2
  55. package/dist/cli/router.js.map +1 -1
  56. package/dist/main.js +0 -0
  57. package/dist/storage/image-cache.d.ts +32 -0
  58. package/dist/storage/image-cache.d.ts.map +1 -0
  59. package/dist/storage/image-cache.js +90 -0
  60. package/dist/storage/image-cache.js.map +1 -0
  61. package/dist/storage/vpn-store.d.ts +39 -0
  62. package/dist/storage/vpn-store.d.ts.map +1 -0
  63. package/dist/storage/vpn-store.js +94 -0
  64. package/dist/storage/vpn-store.js.map +1 -0
  65. package/dist/tui/app.d.ts.map +1 -1
  66. package/dist/tui/app.js +27 -1725
  67. package/dist/tui/app.js.map +1 -1
  68. package/dist/tui/borders.d.ts +183 -0
  69. package/dist/tui/borders.d.ts.map +1 -0
  70. package/dist/tui/borders.js +143 -0
  71. package/dist/tui/borders.js.map +1 -0
  72. package/dist/tui/cached-client.d.ts +23 -0
  73. package/dist/tui/cached-client.d.ts.map +1 -1
  74. package/dist/tui/cached-client.js +88 -0
  75. package/dist/tui/cached-client.js.map +1 -1
  76. package/dist/tui/components/content.d.ts +11 -0
  77. package/dist/tui/components/content.d.ts.map +1 -0
  78. package/dist/tui/components/content.js +129 -0
  79. package/dist/tui/components/content.js.map +1 -0
  80. package/dist/tui/components/header.d.ts +7 -0
  81. package/dist/tui/components/header.d.ts.map +1 -0
  82. package/dist/tui/components/header.js +38 -0
  83. package/dist/tui/components/header.js.map +1 -0
  84. package/dist/tui/components/index.d.ts +8 -0
  85. package/dist/tui/components/index.d.ts.map +1 -0
  86. package/dist/tui/components/index.js +9 -0
  87. package/dist/tui/components/index.js.map +1 -0
  88. package/dist/tui/components/layout.d.ts +3 -0
  89. package/dist/tui/components/layout.d.ts.map +1 -0
  90. package/dist/tui/components/layout.js +453 -0
  91. package/dist/tui/components/layout.js.map +1 -0
  92. package/dist/tui/components/overview.d.ts +7 -0
  93. package/dist/tui/components/overview.d.ts.map +1 -0
  94. package/dist/tui/components/overview.js +30 -0
  95. package/dist/tui/components/overview.js.map +1 -0
  96. package/dist/tui/components/sidebar.d.ts +7 -0
  97. package/dist/tui/components/sidebar.d.ts.map +1 -0
  98. package/dist/tui/components/sidebar.js +57 -0
  99. package/dist/tui/components/sidebar.js.map +1 -0
  100. package/dist/tui/components/status.d.ts +8 -0
  101. package/dist/tui/components/status.d.ts.map +1 -0
  102. package/dist/tui/components/status.js +42 -0
  103. package/dist/tui/components/status.js.map +1 -0
  104. package/dist/tui/components/types.d.ts +17 -0
  105. package/dist/tui/components/types.d.ts.map +1 -0
  106. package/dist/tui/components/types.js +3 -0
  107. package/dist/tui/components/types.js.map +1 -0
  108. package/dist/tui/components/utils.d.ts +5 -0
  109. package/dist/tui/components/utils.d.ts.map +1 -0
  110. package/dist/tui/components/utils.js +68 -0
  111. package/dist/tui/components/utils.js.map +1 -0
  112. package/dist/tui/controller.d.ts +66 -0
  113. package/dist/tui/controller.d.ts.map +1 -0
  114. package/dist/tui/controller.js +1200 -0
  115. package/dist/tui/controller.js.map +1 -0
  116. package/dist/tui/helpers.d.ts +25 -0
  117. package/dist/tui/helpers.d.ts.map +1 -0
  118. package/dist/tui/helpers.js +249 -0
  119. package/dist/tui/helpers.js.map +1 -0
  120. package/dist/tui/navigation.d.ts +4 -0
  121. package/dist/tui/navigation.d.ts.map +1 -0
  122. package/dist/tui/navigation.js +19 -0
  123. package/dist/tui/navigation.js.map +1 -0
  124. package/dist/tui/renderer.d.ts +6 -0
  125. package/dist/tui/renderer.d.ts.map +1 -0
  126. package/dist/tui/renderer.js +305 -0
  127. package/dist/tui/renderer.js.map +1 -0
  128. package/dist/tui/state/index.d.ts +3 -0
  129. package/dist/tui/state/index.d.ts.map +1 -0
  130. package/dist/tui/state/index.js +4 -0
  131. package/dist/tui/state/index.js.map +1 -0
  132. package/dist/tui/state/store.d.ts +4 -0
  133. package/dist/tui/state/store.d.ts.map +1 -0
  134. package/dist/tui/state/store.js +62 -0
  135. package/dist/tui/state/store.js.map +1 -0
  136. package/dist/tui/state/types.d.ts +141 -0
  137. package/dist/tui/state/types.d.ts.map +1 -0
  138. package/dist/tui/state/types.js +3 -0
  139. package/dist/tui/state/types.js.map +1 -0
  140. package/dist/tui/topic-reader.d.ts +10 -0
  141. package/dist/tui/topic-reader.d.ts.map +1 -0
  142. package/dist/tui/topic-reader.js +178 -0
  143. package/dist/tui/topic-reader.js.map +1 -0
  144. package/dist/tui/ubb-renderer.d.ts.map +1 -1
  145. package/dist/tui/ubb-renderer.js +176 -12
  146. package/dist/tui/ubb-renderer.js.map +1 -1
  147. package/dist/version.d.ts +1 -1
  148. package/dist/version.js +1 -1
  149. package/package.json +2 -2
  150. /package/{docs/images → images}/tui.jpg +0 -0
package/dist/tui/app.js CHANGED
@@ -1,502 +1,59 @@
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";
5
- import { ansi, bg, fg, stripAnsi } from "./ansi.js";
3
+ import { VpnStore } from "../storage/vpn-store.js";
6
4
  import { CachedCc98Client } from "./cached-client.js";
5
+ import { TuiController } from "./controller.js";
6
+ import { draw } from "./renderer.js";
7
+ import { createInitialState } from "./state/store.js";
7
8
  import { Terminal } from "./terminal.js";
8
- import { renderUbbToLines } from "./ubb-renderer.js";
9
- const cc98Blue = fg(0, 130, 202);
10
- const cc98BlueSoft = fg(94, 180, 232);
11
- const cc98BlueBg = bg(0, 104, 176);
12
- const white = fg(245, 250, 255);
13
- const muted = fg(139, 152, 166);
14
- const line = fg(52, 84, 112);
15
- const danger = fg(245, 101, 101);
16
- const ok = fg(91, 207, 140);
17
- const mascotMini = [
18
- " ▄▄▄ ▄▄▄ ▄███",
19
- " ██▀█████▀█▄ ██",
20
- "█▀ ▀ ▀ ██ ██",
21
- "█ ██▄█ █▄▄ ██",
22
- "██ ▀ ████▄██",
23
- " ▀██▄▄██████▀"
24
- ];
25
- const navItems = [
26
- { id: "hot", label: "十大", hint: "热门话题" },
27
- { id: "favorite", label: "收藏", hint: "版面帖子" },
28
- { id: "new", label: "最新", hint: "新帖流" },
29
- { id: "boards", label: "版面", hint: "所有分区" },
30
- { id: "following", label: "关注", hint: "用户动态" },
31
- { id: "messages", 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: "清除本地登录信息" }
41
- ];
42
9
  export async function runTui() {
43
10
  const terminal = new Terminal();
44
11
  const tokenStore = new TokenStore();
45
- const client = new CachedCc98Client(new Cc98Client({ tokenStore }));
12
+ const vpnStore = new VpnStore();
13
+ // 读取 VPN 配置
14
+ const vpnConfig = await vpnStore.getConfig();
15
+ const webVpnOptions = vpnConfig.mode === "vpn" || vpnConfig.cookies
16
+ ? { mode: vpnConfig.mode, cookies: vpnConfig.cookies }
17
+ : undefined;
18
+ const cc98Client = new Cc98Client({ tokenStore, webVpn: webVpnOptions });
19
+ // 初始化 WebVPN(如果需要)
20
+ if (webVpnOptions) {
21
+ await cc98Client.initWebVpn();
22
+ }
23
+ const client = new CachedCc98Client(cc98Client);
24
+ const state = createInitialState();
46
25
  let exitRequested = false;
47
- const state = {
48
- mode: "list",
49
- focus: "nav",
50
- navIndex: 0,
51
- itemIndex: 0,
52
- scroll: 0,
53
- loading: true,
54
- loadingMore: false,
55
- status: "",
56
- viewTitle: "十大",
57
- items: [],
58
- stats: [],
59
- overview: [],
60
- modal: null,
61
- menuIndex: 0,
62
- menuItems: []
63
- };
64
26
  terminal.enter();
65
27
  try {
66
28
  await new Promise((resolve) => {
67
29
  let closed = false;
68
- let loadVersion = 0;
69
30
  let currentAbort;
31
+ const abortCurrent = () => currentAbort?.abort();
70
32
  const nextSignal = () => {
71
- currentAbort?.abort();
33
+ abortCurrent();
72
34
  currentAbort = new AbortController();
73
35
  return currentAbort.signal;
74
36
  };
75
- const render = () => terminal.render(draw(state, terminal.size()));
76
- const load = async (force = false) => {
77
- const version = ++loadVersion;
78
- const signal = nextSignal();
79
- const nav = navItems[state.navIndex];
80
- state.viewTitle = nav.label;
81
- state.loading = true;
82
- state.error = undefined;
83
- state.itemIndex = 0;
84
- state.scroll = 0;
85
- state.mode = nav.id === "settings" && state.mode === "settings" ? "settings" : "list";
86
- if (state.mode === "settings") {
87
- state.focus = "content";
88
- }
89
- state.items = [];
90
- state.stats = [];
91
- state.topic = undefined;
92
- state.parentList = undefined;
93
- state.currentBoard = undefined;
94
- state.currentChat = undefined;
95
- render();
96
- try {
97
- state.account = await tokenStore.getCurrentAccountName();
98
- const next = await loadView(client, nav.id, force, signal);
99
- if (closed || version !== loadVersion) {
100
- return;
101
- }
102
- state.viewTitle = next.title;
103
- state.items = next.items;
104
- state.stats = next.stats;
105
- if (next.overview) {
106
- state.overview = next.overview;
107
- }
108
- state.status = next.status ?? getStatus(state);
109
- }
110
- catch (error) {
111
- if (isAbortError(error)) {
112
- return;
113
- }
114
- if (closed || version !== loadVersion) {
115
- return;
116
- }
117
- state.error = error instanceof Error ? error.message : String(error);
118
- state.items = [];
119
- state.stats = [];
120
- }
121
- finally {
122
- if (!closed && version === loadVersion) {
123
- state.loading = false;
124
- render();
125
- }
37
+ const render = () => {
38
+ if (!closed) {
39
+ terminal.render(draw(state, terminal.size()));
126
40
  }
127
41
  };
128
42
  const close = () => {
129
- if (closed) {
43
+ if (closed)
130
44
  return;
131
- }
132
45
  closed = true;
133
46
  exitRequested = true;
134
- currentAbort?.abort();
47
+ abortCurrent();
135
48
  offKey();
136
49
  offResize();
137
50
  resolve();
138
51
  };
52
+ const controller = new TuiController(state, client, tokenStore, render, close, nextSignal, abortCurrent);
139
53
  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
- };
155
- const offKey = terminal.onKey((key) => {
156
- // Global: Ctrl+C or q to quit
157
- if (key === "\u0003" || key === "q") {
158
- close();
159
- return;
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
216
- if (state.mode === "topic") {
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") {
252
- currentAbort?.abort();
253
- state.mode = "list";
254
- state.focus = "content";
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);
262
- render();
263
- return;
264
- }
265
- if (key === "j" || key === "\x1b[B") {
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);
269
- render();
270
- if (wasAtEnd && state.topic?.hasMore && !state.loadingMore) {
271
- void loadNextTopicPage(client, state, render, nextSignal(), true);
272
- }
273
- return;
274
- }
275
- if (key === "k" || key === "\x1b[A") {
276
- state.scroll = Math.max(0, state.scroll - 1);
277
- render();
278
- return;
279
- }
280
- if (key === "n" || key === " ") {
281
- void loadNextTopicPage(client, state, render, nextSignal());
282
- return;
283
- }
284
- if (key === "r") {
285
- if (state.topic) {
286
- void openTopic(client, state, state.topic.topicId, render, true, nextSignal());
287
- }
288
- return;
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
- }
356
- return;
357
- }
358
- // Nav focus
359
- if (state.focus === "nav") {
360
- if (key === "j" || key === "\x1b[B") {
361
- state.navIndex = Math.min(navItems.length - 1, state.navIndex + 1);
362
- void load();
363
- return;
364
- }
365
- if (key === "k" || key === "\x1b[A") {
366
- state.navIndex = Math.max(0, state.navIndex - 1);
367
- void load();
368
- return;
369
- }
370
- if (key === "l" || key === "\x1b[C") {
371
- if (!state.loading && state.items.length > 0) {
372
- if (navItems[state.navIndex]?.id === "settings") {
373
- state.mode = "settings";
374
- }
375
- state.focus = "content";
376
- state.status = getStatus(state);
377
- render();
378
- }
379
- return;
380
- }
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
- }
391
- return;
392
- }
393
- if (key === "r") {
394
- void load(true);
395
- return;
396
- }
397
- return;
398
- }
399
- // Content focus
400
- if (key === "j" || key === "\x1b[B") {
401
- state.itemIndex = Math.min(Math.max(0, state.items.length - 1), state.itemIndex + 1);
402
- render();
403
- return;
404
- }
405
- if (key === "k" || key === "\x1b[A") {
406
- state.itemIndex = Math.max(0, state.itemIndex - 1);
407
- render();
408
- return;
409
- }
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
- }
436
- return;
437
- }
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 = "当前条目不可进入";
453
- render();
454
- return;
455
- }
456
- if (key === "\r") {
457
- const selected = state.items[state.itemIndex];
458
- if (selected?.topicId !== undefined) {
459
- void openTopic(client, state, selected.topicId, render, false, nextSignal());
460
- return;
461
- }
462
- if (selected?.boardId !== undefined) {
463
- void openBoard(client, state, selected.boardId, selected.title, render, false, nextSignal());
464
- return;
465
- }
466
- if (selected?.chatUserId !== undefined) {
467
- void openChat(client, state, selected.chatUserId, selected.title, render, false, nextSignal());
468
- return;
469
- }
470
- state.status = "当前条目不可进入";
471
- render();
472
- return;
473
- }
474
- if ((key === "n" || key === " ") && state.currentChat) {
475
- void loadNextChatPage(client, state, render, nextSignal());
476
- return;
477
- }
478
- if (key === "r") {
479
- if (state.currentBoard) {
480
- void openBoard(client, state, state.currentBoard.boardId, state.currentBoard.title, render, true, nextSignal(), false);
481
- return;
482
- }
483
- if (state.currentChat) {
484
- void openChat(client, state, state.currentChat.userId, state.currentChat.title, render, true, nextSignal(), false);
485
- return;
486
- }
487
- void load(true);
488
- return;
489
- }
490
- if (key === "o") {
491
- state.modal = "menu";
492
- state.menuItems = getMenuItems();
493
- state.menuIndex = 0;
494
- render();
495
- return;
496
- }
497
- });
54
+ const offKey = terminal.onKey((key) => controller.handleKey(key));
498
55
  render();
499
- void load();
56
+ void controller.load();
500
57
  });
501
58
  }
502
59
  finally {
@@ -507,1259 +64,4 @@ export async function runTui() {
507
64
  }
508
65
  }
509
66
  }
510
- async function openTopic(client, state, topicId, render, force = false, signal) {
511
- state.mode = "topic";
512
- state.loading = true;
513
- state.loadingMore = false;
514
- state.error = undefined;
515
- state.scroll = 0;
516
- state.topic = {
517
- topicId,
518
- title: `#${topicId}`,
519
- meta: "",
520
- lines: [],
521
- posts: [],
522
- loaded: 0,
523
- size: 10,
524
- hasMore: true,
525
- imageCount: 0,
526
- linkCount: 0,
527
- floorInput: ""
528
- };
529
- state.status = "正在打开帖子...";
530
- render();
531
- try {
532
- const [topicRaw, postsRaw] = await Promise.all([
533
- client.getTopic(topicId, force, signal),
534
- client.getTopicPosts(topicId, 0, 10, force, signal)
535
- ]);
536
- const topic = asObject(topicRaw);
537
- const posts = asArray(postsRaw);
538
- const reader = buildTopicReader(topicId, topic, posts, 10);
539
- state.topic = reader;
540
- state.viewTitle = reader.title;
541
- state.status = reader.hasMore
542
- ? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
543
- : "j/k 滚动 h/Esc 返回 r 刷新";
544
- }
545
- catch (error) {
546
- if (isAbortError(error)) {
547
- return;
548
- }
549
- state.error = error instanceof Error ? error.message : String(error);
550
- state.status = state.parentList
551
- ? "版面读取失败;Esc/Backspace 返回版面列表 h 返回左栏 r 重试"
552
- : "版面读取失败;h 返回左栏 r 重试";
553
- }
554
- finally {
555
- state.loading = false;
556
- render();
557
- }
558
- }
559
- async function openBoard(client, state, boardId, boardTitle, render, force = false, signal, pushParent = true) {
560
- if (pushParent) {
561
- state.parentList = {
562
- title: state.viewTitle,
563
- items: state.items,
564
- stats: state.stats,
565
- itemIndex: state.itemIndex,
566
- status: state.status
567
- };
568
- }
569
- state.mode = "list";
570
- state.focus = "content";
571
- state.loading = true;
572
- state.error = undefined;
573
- state.itemIndex = 0;
574
- state.scroll = 0;
575
- state.topic = undefined;
576
- state.currentChat = undefined;
577
- state.currentBoard = { boardId, title: boardTitle };
578
- state.viewTitle = boardTitle;
579
- state.items = [];
580
- state.stats = [
581
- { title: "版面", detail: `#${boardId}` },
582
- { title: "缓存", detail: "topics 30s" }
583
- ];
584
- state.status = "正在读取版面帖子...";
585
- render();
586
- try {
587
- const topics = asArray(await client.getBoardTopics(boardId, 0, 12, false, force, signal));
588
- state.items = topics.map((topic) => topicItem(topic));
589
- state.stats = [
590
- { title: "版面", detail: `#${boardId}` },
591
- { title: "主题", detail: `${topics.length} 条` },
592
- { title: "缓存", detail: "topics 30s" }
593
- ];
594
- state.status = "版面帖子:j/k 选择 l 打开帖子 h 返回 r 刷新";
595
- }
596
- catch (error) {
597
- if (isAbortError(error)) {
598
- return;
599
- }
600
- state.error = error instanceof Error ? error.message : String(error);
601
- }
602
- finally {
603
- state.loading = false;
604
- render();
605
- }
606
- }
607
- async function openChat(client, state, userId, title, render, force = false, signal, pushParent = true) {
608
- if (pushParent) {
609
- state.parentList = {
610
- title: state.viewTitle,
611
- items: state.items,
612
- stats: state.stats,
613
- itemIndex: state.itemIndex,
614
- status: state.status
615
- };
616
- }
617
- state.mode = "list";
618
- state.focus = "content";
619
- state.loading = true;
620
- state.error = undefined;
621
- state.itemIndex = 0;
622
- state.scroll = 0;
623
- state.topic = undefined;
624
- state.currentBoard = undefined;
625
- state.currentChat = { userId, title, loaded: 0, size: 10, hasMore: true };
626
- state.viewTitle = title;
627
- state.items = [];
628
- state.stats = [
629
- { title: "用户", detail: `#${userId}` },
630
- { title: "缓存", detail: "history 15s" }
631
- ];
632
- state.status = "正在读取私信...";
633
- render();
634
- try {
635
- const messages = asArray(await client.getChatHistory(userId, 0, 10, force, signal));
636
- state.items = chatMessageItems(messages, title, userId);
637
- state.currentChat.loaded = messages.length;
638
- state.currentChat.hasMore = messages.length === state.currentChat.size;
639
- state.itemIndex = Math.max(0, state.items.length - 1);
640
- state.stats = [
641
- { title: "用户", detail: `#${userId}` },
642
- { title: "消息", detail: `${messages.length} 条` },
643
- { title: "缓存", detail: "history 15s" }
644
- ];
645
- state.status = state.currentChat.hasMore
646
- ? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
647
- : "私信:j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
648
- }
649
- catch (error) {
650
- if (isAbortError(error)) {
651
- return;
652
- }
653
- state.error = error instanceof Error ? error.message : String(error);
654
- state.status = "私信读取失败;Esc/Backspace 返回联系人 h 返回左栏 r 重试";
655
- }
656
- finally {
657
- state.loading = false;
658
- render();
659
- }
660
- }
661
- async function loadNextChatPage(client, state, render, signal) {
662
- if (!state.currentChat || state.loadingMore || !state.currentChat.hasMore) {
663
- return;
664
- }
665
- state.loadingMore = true;
666
- state.status = "正在读取更早私信...";
667
- render();
668
- try {
669
- const chat = state.currentChat;
670
- const messages = asArray(await client.getChatHistory(chat.userId, chat.loaded, chat.size, false, signal));
671
- const olderItems = chatMessageItems(messages, chat.title, chat.userId);
672
- state.items = [...olderItems, ...state.items];
673
- state.itemIndex += olderItems.length;
674
- state.scroll += olderItems.length;
675
- chat.loaded += messages.length;
676
- chat.hasMore = messages.length === chat.size;
677
- state.stats = [
678
- { title: "用户", detail: `#${chat.userId}` },
679
- { title: "消息", detail: `${chat.loaded} 条` },
680
- { title: "缓存", detail: "history 15s" }
681
- ];
682
- state.status = chat.hasMore
683
- ? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
684
- : "已到最早私信;j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
685
- }
686
- catch (error) {
687
- if (isAbortError(error)) {
688
- return;
689
- }
690
- state.error = error instanceof Error ? error.message : String(error);
691
- state.status = "更早私信读取失败;n/Space 重试 Esc/Backspace 返回联系人";
692
- }
693
- finally {
694
- state.loadingMore = false;
695
- render();
696
- }
697
- }
698
- function restoreParentList(state) {
699
- if (!state.parentList) {
700
- return;
701
- }
702
- const parent = state.parentList;
703
- state.mode = "list";
704
- state.focus = "content";
705
- state.loading = false;
706
- state.loadingMore = false;
707
- state.error = undefined;
708
- state.topic = undefined;
709
- state.currentBoard = undefined;
710
- state.currentChat = undefined;
711
- state.parentList = undefined;
712
- state.viewTitle = parent.title;
713
- state.items = parent.items;
714
- state.stats = parent.stats;
715
- state.itemIndex = parent.itemIndex;
716
- state.status = parent.status;
717
- }
718
- async function loadNextTopicPage(client, state, render, signal, advanceAfterLoad = false) {
719
- if (!state.topic || state.loadingMore || !state.topic.hasMore) {
720
- return;
721
- }
722
- state.loadingMore = true;
723
- state.status = "正在加载下一页...";
724
- render();
725
- try {
726
- const posts = asArray(await client.getTopicPosts(state.topic.topicId, state.topic.loaded, state.topic.size, false, signal));
727
- const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), state.topic.lines.length);
728
- state.topic.lines.push(...next.lines);
729
- state.topic.posts.push(...next.posts);
730
- state.topic.imageCount += next.imageCount;
731
- state.topic.linkCount += next.linkCount;
732
- state.topic.loaded += posts.length;
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
- }
737
- state.status = state.topic.hasMore
738
- ? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
739
- : "已到最后一页 j/k 滚动 h/Esc 返回 r 刷新";
740
- }
741
- catch (error) {
742
- if (isAbortError(error)) {
743
- return;
744
- }
745
- state.error = error instanceof Error ? error.message : String(error);
746
- }
747
- finally {
748
- state.loadingMore = false;
749
- render();
750
- }
751
- }
752
- function currentTopicWidthEstimate() {
753
- return Number(process.env.COLUMNS) > 90 ? 56 : 44;
754
- }
755
- function buildTopicReader(topicId, topic, posts, size) {
756
- const title = String(topic.title ?? `#${topicId}`);
757
- const meta = [
758
- topic.userName,
759
- topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
760
- topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
761
- ].filter(Boolean).join(" · ");
762
- const rendered = renderPosts(posts, currentTopicWidthEstimate());
763
- return {
764
- topicId,
765
- title,
766
- meta,
767
- lines: rendered.lines,
768
- posts: rendered.posts,
769
- loaded: posts.length,
770
- size,
771
- hasMore: posts.length === size,
772
- imageCount: rendered.imageCount,
773
- linkCount: rendered.linkCount,
774
- floorInput: ""
775
- };
776
- }
777
- function renderPosts(posts, width, lineOffset = 0) {
778
- const lines = [];
779
- const entries = [];
780
- let imageCount = 0;
781
- let linkCount = 0;
782
- posts.forEach((postRaw) => {
783
- const post = asObject(postRaw);
784
- const lineStart = lineOffset + lines.length;
785
- const postLines = [];
786
- const floorNumber = asNumber(post.floor);
787
- const floor = floorNumber !== undefined ? `#${floorNumber}` : "#?";
788
- const author = String(post.userName ?? "匿名");
789
- const time = typeof post.time === "string" ? post.time.replace("T", " ").slice(0, 16) : "";
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");
807
- const content = typeof post.content === "string" ? post.content : "";
808
- const rendered = renderUbbToLines(content, width);
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
- });
849
- imageCount += rendered.images.length;
850
- linkCount += rendered.links.length;
851
- });
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;
959
- }
960
- async function loadView(client, view, force, signal) {
961
- switch (view) {
962
- case "hot": {
963
- const [index, unread] = await Promise.all([
964
- client.getForumIndex(force, signal),
965
- client.getUnreadCount(force, signal)
966
- ]);
967
- const indexObject = asObject(index);
968
- const unreadObject = asObject(unread);
969
- const hotTopics = asArray(indexObject.hotTopic ?? indexObject.manualHotTopic);
970
- return {
971
- title: "十大",
972
- items: hotTopics.map((topic) => topicItem(topic)),
973
- stats: unreadStats(unreadObject),
974
- overview: overviewStats(indexObject, unreadObject)
975
- };
976
- }
977
- case "new": {
978
- const topics = asArray(await client.getNewTopics(0, 12, force, signal));
979
- return {
980
- title: "最新",
981
- items: topics.map((topic) => topicItem(topic)),
982
- stats: [{ title: "新帖流", detail: `${topics.length} 条` }]
983
- };
984
- }
985
- case "boards": {
986
- const sections = asArray(await client.getAllBoards(force, signal));
987
- const boards = flattenBoards(sections).slice(0, 14);
988
- return {
989
- title: "版面",
990
- items: boards,
991
- stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${flattenBoards(sections).length}` }],
992
- status: "版面:j/k 选择 l 进入版面 h 返回 r 刷新"
993
- };
994
- }
995
- case "following": {
996
- const topics = asArray(await client.getFolloweeTopics(0, 12, force, signal));
997
- return {
998
- title: "关注",
999
- items: topics.map((topic) => topicItem(topic)),
1000
- stats: [
1001
- { title: "关注动态", detail: `${topics.length} 条` },
1002
- { title: "缓存", detail: "30s" }
1003
- ],
1004
- status: "关注:j/k 选择 l 打开帖子 h 返回 r 刷新"
1005
- };
1006
- }
1007
- case "favorite": {
1008
- const [meRaw, sectionsRaw] = await Promise.all([
1009
- client.getMe(force, signal),
1010
- client.getAllBoards(false, signal)
1011
- ]);
1012
- const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
1013
- const allBoards = flattenBoards(asArray(sectionsRaw));
1014
- const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
1015
- const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
1016
- const board = boardById.get(boardId);
1017
- const topics = asArray(await client.getBoardTopics(boardId, 0, 3, false, force, signal));
1018
- return topics.map((topic) => topicItem(topic, board));
1019
- });
1020
- const items = topicGroups.flat().sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0)).slice(0, 18);
1021
- return {
1022
- title: "收藏",
1023
- items,
1024
- stats: [
1025
- { title: "收藏版面", detail: `${customBoards.length} 个` },
1026
- { title: "主题", detail: `${items.length} 条` },
1027
- { title: "缓存", detail: "boards 24h / topics 30s" }
1028
- ],
1029
- status: "收藏:j/k 选择 l 打开帖子 h 返回 r 刷新"
1030
- };
1031
- }
1032
- case "messages": {
1033
- const [unread, recent] = await Promise.all([
1034
- client.getUnreadCount(force, signal),
1035
- client.getRecentChats(0, 10, force, signal)
1036
- ]);
1037
- const unreadObject = asObject(unread);
1038
- const chats = asArray(recent);
1039
- const userNames = await loadChatUserNames(client, chats, force, signal);
1040
- return {
1041
- title: "消息",
1042
- items: chats.length > 0 ? chats.map((chat) => chatItem(chat, userNames)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
1043
- stats: unreadStats(unreadObject),
1044
- status: "消息:j/k 选择 l 打开会话 h 返回 r 刷新"
1045
- };
1046
- }
1047
- case "me": {
1048
- const [me, cacheStats] = await Promise.all([
1049
- client.getMe(force, signal),
1050
- client.getCacheStats()
1051
- ]);
1052
- const meObject = asObject(me);
1053
- return {
1054
- title: "我的",
1055
- items: [
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)
1063
- ],
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 返回"
1079
- };
1080
- }
1081
- }
1082
- }
1083
- function draw(state, size) {
1084
- const width = Math.max(60, size.columns);
1085
- const height = Math.max(20, size.rows);
1086
- const sidebarWidth = width < 90 ? 14 : 18;
1087
- const rightWidth = width < 78 ? 0 : Math.min(42, Math.max(34, Math.floor(width * 0.30)));
1088
- const mainWidth = width - sidebarWidth - rightWidth - (rightWidth > 0 ? 2 : 1);
1089
- const overviewHeight = height < 24 ? 1 : 2;
1090
- const bodyHeight = height - 4 - overviewHeight;
1091
- const lines = [];
1092
- lines.push(header(width, state));
1093
- lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1094
- lines.push(...drawOverview(state, width, overviewHeight));
1095
- const sidebar = drawSidebar(state, sidebarWidth, bodyHeight);
1096
- const main = drawMain(state, mainWidth, bodyHeight);
1097
- const right = rightWidth > 0 ? drawRight(state, rightWidth, bodyHeight) : [];
1098
- for (let row = 0; row < bodyHeight; row += 1) {
1099
- const parts = [
1100
- fit(sidebar[row] ?? "", sidebarWidth),
1101
- `${line}│${ansi.reset}`,
1102
- fit(main[row] ?? "", mainWidth)
1103
- ];
1104
- if (rightWidth > 0) {
1105
- parts.push(`${line}│${ansi.reset}`, fit(right[row] ?? "", rightWidth));
1106
- }
1107
- lines.push(parts.join(""));
1108
- }
1109
- lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
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
- }
1118
- return lines.slice(0, height).join("\n");
1119
- }
1120
- function header(width, state) {
1121
- const account = state.account ? `@${state.account}` : "未登录";
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}`;
1125
- }
1126
- function drawOverview(state, width, height) {
1127
- const rows = [];
1128
- const summary = state.overview.length > 0
1129
- ? state.overview.map((entry) => `${entry.title} ${entry.detail ?? "-"}`).join(" ")
1130
- : "全站概览会在读取十大时更新";
1131
- rows.push(fit(`${cc98BlueSoft} ${summary}${ansi.reset}`, width));
1132
- if (height > 1) {
1133
- rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
1134
- }
1135
- return rows.slice(0, height);
1136
- }
1137
- function drawSidebar(state, width, height) {
1138
- const rows = [];
1139
- for (let index = 0; index < height; index += 1) {
1140
- const nav = navItems[index];
1141
- if (!nav) {
1142
- rows.push(" ".repeat(width));
1143
- continue;
1144
- }
1145
- const active = index === state.navIndex;
1146
- const focused = state.focus === "nav";
1147
- const label = ` ${nav.label}`;
1148
- const hint = width > 16 ? ` ${nav.hint}` : "";
1149
- const text = fit(`${label}${hint}`, width);
1150
- if (active && focused) {
1151
- rows.push(`${bg(0, 130, 202)}${white}${text}${ansi.reset}`);
1152
- }
1153
- else if (active) {
1154
- rows.push(`${bg(5, 46, 74)}${cc98BlueSoft}${text}${ansi.reset}`);
1155
- }
1156
- else {
1157
- rows.push(`${cc98Blue}${label}${ansi.reset}${muted}${fit(hint, Math.max(0, width - cellWidth(label)))}${ansi.reset}`);
1158
- }
1159
- }
1160
- return rows;
1161
- }
1162
- function drawMain(state, width, height) {
1163
- if (state.mode === "topic") {
1164
- return drawTopic(state, width, height);
1165
- }
1166
- if (state.loading) {
1167
- return [
1168
- `${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
1169
- fit(`${muted} 正在加载...${ansi.reset}`, width),
1170
- `${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
1171
- `${muted} ${"· ".repeat(Math.max(1, Math.floor((width - 2) / 2))).slice(0, width - 1)}${ansi.reset}`
1172
- ].concat(blank(height - 4, width)).slice(0, height);
1173
- }
1174
- if (state.error) {
1175
- return [
1176
- `${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
1177
- `${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
1178
- `${danger} 请求失败${ansi.reset}`,
1179
- fit(` ${state.error}`, width)
1180
- ].concat(blank(height - 4, width)).slice(0, height);
1181
- }
1182
- const rows = [];
1183
- rows.push(`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`);
1184
- rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
1185
- const visibleCapacity = Math.max(1, Math.floor(Math.max(1, height - 3) / 3));
1186
- if (state.itemIndex < state.scroll) {
1187
- state.scroll = state.itemIndex;
1188
- }
1189
- else if (state.itemIndex >= state.scroll + visibleCapacity) {
1190
- state.scroll = state.itemIndex - visibleCapacity + 1;
1191
- }
1192
- const visible = state.items.slice(state.scroll);
1193
- visible.forEach((itemValue, offset) => {
1194
- if (rows.length >= height) {
1195
- return;
1196
- }
1197
- const index = state.scroll + offset;
1198
- const active = index === state.itemIndex && (state.focus === "content" || state.mode === "settings");
1199
- const prefix = active ? `${ok}●${ansi.reset}` : `${muted}•${ansi.reset}`;
1200
- const title = fit(` ${itemValue.title}`, Math.max(10, width - 2));
1201
- rows.push(active ? `${bg(5, 46, 74)}${prefix}${title}${ansi.reset}` : fit(`${prefix}${title}`, width));
1202
- if (itemValue.meta && rows.length < height) {
1203
- rows.push(fit(` ${muted}${itemValue.meta}${ansi.reset}`, width));
1204
- }
1205
- // Note: detail is shown in right panel, not here
1206
- });
1207
- if (visible.length === 0) {
1208
- rows.push(`${muted} 暂无数据${ansi.reset}`);
1209
- }
1210
- if (state.scroll + visibleCapacity < state.items.length && rows.length < height) {
1211
- rows.push(fit(`${muted} ↓ 还有 ${state.items.length - state.scroll - visibleCapacity} 项${ansi.reset}`, width));
1212
- }
1213
- return rows.concat(blank(height - rows.length, width)).slice(0, height);
1214
- }
1215
- function drawTopic(state, width, height) {
1216
- if (state.loading && (!state.topic || state.topic.lines.length === 0)) {
1217
- return [
1218
- `${cc98Blue} 正在打开帖子...${ansi.reset}`,
1219
- "",
1220
- `${muted} 只加载第一页,不预取未读楼层。${ansi.reset}`
1221
- ].concat(blank(height - 3, width)).slice(0, height);
1222
- }
1223
- if (state.error) {
1224
- return [
1225
- `${danger} 读取帖子失败${ansi.reset}`,
1226
- fit(` ${state.error}`, width),
1227
- "",
1228
- `${muted} h/Esc 返回列表${ansi.reset}`
1229
- ].concat(blank(height - 4, width)).slice(0, height);
1230
- }
1231
- const topic = state.topic;
1232
- if (!topic) {
1233
- return blank(height, width);
1234
- }
1235
- const rows = [];
1236
- rows.push(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`);
1237
- rows.push(fit(`${muted} ${topic.meta}${ansi.reset}`, width));
1238
- rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
1239
- const viewport = Math.max(0, height - rows.length - 1);
1240
- const maxScroll = Math.max(0, topic.lines.length - viewport);
1241
- state.scroll = Math.min(state.scroll, maxScroll);
1242
- const body = topic.lines.slice(state.scroll, state.scroll + viewport);
1243
- for (const bodyLine of body) {
1244
- if (bodyLine.startsWith("[image ")) {
1245
- rows.push(fit(`${cc98BlueSoft}${bodyLine}${ansi.reset}`, width));
1246
- }
1247
- else if (bodyLine.startsWith("│ ")) {
1248
- rows.push(fit(`${muted}${bodyLine}${ansi.reset}`, width));
1249
- }
1250
- else if (/^#\d+ /.test(bodyLine)) {
1251
- rows.push(fit(`${ok}${bodyLine}${ansi.reset}`, width));
1252
- }
1253
- else {
1254
- rows.push(fit(` ${bodyLine}`, width));
1255
- }
1256
- }
1257
- const pageInfo = topic.hasMore
1258
- ? `已载入 ${topic.loaded} 楼,n 下一页`
1259
- : `已载入 ${topic.loaded} 楼,已到底`;
1260
- rows.push(fit(`${muted}${pageInfo}${state.loadingMore ? " · 加载中" : ""}${ansi.reset}`, width));
1261
- return rows.concat(blank(height - rows.length, width)).slice(0, height);
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
- }
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) {
1377
- const rows = [];
1378
- const nav = navItems[state.navIndex];
1379
- rows.push(...mascotMini.map((row) => fit(`${white}${row}${ansi.reset}`, width)));
1380
- rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
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
- });
1400
- }
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
- }
1482
- }
1483
- rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
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));
1505
- }
1506
- return rows.concat(blank(height - rows.length, width)).slice(0, height);
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
- }
1529
- function item(title, value, meta) {
1530
- return {
1531
- title,
1532
- meta,
1533
- detail: value === undefined || value === null ? "-" : String(value)
1534
- };
1535
- }
1536
- function topicItem(value, fallbackBoard) {
1537
- const topic = asObject(value);
1538
- const topicId = asNumber(topic.id ?? topic.Id);
1539
- const boardId = asNumber(topic.boardId ?? topic.BoardId) ?? fallbackBoard?.boardId;
1540
- const boardName = topic.boardName ?? topic.BoardName ?? fallbackBoard?.title;
1541
- return {
1542
- title: String(topic.title ?? topic.Title ?? `#${topicId ?? ""}`),
1543
- meta: [
1544
- boardName,
1545
- topic.userName ?? topic.authorName,
1546
- topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
1547
- topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
1548
- ]
1549
- .filter(Boolean)
1550
- .join(" · "),
1551
- detail: typeof topic.lastPostContent === "string" ? topic.lastPostContent.replace(/\s+/g, " ") : undefined,
1552
- topicId,
1553
- boardId,
1554
- sortTime: timestampOf(topic.lastPostTime ?? topic.updateTime ?? topic.time ?? topic.createTime)
1555
- };
1556
- }
1557
- async function loadChatUserNames(client, chats, force, signal) {
1558
- const ids = chats
1559
- .map((chat) => asNumber(asObject(chat).userId ?? asObject(chat).UserId))
1560
- .filter((id) => id !== undefined);
1561
- const users = asArray(await client.getBasicUsers(ids, force, signal));
1562
- return new Map(users.map((userRaw) => {
1563
- const user = asObject(userRaw);
1564
- const id = asNumber(user.id ?? user.Id);
1565
- const name = String(user.name ?? user.Name ?? (id !== undefined ? `#${id}` : "用户"));
1566
- return [id, name];
1567
- }).filter((entry) => entry[0] !== undefined));
1568
- }
1569
- function chatItem(value, userNames) {
1570
- const chat = asObject(value);
1571
- const userId = asNumber(chat.userId ?? chat.UserId);
1572
- const name = userId !== undefined ? userNames.get(userId) : undefined;
1573
- return {
1574
- title: String(name ?? chat.name ?? chat.userName ?? userId ?? "私信"),
1575
- meta: userId !== undefined ? `user #${userId}` : undefined,
1576
- detail: normalizeInline(String(chat.lastContent ?? chat.lastMessage ?? chat.content ?? "")),
1577
- chatUserId: userId
1578
- };
1579
- }
1580
- function chatMessageItems(messages, otherName, otherUserId) {
1581
- return [...messages].reverse().map((messageRaw) => {
1582
- const message = asObject(messageRaw);
1583
- const receiverId = asNumber(message.receiverId ?? message.ReceiverId);
1584
- const isMine = receiverId === otherUserId;
1585
- const time = typeof message.time === "string"
1586
- ? message.time.replace("T", " ").slice(0, 16)
1587
- : "";
1588
- const content = normalizeInline(String(message.content ?? message.Content ?? ""));
1589
- return {
1590
- title: isMine ? `我 -> ${otherName}` : `${otherName} -> 我`,
1591
- meta: [time, receiverId !== undefined ? `receiver #${receiverId}` : undefined].filter(Boolean).join(" · "),
1592
- detail: content || "(空消息)"
1593
- };
1594
- });
1595
- }
1596
- function unreadStats(value) {
1597
- return [
1598
- item("系统", value.systemCount),
1599
- item("@", value.atCount),
1600
- item("回复", value.replyCount),
1601
- item("私信", value.messageCount)
1602
- ];
1603
- }
1604
- function overviewStats(index, unread) {
1605
- const unreadTotal = ["systemCount", "atCount", "replyCount", "messageCount"].reduce((total, key) => {
1606
- const value = unread[key];
1607
- return total + (typeof value === "number" ? value : 0);
1608
- }, 0);
1609
- return [
1610
- item("今日主题", index.todayTopicCount),
1611
- item("今日回复", index.todayCount),
1612
- item("在线", index.onlineUserCount),
1613
- item("用户", index.userCount),
1614
- item("未读", unreadTotal)
1615
- ];
1616
- }
1617
- async function mapLimit(values, limit, mapper) {
1618
- const results = [];
1619
- let nextIndex = 0;
1620
- const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
1621
- while (nextIndex < values.length) {
1622
- const index = nextIndex;
1623
- nextIndex += 1;
1624
- results[index] = await mapper(values[index]);
1625
- }
1626
- });
1627
- await Promise.all(workers);
1628
- return results;
1629
- }
1630
- function getStatus(state) {
1631
- // Left part: context status
1632
- let left = "";
1633
- if (state.loading) {
1634
- left = "加载中...";
1635
- }
1636
- else if (state.loadingMore) {
1637
- left = "加载更多...";
1638
- }
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} 项`;
1656
- }
1657
- return left;
1658
- }
1659
- function flattenBoards(sections) {
1660
- const boards = [];
1661
- for (const section of sections) {
1662
- const sectionObject = asObject(section);
1663
- const sectionName = String(sectionObject.name ?? sectionObject.title ?? "分区");
1664
- const candidates = [sectionObject.boards, sectionObject.children, sectionObject.boardList];
1665
- for (const candidate of candidates) {
1666
- if (!Array.isArray(candidate)) {
1667
- continue;
1668
- }
1669
- for (const board of candidate) {
1670
- const boardObject = asObject(board);
1671
- boards.push({
1672
- title: String(boardObject.name ?? boardObject.title ?? `#${boardObject.id ?? ""}`),
1673
- meta: `${sectionName}${boardObject.id !== undefined ? ` · #${boardObject.id}` : ""}`,
1674
- detail: typeof boardObject.description === "string" ? boardObject.description : undefined,
1675
- boardId: typeof boardObject.id === "number" ? boardObject.id : undefined
1676
- });
1677
- }
1678
- }
1679
- }
1680
- return boards;
1681
- }
1682
- function asObject(value) {
1683
- return typeof value === "object" && value !== null ? value : {};
1684
- }
1685
- function asArray(value) {
1686
- return Array.isArray(value) ? value : [];
1687
- }
1688
- function asNumber(value) {
1689
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1690
- }
1691
- function normalizeInline(value) {
1692
- return value.replace(/\s+/g, " ").trim();
1693
- }
1694
- function timestampOf(value) {
1695
- if (typeof value !== "string" && typeof value !== "number") {
1696
- return undefined;
1697
- }
1698
- const timestamp = new Date(value).getTime();
1699
- return Number.isFinite(timestamp) ? timestamp : undefined;
1700
- }
1701
- function isAbortError(error) {
1702
- return error instanceof Error && error.name === "AbortError";
1703
- }
1704
- function blank(count, width) {
1705
- return Array.from({ length: Math.max(0, count) }, () => " ".repeat(width));
1706
- }
1707
- function fit(value, width) {
1708
- const truncated = truncate(value, width);
1709
- return `${truncated}${" ".repeat(Math.max(0, width - cellWidth(truncated)))}`;
1710
- }
1711
- function truncate(value, width) {
1712
- let out = "";
1713
- let used = 0;
1714
- let inEscape = false;
1715
- for (let index = 0; index < value.length; index += 1) {
1716
- const char = value[index];
1717
- if (char === "\x1b") {
1718
- inEscape = true;
1719
- out += char;
1720
- continue;
1721
- }
1722
- if (inEscape) {
1723
- out += char;
1724
- if (/[A-Za-z]/.test(char)) {
1725
- inEscape = false;
1726
- }
1727
- continue;
1728
- }
1729
- const charWidth = charCellWidth(char);
1730
- if (used + charWidth > width) {
1731
- break;
1732
- }
1733
- out += char;
1734
- used += charWidth;
1735
- }
1736
- return out;
1737
- }
1738
- function cellWidth(value) {
1739
- let width = 0;
1740
- for (const char of stripAnsi(value)) {
1741
- width += charCellWidth(char);
1742
- }
1743
- return width;
1744
- }
1745
- function charCellWidth(char) {
1746
- const code = char.codePointAt(0) ?? 0;
1747
- if (code === 0) {
1748
- return 0;
1749
- }
1750
- if (code >= 0x1100 &&
1751
- (code <= 0x115f ||
1752
- code === 0x2329 ||
1753
- code === 0x232a ||
1754
- (code >= 0x2e80 && code <= 0xa4cf) ||
1755
- (code >= 0xac00 && code <= 0xd7a3) ||
1756
- (code >= 0xf900 && code <= 0xfaff) ||
1757
- (code >= 0xfe10 && code <= 0xfe19) ||
1758
- (code >= 0xfe30 && code <= 0xfe6f) ||
1759
- (code >= 0xff00 && code <= 0xff60) ||
1760
- (code >= 0xffe0 && code <= 0xffe6))) {
1761
- return 2;
1762
- }
1763
- return 1;
1764
- }
1765
67
  //# sourceMappingURL=app.js.map