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