cc98-cli 0.1.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/LICENSE +21 -0
- package/README.md +246 -0
- package/dist/api/client.d.ts +47 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +188 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/endpoints.d.ts +48 -0
- package/dist/api/endpoints.d.ts.map +1 -0
- package/dist/api/endpoints.js +53 -0
- package/dist/api/endpoints.js.map +1 -0
- package/dist/api/types.d.ts +14 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/cli/commands/account.d.ts +2 -0
- package/dist/cli/commands/account.d.ts.map +1 -0
- package/dist/cli/commands/account.js +81 -0
- package/dist/cli/commands/account.js.map +1 -0
- package/dist/cli/commands/board.d.ts +2 -0
- package/dist/cli/commands/board.d.ts.map +1 -0
- package/dist/cli/commands/board.js +61 -0
- package/dist/cli/commands/board.js.map +1 -0
- package/dist/cli/commands/forum.d.ts +2 -0
- package/dist/cli/commands/forum.d.ts.map +1 -0
- package/dist/cli/commands/forum.js +38 -0
- package/dist/cli/commands/forum.js.map +1 -0
- package/dist/cli/commands/login.d.ts +2 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +98 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/logout.d.ts +2 -0
- package/dist/cli/commands/logout.d.ts.map +1 -0
- package/dist/cli/commands/logout.js +20 -0
- package/dist/cli/commands/logout.js.map +1 -0
- package/dist/cli/commands/me.d.ts +2 -0
- package/dist/cli/commands/me.d.ts.map +1 -0
- package/dist/cli/commands/me.js +9 -0
- package/dist/cli/commands/me.js.map +1 -0
- package/dist/cli/commands/message.d.ts +2 -0
- package/dist/cli/commands/message.d.ts.map +1 -0
- package/dist/cli/commands/message.js +42 -0
- package/dist/cli/commands/message.js.map +1 -0
- package/dist/cli/commands/notice.d.ts +2 -0
- package/dist/cli/commands/notice.d.ts.map +1 -0
- package/dist/cli/commands/notice.js +30 -0
- package/dist/cli/commands/notice.js.map +1 -0
- package/dist/cli/commands/post.d.ts +2 -0
- package/dist/cli/commands/post.d.ts.map +1 -0
- package/dist/cli/commands/post.js +37 -0
- package/dist/cli/commands/post.js.map +1 -0
- package/dist/cli/commands/search.d.ts +2 -0
- package/dist/cli/commands/search.d.ts.map +1 -0
- package/dist/cli/commands/search.js +13 -0
- package/dist/cli/commands/search.js.map +1 -0
- package/dist/cli/commands/topic.d.ts +2 -0
- package/dist/cli/commands/topic.d.ts.map +1 -0
- package/dist/cli/commands/topic.js +196 -0
- package/dist/cli/commands/topic.js.map +1 -0
- package/dist/cli/commands/user.d.ts +2 -0
- package/dist/cli/commands/user.d.ts.map +1 -0
- package/dist/cli/commands/user.js +90 -0
- package/dist/cli/commands/user.js.map +1 -0
- package/dist/cli/context.d.ts +9 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +8 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/options.d.ts +6 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/cli/options.js +22 -0
- package/dist/cli/options.js.map +1 -0
- package/dist/cli/parse.d.ts +15 -0
- package/dist/cli/parse.d.ts.map +1 -0
- package/dist/cli/parse.js +55 -0
- package/dist/cli/parse.js.map +1 -0
- package/dist/cli/prompt.d.ts +4 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +53 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/router.d.ts +2 -0
- package/dist/cli/router.d.ts.map +1 -0
- package/dist/cli/router.js +77 -0
- package/dist/cli/router.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -0
- package/dist/storage/cache-store.d.ts +19 -0
- package/dist/storage/cache-store.d.ts.map +1 -0
- package/dist/storage/cache-store.js +82 -0
- package/dist/storage/cache-store.js.map +1 -0
- package/dist/storage/paths.d.ts +4 -0
- package/dist/storage/paths.d.ts.map +1 -0
- package/dist/storage/paths.js +14 -0
- package/dist/storage/paths.js.map +1 -0
- package/dist/storage/token-store.d.ts +39 -0
- package/dist/storage/token-store.d.ts.map +1 -0
- package/dist/storage/token-store.js +177 -0
- package/dist/storage/token-store.js.map +1 -0
- package/dist/tui/ansi.d.ts +16 -0
- package/dist/tui/ansi.d.ts.map +1 -0
- package/dist/tui/ansi.js +24 -0
- package/dist/tui/ansi.js.map +1 -0
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +1096 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/cached-client.d.ts +22 -0
- package/dist/tui/cached-client.d.ts.map +1 -0
- package/dist/tui/cached-client.js +60 -0
- package/dist/tui/cached-client.js.map +1 -0
- package/dist/tui/terminal.d.ts +21 -0
- package/dist/tui/terminal.d.ts.map +1 -0
- package/dist/tui/terminal.js +67 -0
- package/dist/tui/terminal.js.map +1 -0
- package/dist/tui/ubb-renderer.d.ts +7 -0
- package/dist/tui/ubb-renderer.d.ts.map +1 -0
- package/dist/tui/ubb-renderer.js +102 -0
- package/dist/tui/ubb-renderer.js.map +1 -0
- package/package.json +39 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import { Cc98Client } from "../api/client.js";
|
|
2
|
+
import { TokenStore } from "../storage/token-store.js";
|
|
3
|
+
import { ansi, bg, fg, stripAnsi } from "./ansi.js";
|
|
4
|
+
import { CachedCc98Client } from "./cached-client.js";
|
|
5
|
+
import { Terminal } from "./terminal.js";
|
|
6
|
+
import { renderUbbToLines } from "./ubb-renderer.js";
|
|
7
|
+
const cc98Blue = fg(0, 130, 202);
|
|
8
|
+
const cc98BlueSoft = fg(94, 180, 232);
|
|
9
|
+
const cc98BlueBg = bg(0, 104, 176);
|
|
10
|
+
const white = fg(245, 250, 255);
|
|
11
|
+
const muted = fg(139, 152, 166);
|
|
12
|
+
const line = fg(52, 84, 112);
|
|
13
|
+
const danger = fg(245, 101, 101);
|
|
14
|
+
const ok = fg(91, 207, 140);
|
|
15
|
+
const mascot = [
|
|
16
|
+
" ▄▄▄ ▄▄▄ ▄████▄███▄▄",
|
|
17
|
+
" ███▀█▄▄▄██▀██ ▀██ ██▄ ██▄▄▄",
|
|
18
|
+
" ▄██ ▀▀▀▀▀ ▀██▄ ████▀▀▀▀▀▀▀▀███▄",
|
|
19
|
+
" █▀ ██▄██▀ ▀██▄",
|
|
20
|
+
" █▀ ███▀ ▀██▄",
|
|
21
|
+
"██ ██ ██ ██ ████ ███",
|
|
22
|
+
"██ ▄██▀ ▀▀ ▄▄█ ██ ▀ ▀ ███",
|
|
23
|
+
"██▄ ▀ ▀▀ ██ ▄███",
|
|
24
|
+
" ▀██▄▄ ███▄▄ ████▀",
|
|
25
|
+
" ▀██▀▄▄▄▄ ████▀ ▀██",
|
|
26
|
+
" ██▀ █▀▀█ ▄█████▄ ██▄",
|
|
27
|
+
" ▄██ █▄ ▀█▄▄▄▄ ████▀▄█▀ ██▀",
|
|
28
|
+
" ███▄▄ ▀▀▄█████▀▄▄█████▄▄▄ ▄▄▄▄▄██",
|
|
29
|
+
" ▀▀█████████████▀▀ ▀▀███████████▀"
|
|
30
|
+
];
|
|
31
|
+
const mascotCompact = [
|
|
32
|
+
" ▄▄▄ ▄▄▄ ▄███▄▀█▄▄",
|
|
33
|
+
" ██▀█████▀██ ▀█▄ ██▄▄███▄▄",
|
|
34
|
+
" ▄█▀ ▀ ▀██ ▄██▀▀ ▀▀▀ ▀▀██",
|
|
35
|
+
" █▀ ███▀ ██▄",
|
|
36
|
+
"█▀ ██▄█ ██ █▄▄ ██",
|
|
37
|
+
"█ ▄█▀▀▀ ▄█ ██ ▀▀▀ ██",
|
|
38
|
+
"██ ▀ ▀ ██▄ ▄▄██",
|
|
39
|
+
" ▀██▄▄ ████ ███▀",
|
|
40
|
+
" ▄██▀▄█▄ ███▄ ██",
|
|
41
|
+
" ██ ▄ █▄ ▄ ████▀█ ██▀",
|
|
42
|
+
" ▀██ ▀█▄████ ████▀▀ ██",
|
|
43
|
+
" ▀██▄███████▀▀ ▀▀███▄█████▀"
|
|
44
|
+
];
|
|
45
|
+
const navItems = [
|
|
46
|
+
{ id: "hot", label: "十大", hint: "热门话题" },
|
|
47
|
+
{ id: "favorite", label: "收藏", hint: "版面帖子" },
|
|
48
|
+
{ id: "new", label: "最新", hint: "新帖流" },
|
|
49
|
+
{ id: "boards", label: "版面", hint: "所有分区" },
|
|
50
|
+
{ id: "following", label: "关注", hint: "用户动态" },
|
|
51
|
+
{ id: "messages", label: "消息", hint: "未读与私信" },
|
|
52
|
+
{ id: "me", label: "我的", hint: "当前账号" }
|
|
53
|
+
];
|
|
54
|
+
export async function runTui() {
|
|
55
|
+
const terminal = new Terminal();
|
|
56
|
+
const tokenStore = new TokenStore();
|
|
57
|
+
const client = new CachedCc98Client(new Cc98Client({ tokenStore }));
|
|
58
|
+
let exitRequested = false;
|
|
59
|
+
const state = {
|
|
60
|
+
mode: "list",
|
|
61
|
+
focus: "nav",
|
|
62
|
+
navIndex: 0,
|
|
63
|
+
itemIndex: 0,
|
|
64
|
+
scroll: 0,
|
|
65
|
+
loading: true,
|
|
66
|
+
loadingMore: false,
|
|
67
|
+
status: "左栏:j/k 选栏目 l/Enter 进入内容 r 刷新 q 退出",
|
|
68
|
+
viewTitle: "十大",
|
|
69
|
+
items: [],
|
|
70
|
+
stats: [],
|
|
71
|
+
overview: []
|
|
72
|
+
};
|
|
73
|
+
terminal.enter();
|
|
74
|
+
try {
|
|
75
|
+
await new Promise((resolve) => {
|
|
76
|
+
let closed = false;
|
|
77
|
+
let loadVersion = 0;
|
|
78
|
+
let currentAbort;
|
|
79
|
+
const nextSignal = () => {
|
|
80
|
+
currentAbort?.abort();
|
|
81
|
+
currentAbort = new AbortController();
|
|
82
|
+
return currentAbort.signal;
|
|
83
|
+
};
|
|
84
|
+
const render = () => terminal.render(draw(state, terminal.size()));
|
|
85
|
+
const load = async (force = false) => {
|
|
86
|
+
const version = ++loadVersion;
|
|
87
|
+
const signal = nextSignal();
|
|
88
|
+
const nav = navItems[state.navIndex];
|
|
89
|
+
state.viewTitle = nav.label;
|
|
90
|
+
state.loading = true;
|
|
91
|
+
state.error = undefined;
|
|
92
|
+
state.itemIndex = 0;
|
|
93
|
+
state.scroll = 0;
|
|
94
|
+
state.mode = "list";
|
|
95
|
+
state.items = [];
|
|
96
|
+
state.stats = [];
|
|
97
|
+
state.topic = undefined;
|
|
98
|
+
state.parentList = undefined;
|
|
99
|
+
state.currentBoard = undefined;
|
|
100
|
+
state.currentChat = undefined;
|
|
101
|
+
render();
|
|
102
|
+
try {
|
|
103
|
+
state.account = await tokenStore.getCurrentAccountName();
|
|
104
|
+
const next = await loadView(client, nav.id, force, signal);
|
|
105
|
+
if (closed || version !== loadVersion) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
state.viewTitle = next.title;
|
|
109
|
+
state.items = next.items;
|
|
110
|
+
state.stats = next.stats;
|
|
111
|
+
if (next.overview) {
|
|
112
|
+
state.overview = next.overview;
|
|
113
|
+
}
|
|
114
|
+
state.status = next.status ?? listStatus(state);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (isAbortError(error)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (closed || version !== loadVersion) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
124
|
+
state.items = [];
|
|
125
|
+
state.stats = [];
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (!closed && version === loadVersion) {
|
|
129
|
+
state.loading = false;
|
|
130
|
+
render();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const close = () => {
|
|
135
|
+
if (closed) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
closed = true;
|
|
139
|
+
exitRequested = true;
|
|
140
|
+
currentAbort?.abort();
|
|
141
|
+
offKey();
|
|
142
|
+
offResize();
|
|
143
|
+
resolve();
|
|
144
|
+
};
|
|
145
|
+
const offResize = terminal.onResize(render);
|
|
146
|
+
const offKey = terminal.onKey((key) => {
|
|
147
|
+
if (key === "\u0003" || key === "q") {
|
|
148
|
+
close();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (state.mode === "topic") {
|
|
152
|
+
if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "\x7f") {
|
|
153
|
+
currentAbort?.abort();
|
|
154
|
+
state.mode = "list";
|
|
155
|
+
state.focus = "content";
|
|
156
|
+
state.status = listStatus(state);
|
|
157
|
+
render();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (key === "j" || key === "\x1b[B") {
|
|
161
|
+
state.scroll = Math.min(Math.max(0, (state.topic?.lines.length ?? 0) - 1), state.scroll + 1);
|
|
162
|
+
render();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (key === "k" || key === "\x1b[A") {
|
|
166
|
+
state.scroll = Math.max(0, state.scroll - 1);
|
|
167
|
+
render();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (key === "n" || key === " ") {
|
|
171
|
+
void loadNextTopicPage(client, state, render, nextSignal());
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (key === "r") {
|
|
175
|
+
if (state.topic) {
|
|
176
|
+
void openTopic(client, state, state.topic.topicId, render, true, nextSignal());
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (state.focus === "nav") {
|
|
183
|
+
if (key === "j" || key === "\x1b[B") {
|
|
184
|
+
state.navIndex = Math.min(navItems.length - 1, state.navIndex + 1);
|
|
185
|
+
void load();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (key === "k" || key === "\x1b[A") {
|
|
189
|
+
state.navIndex = Math.max(0, state.navIndex - 1);
|
|
190
|
+
void load();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (key === "l" || key === "\x1b[C" || key === "\t" || key === "\r") {
|
|
194
|
+
if (!state.loading && state.items.length > 0) {
|
|
195
|
+
state.focus = "content";
|
|
196
|
+
state.status = listStatus(state);
|
|
197
|
+
render();
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (key === "h" || key === "\x1b[D") {
|
|
202
|
+
state.status = listStatus(state);
|
|
203
|
+
render();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (key === "r") {
|
|
207
|
+
void load(true);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (key === "j" || key === "\x1b[B") {
|
|
213
|
+
state.itemIndex = Math.min(Math.max(0, state.items.length - 1), state.itemIndex + 1);
|
|
214
|
+
render();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (key === "k" || key === "\x1b[A") {
|
|
218
|
+
state.itemIndex = Math.max(0, state.itemIndex - 1);
|
|
219
|
+
render();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if ((key === "\x7f" || key === "\x1b") && state.parentList) {
|
|
223
|
+
currentAbort?.abort();
|
|
224
|
+
restoreParentList(state);
|
|
225
|
+
render();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (key === "h" || key === "\x1b[D" || key === "\x1b") {
|
|
229
|
+
currentAbort?.abort();
|
|
230
|
+
state.focus = "nav";
|
|
231
|
+
state.status = listStatus(state);
|
|
232
|
+
render();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (key === "l" || key === "\r" || key === "\x1b[C") {
|
|
236
|
+
const selected = state.items[state.itemIndex];
|
|
237
|
+
if (selected?.topicId !== undefined) {
|
|
238
|
+
void openTopic(client, state, selected.topicId, render, false, nextSignal());
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (selected?.boardId !== undefined) {
|
|
242
|
+
void openBoard(client, state, selected.boardId, selected.title, render, false, nextSignal());
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (selected?.chatUserId !== undefined) {
|
|
246
|
+
void openChat(client, state, selected.chatUserId, selected.title, render, false, nextSignal());
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
state.status = "当前条目不可继续打开;h 返回左栏,j/k 继续选择";
|
|
250
|
+
render();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if ((key === "n" || key === " ") && state.currentChat) {
|
|
254
|
+
void loadNextChatPage(client, state, render, nextSignal());
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (key === "r") {
|
|
258
|
+
if (state.currentBoard) {
|
|
259
|
+
void openBoard(client, state, state.currentBoard.boardId, state.currentBoard.title, render, true, nextSignal(), false);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (state.currentChat) {
|
|
263
|
+
void openChat(client, state, state.currentChat.userId, state.currentChat.title, render, true, nextSignal(), false);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
void load(true);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
render();
|
|
271
|
+
void load();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
terminal.exit();
|
|
276
|
+
process.stdout.write("\n");
|
|
277
|
+
if (exitRequested) {
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function openTopic(client, state, topicId, render, force = false, signal) {
|
|
283
|
+
state.mode = "topic";
|
|
284
|
+
state.loading = true;
|
|
285
|
+
state.loadingMore = false;
|
|
286
|
+
state.error = undefined;
|
|
287
|
+
state.scroll = 0;
|
|
288
|
+
state.topic = {
|
|
289
|
+
topicId,
|
|
290
|
+
title: `#${topicId}`,
|
|
291
|
+
meta: "",
|
|
292
|
+
lines: [],
|
|
293
|
+
loaded: 0,
|
|
294
|
+
size: 10,
|
|
295
|
+
hasMore: true,
|
|
296
|
+
imageCount: 0,
|
|
297
|
+
linkCount: 0
|
|
298
|
+
};
|
|
299
|
+
state.status = "正在打开帖子...";
|
|
300
|
+
render();
|
|
301
|
+
try {
|
|
302
|
+
const [topicRaw, postsRaw] = await Promise.all([
|
|
303
|
+
client.getTopic(topicId, force, signal),
|
|
304
|
+
client.getTopicPosts(topicId, 0, 10, force, signal)
|
|
305
|
+
]);
|
|
306
|
+
const topic = asObject(topicRaw);
|
|
307
|
+
const posts = asArray(postsRaw);
|
|
308
|
+
const reader = buildTopicReader(topicId, topic, posts, 10);
|
|
309
|
+
state.topic = reader;
|
|
310
|
+
state.viewTitle = reader.title;
|
|
311
|
+
state.status = reader.hasMore
|
|
312
|
+
? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
|
|
313
|
+
: "j/k 滚动 h/Esc 返回 r 刷新";
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (isAbortError(error)) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
320
|
+
state.status = state.parentList
|
|
321
|
+
? "版面读取失败;Esc/Backspace 返回版面列表 h 返回左栏 r 重试"
|
|
322
|
+
: "版面读取失败;h 返回左栏 r 重试";
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
state.loading = false;
|
|
326
|
+
render();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function openBoard(client, state, boardId, boardTitle, render, force = false, signal, pushParent = true) {
|
|
330
|
+
if (pushParent) {
|
|
331
|
+
state.parentList = {
|
|
332
|
+
title: state.viewTitle,
|
|
333
|
+
items: state.items,
|
|
334
|
+
stats: state.stats,
|
|
335
|
+
itemIndex: state.itemIndex,
|
|
336
|
+
status: state.status
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
state.mode = "list";
|
|
340
|
+
state.focus = "content";
|
|
341
|
+
state.loading = true;
|
|
342
|
+
state.error = undefined;
|
|
343
|
+
state.itemIndex = 0;
|
|
344
|
+
state.scroll = 0;
|
|
345
|
+
state.topic = undefined;
|
|
346
|
+
state.currentChat = undefined;
|
|
347
|
+
state.currentBoard = { boardId, title: boardTitle };
|
|
348
|
+
state.viewTitle = boardTitle;
|
|
349
|
+
state.items = [];
|
|
350
|
+
state.stats = [
|
|
351
|
+
{ title: "版面", detail: `#${boardId}` },
|
|
352
|
+
{ title: "缓存", detail: "topics 30s" }
|
|
353
|
+
];
|
|
354
|
+
state.status = "正在读取版面帖子...";
|
|
355
|
+
render();
|
|
356
|
+
try {
|
|
357
|
+
const topics = asArray(await client.getBoardTopics(boardId, 0, 12, false, force, signal));
|
|
358
|
+
state.items = topics.map((topic) => topicItem(topic));
|
|
359
|
+
state.stats = [
|
|
360
|
+
{ title: "版面", detail: `#${boardId}` },
|
|
361
|
+
{ title: "主题", detail: `${topics.length} 条` },
|
|
362
|
+
{ title: "缓存", detail: "topics 30s" }
|
|
363
|
+
];
|
|
364
|
+
state.status = "版面帖子:j/k 选择 l/Enter 打开帖子 Esc/Backspace 返回版面列表 h 返回左栏";
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (isAbortError(error)) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
state.loading = false;
|
|
374
|
+
render();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function openChat(client, state, userId, title, render, force = false, signal, pushParent = true) {
|
|
378
|
+
if (pushParent) {
|
|
379
|
+
state.parentList = {
|
|
380
|
+
title: state.viewTitle,
|
|
381
|
+
items: state.items,
|
|
382
|
+
stats: state.stats,
|
|
383
|
+
itemIndex: state.itemIndex,
|
|
384
|
+
status: state.status
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
state.mode = "list";
|
|
388
|
+
state.focus = "content";
|
|
389
|
+
state.loading = true;
|
|
390
|
+
state.error = undefined;
|
|
391
|
+
state.itemIndex = 0;
|
|
392
|
+
state.scroll = 0;
|
|
393
|
+
state.topic = undefined;
|
|
394
|
+
state.currentBoard = undefined;
|
|
395
|
+
state.currentChat = { userId, title, loaded: 0, size: 10, hasMore: true };
|
|
396
|
+
state.viewTitle = title;
|
|
397
|
+
state.items = [];
|
|
398
|
+
state.stats = [
|
|
399
|
+
{ title: "用户", detail: `#${userId}` },
|
|
400
|
+
{ title: "缓存", detail: "history 15s" }
|
|
401
|
+
];
|
|
402
|
+
state.status = "正在读取私信...";
|
|
403
|
+
render();
|
|
404
|
+
try {
|
|
405
|
+
const messages = asArray(await client.getChatHistory(userId, 0, 10, force, signal));
|
|
406
|
+
state.items = chatMessageItems(messages, title, userId);
|
|
407
|
+
state.currentChat.loaded = messages.length;
|
|
408
|
+
state.currentChat.hasMore = messages.length === state.currentChat.size;
|
|
409
|
+
state.itemIndex = Math.max(0, state.items.length - 1);
|
|
410
|
+
state.stats = [
|
|
411
|
+
{ title: "用户", detail: `#${userId}` },
|
|
412
|
+
{ title: "消息", detail: `${messages.length} 条` },
|
|
413
|
+
{ title: "缓存", detail: "history 15s" }
|
|
414
|
+
];
|
|
415
|
+
state.status = state.currentChat.hasMore
|
|
416
|
+
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
417
|
+
: "私信:j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (isAbortError(error)) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
424
|
+
state.status = "私信读取失败;Esc/Backspace 返回联系人 h 返回左栏 r 重试";
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
state.loading = false;
|
|
428
|
+
render();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async function loadNextChatPage(client, state, render, signal) {
|
|
432
|
+
if (!state.currentChat || state.loadingMore || !state.currentChat.hasMore) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
state.loadingMore = true;
|
|
436
|
+
state.status = "正在读取更早私信...";
|
|
437
|
+
render();
|
|
438
|
+
try {
|
|
439
|
+
const chat = state.currentChat;
|
|
440
|
+
const messages = asArray(await client.getChatHistory(chat.userId, chat.loaded, chat.size, false, signal));
|
|
441
|
+
const olderItems = chatMessageItems(messages, chat.title, chat.userId);
|
|
442
|
+
state.items = [...olderItems, ...state.items];
|
|
443
|
+
state.itemIndex += olderItems.length;
|
|
444
|
+
state.scroll += olderItems.length;
|
|
445
|
+
chat.loaded += messages.length;
|
|
446
|
+
chat.hasMore = messages.length === chat.size;
|
|
447
|
+
state.stats = [
|
|
448
|
+
{ title: "用户", detail: `#${chat.userId}` },
|
|
449
|
+
{ title: "消息", detail: `${chat.loaded} 条` },
|
|
450
|
+
{ title: "缓存", detail: "history 15s" }
|
|
451
|
+
];
|
|
452
|
+
state.status = chat.hasMore
|
|
453
|
+
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
454
|
+
: "已到最早私信;j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (isAbortError(error)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
461
|
+
state.status = "更早私信读取失败;n/Space 重试 Esc/Backspace 返回联系人";
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
state.loadingMore = false;
|
|
465
|
+
render();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function restoreParentList(state) {
|
|
469
|
+
if (!state.parentList) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const parent = state.parentList;
|
|
473
|
+
state.mode = "list";
|
|
474
|
+
state.focus = "content";
|
|
475
|
+
state.loading = false;
|
|
476
|
+
state.loadingMore = false;
|
|
477
|
+
state.error = undefined;
|
|
478
|
+
state.topic = undefined;
|
|
479
|
+
state.currentBoard = undefined;
|
|
480
|
+
state.currentChat = undefined;
|
|
481
|
+
state.parentList = undefined;
|
|
482
|
+
state.viewTitle = parent.title;
|
|
483
|
+
state.items = parent.items;
|
|
484
|
+
state.stats = parent.stats;
|
|
485
|
+
state.itemIndex = parent.itemIndex;
|
|
486
|
+
state.status = parent.status;
|
|
487
|
+
}
|
|
488
|
+
async function loadNextTopicPage(client, state, render, signal) {
|
|
489
|
+
if (!state.topic || state.loadingMore || !state.topic.hasMore) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
state.loadingMore = true;
|
|
493
|
+
state.status = "正在加载下一页...";
|
|
494
|
+
render();
|
|
495
|
+
try {
|
|
496
|
+
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()));
|
|
498
|
+
state.topic.lines.push(...next.lines);
|
|
499
|
+
state.topic.imageCount += next.imageCount;
|
|
500
|
+
state.topic.linkCount += next.linkCount;
|
|
501
|
+
state.topic.loaded += posts.length;
|
|
502
|
+
state.topic.hasMore = posts.length === state.topic.size;
|
|
503
|
+
state.status = state.topic.hasMore
|
|
504
|
+
? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
|
|
505
|
+
: "已到最后一页 j/k 滚动 h/Esc 返回 r 刷新";
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (isAbortError(error)) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
state.loadingMore = false;
|
|
515
|
+
render();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function currentTopicWidthEstimate() {
|
|
519
|
+
return Number(process.env.COLUMNS) > 90 ? 56 : 44;
|
|
520
|
+
}
|
|
521
|
+
function buildTopicReader(topicId, topic, posts, size) {
|
|
522
|
+
const title = String(topic.title ?? `#${topicId}`);
|
|
523
|
+
const meta = [
|
|
524
|
+
topic.userName,
|
|
525
|
+
topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
|
|
526
|
+
topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
|
|
527
|
+
].filter(Boolean).join(" · ");
|
|
528
|
+
const rendered = renderPosts(posts, currentTopicWidthEstimate());
|
|
529
|
+
return {
|
|
530
|
+
topicId,
|
|
531
|
+
title,
|
|
532
|
+
meta,
|
|
533
|
+
lines: rendered.lines,
|
|
534
|
+
loaded: posts.length,
|
|
535
|
+
size,
|
|
536
|
+
hasMore: posts.length === size,
|
|
537
|
+
imageCount: rendered.imageCount,
|
|
538
|
+
linkCount: rendered.linkCount
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function renderPosts(posts, width) {
|
|
542
|
+
const lines = [];
|
|
543
|
+
let imageCount = 0;
|
|
544
|
+
let linkCount = 0;
|
|
545
|
+
posts.forEach((postRaw) => {
|
|
546
|
+
const post = asObject(postRaw);
|
|
547
|
+
const floor = post.floor !== undefined ? `#${post.floor}` : "#?";
|
|
548
|
+
const author = String(post.userName ?? "匿名");
|
|
549
|
+
const time = typeof post.time === "string" ? post.time.replace("T", " ").slice(0, 16) : "";
|
|
550
|
+
const like = post.likeCount !== undefined ? ` · ${post.likeCount} 赞` : "";
|
|
551
|
+
lines.push(`${floor} ${author}${time ? ` · ${time}` : ""}${like}`);
|
|
552
|
+
lines.push("─".repeat(Math.max(8, width)));
|
|
553
|
+
const content = typeof post.content === "string" ? post.content : "";
|
|
554
|
+
const rendered = renderUbbToLines(content, width);
|
|
555
|
+
lines.push(...rendered.lines);
|
|
556
|
+
lines.push("");
|
|
557
|
+
imageCount += rendered.images.length;
|
|
558
|
+
linkCount += rendered.links.length;
|
|
559
|
+
});
|
|
560
|
+
return { lines, imageCount, linkCount };
|
|
561
|
+
}
|
|
562
|
+
async function loadView(client, view, force, signal) {
|
|
563
|
+
switch (view) {
|
|
564
|
+
case "hot": {
|
|
565
|
+
const [index, unread] = await Promise.all([
|
|
566
|
+
client.getForumIndex(force, signal),
|
|
567
|
+
client.getUnreadCount(force, signal)
|
|
568
|
+
]);
|
|
569
|
+
const indexObject = asObject(index);
|
|
570
|
+
const unreadObject = asObject(unread);
|
|
571
|
+
const hotTopics = asArray(indexObject.hotTopic ?? indexObject.manualHotTopic);
|
|
572
|
+
return {
|
|
573
|
+
title: "十大",
|
|
574
|
+
items: hotTopics.map((topic) => topicItem(topic)),
|
|
575
|
+
stats: unreadStats(unreadObject),
|
|
576
|
+
overview: overviewStats(indexObject, unreadObject)
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
case "new": {
|
|
580
|
+
const topics = asArray(await client.getNewTopics(0, 12, force, signal));
|
|
581
|
+
return {
|
|
582
|
+
title: "最新",
|
|
583
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
584
|
+
stats: [{ title: "新帖流", detail: `${topics.length} 条` }]
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
case "boards": {
|
|
588
|
+
const sections = asArray(await client.getAllBoards(force, signal));
|
|
589
|
+
const boards = flattenBoards(sections).slice(0, 14);
|
|
590
|
+
return {
|
|
591
|
+
title: "版面",
|
|
592
|
+
items: boards,
|
|
593
|
+
stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${flattenBoards(sections).length}` }],
|
|
594
|
+
status: "版面:j/k 选择 l/Enter 读取该版主题 h 返回左栏 r 刷新"
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
case "following": {
|
|
598
|
+
const topics = asArray(await client.getFolloweeTopics(0, 12, force, signal));
|
|
599
|
+
return {
|
|
600
|
+
title: "关注",
|
|
601
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
602
|
+
stats: [
|
|
603
|
+
{ title: "关注动态", detail: `${topics.length} 条` },
|
|
604
|
+
{ title: "缓存", detail: "30s" }
|
|
605
|
+
],
|
|
606
|
+
status: "关注用户动态:j/k 选择 l/Enter 打开帖子 h 返回左栏 r 刷新"
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
case "favorite": {
|
|
610
|
+
const [meRaw, sectionsRaw] = await Promise.all([
|
|
611
|
+
client.getMe(force, signal),
|
|
612
|
+
client.getAllBoards(false, signal)
|
|
613
|
+
]);
|
|
614
|
+
const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
|
|
615
|
+
const allBoards = flattenBoards(asArray(sectionsRaw));
|
|
616
|
+
const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
|
|
617
|
+
const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
|
|
618
|
+
const board = boardById.get(boardId);
|
|
619
|
+
const topics = asArray(await client.getBoardTopics(boardId, 0, 3, false, force, signal));
|
|
620
|
+
return topics.map((topic) => topicItem(topic, board));
|
|
621
|
+
});
|
|
622
|
+
const items = topicGroups.flat().sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0)).slice(0, 18);
|
|
623
|
+
return {
|
|
624
|
+
title: "收藏",
|
|
625
|
+
items,
|
|
626
|
+
stats: [
|
|
627
|
+
{ title: "收藏版面", detail: `${customBoards.length} 个` },
|
|
628
|
+
{ title: "主题", detail: `${items.length} 条` },
|
|
629
|
+
{ title: "缓存", detail: "boards 24h / topics 30s" }
|
|
630
|
+
],
|
|
631
|
+
status: "收藏版面帖子:j/k 选择 l/Enter 打开帖子 h 返回左栏 r 刷新"
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
case "messages": {
|
|
635
|
+
const [unread, recent] = await Promise.all([
|
|
636
|
+
client.getUnreadCount(force, signal),
|
|
637
|
+
client.getRecentChats(0, 10, force, signal)
|
|
638
|
+
]);
|
|
639
|
+
const unreadObject = asObject(unread);
|
|
640
|
+
const chats = asArray(recent);
|
|
641
|
+
const userNames = await loadChatUserNames(client, chats, force, signal);
|
|
642
|
+
return {
|
|
643
|
+
title: "消息",
|
|
644
|
+
items: chats.length > 0 ? chats.map((chat) => chatItem(chat, userNames)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
|
|
645
|
+
stats: unreadStats(unreadObject),
|
|
646
|
+
status: "私信联系人:j/k 选择 l/Enter 打开会话 h 返回左栏 r 刷新"
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
case "me": {
|
|
650
|
+
const me = asObject(await client.getMe(force, signal));
|
|
651
|
+
return {
|
|
652
|
+
title: "我的",
|
|
653
|
+
items: [
|
|
654
|
+
item("昵称", me.name),
|
|
655
|
+
item("用户 ID", me.id),
|
|
656
|
+
item("等级", me.levelTitle ?? me.groupName),
|
|
657
|
+
item("发帖数", me.postCount),
|
|
658
|
+
item("财富", me.wealth),
|
|
659
|
+
item("关注", me.followCount),
|
|
660
|
+
item("粉丝", me.fanCount)
|
|
661
|
+
],
|
|
662
|
+
stats: [{ title: "登录状态", detail: "已登录" }]
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function draw(state, size) {
|
|
668
|
+
const width = Math.max(60, size.columns);
|
|
669
|
+
const height = Math.max(20, size.rows);
|
|
670
|
+
const sidebarWidth = width < 90 ? 14 : 18;
|
|
671
|
+
const rightWidth = width < 78 ? 0 : Math.min(42, Math.max(34, Math.floor(width * 0.30)));
|
|
672
|
+
const mainWidth = width - sidebarWidth - rightWidth - (rightWidth > 0 ? 2 : 1);
|
|
673
|
+
const overviewHeight = height < 24 ? 1 : 2;
|
|
674
|
+
const bodyHeight = height - 4 - overviewHeight;
|
|
675
|
+
const lines = [];
|
|
676
|
+
lines.push(header(width, state));
|
|
677
|
+
lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
678
|
+
lines.push(...drawOverview(state, width, overviewHeight));
|
|
679
|
+
const sidebar = drawSidebar(state, sidebarWidth, bodyHeight);
|
|
680
|
+
const main = drawMain(state, mainWidth, bodyHeight);
|
|
681
|
+
const right = rightWidth > 0 ? drawRight(state, rightWidth, bodyHeight) : [];
|
|
682
|
+
for (let row = 0; row < bodyHeight; row += 1) {
|
|
683
|
+
const parts = [
|
|
684
|
+
sidebar[row] ?? " ".repeat(sidebarWidth),
|
|
685
|
+
`${line}│${ansi.reset}`,
|
|
686
|
+
main[row] ?? " ".repeat(mainWidth)
|
|
687
|
+
];
|
|
688
|
+
if (rightWidth > 0) {
|
|
689
|
+
parts.push(`${line}│${ansi.reset}`, right[row] ?? " ".repeat(rightWidth));
|
|
690
|
+
}
|
|
691
|
+
lines.push(parts.join(""));
|
|
692
|
+
}
|
|
693
|
+
lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
694
|
+
lines.push(fit(`${muted}${state.status}${ansi.reset}`, width));
|
|
695
|
+
return lines.slice(0, height).join("\n");
|
|
696
|
+
}
|
|
697
|
+
function header(width, state) {
|
|
698
|
+
const account = state.account ? `@${state.account}` : "未登录";
|
|
699
|
+
const title = `${cc98BlueBg}${white}${ansi.bold} CC98 ${ansi.reset}${cc98BlueBg}${white} ${state.viewTitle} ${ansi.reset}`;
|
|
700
|
+
const right = `${muted}${account}${ansi.reset}`;
|
|
701
|
+
return fit(`${title}${" ".repeat(Math.max(1, width - cellWidth(title) - cellWidth(right)))}${right}`, width);
|
|
702
|
+
}
|
|
703
|
+
function drawOverview(state, width, height) {
|
|
704
|
+
const rows = [];
|
|
705
|
+
const summary = state.overview.length > 0
|
|
706
|
+
? state.overview.map((entry) => `${entry.title} ${entry.detail ?? "-"}`).join(" ")
|
|
707
|
+
: "全站概览会在读取十大时更新";
|
|
708
|
+
rows.push(fit(`${cc98BlueSoft} ${summary}${ansi.reset}`, width));
|
|
709
|
+
if (height > 1) {
|
|
710
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
711
|
+
}
|
|
712
|
+
return rows.slice(0, height);
|
|
713
|
+
}
|
|
714
|
+
function drawSidebar(state, width, height) {
|
|
715
|
+
const rows = [];
|
|
716
|
+
for (let index = 0; index < height; index += 1) {
|
|
717
|
+
const nav = navItems[index];
|
|
718
|
+
if (!nav) {
|
|
719
|
+
rows.push(" ".repeat(width));
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const active = index === state.navIndex;
|
|
723
|
+
const focused = state.focus === "nav";
|
|
724
|
+
const label = ` ${nav.label}`;
|
|
725
|
+
const hint = width > 16 ? ` ${nav.hint}` : "";
|
|
726
|
+
const text = fit(`${label}${hint}`, width);
|
|
727
|
+
if (active && focused) {
|
|
728
|
+
rows.push(`${bg(0, 130, 202)}${white}${text}${ansi.reset}`);
|
|
729
|
+
}
|
|
730
|
+
else if (active) {
|
|
731
|
+
rows.push(`${bg(5, 46, 74)}${cc98BlueSoft}${text}${ansi.reset}`);
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
rows.push(`${cc98Blue}${label}${ansi.reset}${muted}${fit(hint, Math.max(0, width - cellWidth(label)))}${ansi.reset}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return rows;
|
|
738
|
+
}
|
|
739
|
+
function drawMain(state, width, height) {
|
|
740
|
+
if (state.mode === "topic") {
|
|
741
|
+
return drawTopic(state, width, height);
|
|
742
|
+
}
|
|
743
|
+
if (state.loading) {
|
|
744
|
+
return [
|
|
745
|
+
`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
|
|
746
|
+
fit(`${muted} 正在加载...${ansi.reset}`, width),
|
|
747
|
+
`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
|
|
748
|
+
`${muted} ${"· ".repeat(Math.max(1, Math.floor((width - 2) / 2))).slice(0, width - 1)}${ansi.reset}`
|
|
749
|
+
].concat(blank(height - 4, width)).slice(0, height);
|
|
750
|
+
}
|
|
751
|
+
if (state.error) {
|
|
752
|
+
return [
|
|
753
|
+
`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
|
|
754
|
+
`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
|
|
755
|
+
`${danger} 请求失败${ansi.reset}`,
|
|
756
|
+
fit(` ${state.error}`, width)
|
|
757
|
+
].concat(blank(height - 4, width)).slice(0, height);
|
|
758
|
+
}
|
|
759
|
+
const rows = [];
|
|
760
|
+
rows.push(`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`);
|
|
761
|
+
rows.push(fit(`${muted} ${state.focus === "content" ? "内容栏" : "按 l/Enter 进入内容栏"}${ansi.reset}`, width));
|
|
762
|
+
rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
|
|
763
|
+
const visibleCapacity = Math.max(1, Math.floor(Math.max(1, height - 3) / 3));
|
|
764
|
+
if (state.itemIndex < state.scroll) {
|
|
765
|
+
state.scroll = state.itemIndex;
|
|
766
|
+
}
|
|
767
|
+
else if (state.itemIndex >= state.scroll + visibleCapacity) {
|
|
768
|
+
state.scroll = state.itemIndex - visibleCapacity + 1;
|
|
769
|
+
}
|
|
770
|
+
const visible = state.items.slice(state.scroll);
|
|
771
|
+
visible.forEach((itemValue, offset) => {
|
|
772
|
+
if (rows.length >= height) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const index = state.scroll + offset;
|
|
776
|
+
const active = index === state.itemIndex && state.focus === "content";
|
|
777
|
+
const prefix = active ? `${ok}●${ansi.reset}` : `${muted}•${ansi.reset}`;
|
|
778
|
+
const title = fit(` ${itemValue.title}`, Math.max(10, width - 2));
|
|
779
|
+
rows.push(active ? `${bg(5, 46, 74)}${prefix}${title}${ansi.reset}` : fit(`${prefix}${title}`, width));
|
|
780
|
+
if (itemValue.meta && rows.length < height) {
|
|
781
|
+
rows.push(fit(` ${muted}${itemValue.meta}${ansi.reset}`, width));
|
|
782
|
+
}
|
|
783
|
+
if (itemValue.detail && rows.length < height) {
|
|
784
|
+
rows.push(fit(` ${itemValue.detail}`, width));
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
if (visible.length === 0) {
|
|
788
|
+
rows.push(`${muted} 暂无数据${ansi.reset}`);
|
|
789
|
+
}
|
|
790
|
+
if (state.scroll + visibleCapacity < state.items.length && rows.length < height) {
|
|
791
|
+
rows.push(fit(`${muted} ↓ 还有 ${state.items.length - state.scroll - visibleCapacity} 项${ansi.reset}`, width));
|
|
792
|
+
}
|
|
793
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
794
|
+
}
|
|
795
|
+
function drawTopic(state, width, height) {
|
|
796
|
+
if (state.loading && (!state.topic || state.topic.lines.length === 0)) {
|
|
797
|
+
return [
|
|
798
|
+
`${cc98Blue} 正在打开帖子...${ansi.reset}`,
|
|
799
|
+
"",
|
|
800
|
+
`${muted} 只加载第一页,不预取未读楼层。${ansi.reset}`
|
|
801
|
+
].concat(blank(height - 3, width)).slice(0, height);
|
|
802
|
+
}
|
|
803
|
+
if (state.error) {
|
|
804
|
+
return [
|
|
805
|
+
`${danger} 读取帖子失败${ansi.reset}`,
|
|
806
|
+
fit(` ${state.error}`, width),
|
|
807
|
+
"",
|
|
808
|
+
`${muted} h/Esc 返回列表${ansi.reset}`
|
|
809
|
+
].concat(blank(height - 4, width)).slice(0, height);
|
|
810
|
+
}
|
|
811
|
+
const topic = state.topic;
|
|
812
|
+
if (!topic) {
|
|
813
|
+
return blank(height, width);
|
|
814
|
+
}
|
|
815
|
+
const rows = [];
|
|
816
|
+
rows.push(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`);
|
|
817
|
+
rows.push(fit(`${muted} ${topic.meta}${ansi.reset}`, width));
|
|
818
|
+
rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
|
|
819
|
+
const viewport = Math.max(0, height - rows.length - 1);
|
|
820
|
+
const maxScroll = Math.max(0, topic.lines.length - viewport);
|
|
821
|
+
state.scroll = Math.min(state.scroll, maxScroll);
|
|
822
|
+
const body = topic.lines.slice(state.scroll, state.scroll + viewport);
|
|
823
|
+
for (const bodyLine of body) {
|
|
824
|
+
if (bodyLine.startsWith("[image ")) {
|
|
825
|
+
rows.push(fit(`${cc98BlueSoft}${bodyLine}${ansi.reset}`, width));
|
|
826
|
+
}
|
|
827
|
+
else if (bodyLine.startsWith("│ ")) {
|
|
828
|
+
rows.push(fit(`${muted}${bodyLine}${ansi.reset}`, width));
|
|
829
|
+
}
|
|
830
|
+
else if (/^#\d+ /.test(bodyLine)) {
|
|
831
|
+
rows.push(fit(`${ok}${bodyLine}${ansi.reset}`, width));
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
rows.push(fit(` ${bodyLine}`, width));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const pageInfo = topic.hasMore
|
|
838
|
+
? `已载入 ${topic.loaded} 楼,n 下一页`
|
|
839
|
+
: `已载入 ${topic.loaded} 楼,已到底`;
|
|
840
|
+
rows.push(fit(`${muted}${pageInfo}${state.loadingMore ? " · 加载中" : ""}${ansi.reset}`, width));
|
|
841
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
842
|
+
}
|
|
843
|
+
function drawRight(state, width, height) {
|
|
844
|
+
const rows = [];
|
|
845
|
+
rows.push(`${cc98Blue}${ansi.bold} CC98${ansi.reset}`);
|
|
846
|
+
const art = width < 40 ? mascotCompact : mascot;
|
|
847
|
+
rows.push(...art.map((row) => fit(`${white}${row}${ansi.reset}`, width)));
|
|
848
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
849
|
+
const stats = state.mode === "topic" && state.topic
|
|
850
|
+
? [
|
|
851
|
+
{ title: "楼层", detail: String(state.topic.loaded) },
|
|
852
|
+
{ title: "图片", detail: String(state.topic.imageCount) },
|
|
853
|
+
{ title: "链接", detail: String(state.topic.linkCount) },
|
|
854
|
+
{ title: "缓存", detail: "meta 60s / posts 60s-10m" }
|
|
855
|
+
]
|
|
856
|
+
: state.stats;
|
|
857
|
+
stats.slice(0, Math.max(0, height - rows.length)).forEach((stat) => {
|
|
858
|
+
rows.push(fit(`${muted}${stat.title}${ansi.reset}`, width));
|
|
859
|
+
if (stat.detail) {
|
|
860
|
+
rows.push(fit(`${cc98BlueSoft}${stat.detail}${ansi.reset}`, width));
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
const selected = state.mode === "list" ? state.items[state.itemIndex] : undefined;
|
|
864
|
+
if (selected && height - rows.length >= 4) {
|
|
865
|
+
while (height - rows.length > 4) {
|
|
866
|
+
rows.push(" ".repeat(width));
|
|
867
|
+
}
|
|
868
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
869
|
+
rows.push(fit(`${muted}选中${ansi.reset}`, width));
|
|
870
|
+
rows.push(fit(`${cc98BlueSoft}${selected.title}${ansi.reset}`, width));
|
|
871
|
+
rows.push(fit(`${muted}${selected.meta ? `归属 ${selected.meta}` : selected.boardId ? `版面 #${selected.boardId}` : ""}${ansi.reset}`, width));
|
|
872
|
+
}
|
|
873
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
874
|
+
}
|
|
875
|
+
function item(title, value, meta) {
|
|
876
|
+
return {
|
|
877
|
+
title,
|
|
878
|
+
meta,
|
|
879
|
+
detail: value === undefined || value === null ? "-" : String(value)
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
function topicItem(value, fallbackBoard) {
|
|
883
|
+
const topic = asObject(value);
|
|
884
|
+
const topicId = asNumber(topic.id ?? topic.Id);
|
|
885
|
+
const boardId = asNumber(topic.boardId ?? topic.BoardId) ?? fallbackBoard?.boardId;
|
|
886
|
+
const boardName = topic.boardName ?? topic.BoardName ?? fallbackBoard?.title;
|
|
887
|
+
return {
|
|
888
|
+
title: String(topic.title ?? topic.Title ?? `#${topicId ?? ""}`),
|
|
889
|
+
meta: [
|
|
890
|
+
boardName,
|
|
891
|
+
topic.userName ?? topic.authorName,
|
|
892
|
+
topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
|
|
893
|
+
topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
|
|
894
|
+
]
|
|
895
|
+
.filter(Boolean)
|
|
896
|
+
.join(" · "),
|
|
897
|
+
detail: typeof topic.lastPostContent === "string" ? topic.lastPostContent.replace(/\s+/g, " ") : undefined,
|
|
898
|
+
topicId,
|
|
899
|
+
boardId,
|
|
900
|
+
sortTime: timestampOf(topic.lastPostTime ?? topic.updateTime ?? topic.time ?? topic.createTime)
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
async function loadChatUserNames(client, chats, force, signal) {
|
|
904
|
+
const ids = chats
|
|
905
|
+
.map((chat) => asNumber(asObject(chat).userId ?? asObject(chat).UserId))
|
|
906
|
+
.filter((id) => id !== undefined);
|
|
907
|
+
const users = asArray(await client.getBasicUsers(ids, force, signal));
|
|
908
|
+
return new Map(users.map((userRaw) => {
|
|
909
|
+
const user = asObject(userRaw);
|
|
910
|
+
const id = asNumber(user.id ?? user.Id);
|
|
911
|
+
const name = String(user.name ?? user.Name ?? (id !== undefined ? `#${id}` : "用户"));
|
|
912
|
+
return [id, name];
|
|
913
|
+
}).filter((entry) => entry[0] !== undefined));
|
|
914
|
+
}
|
|
915
|
+
function chatItem(value, userNames) {
|
|
916
|
+
const chat = asObject(value);
|
|
917
|
+
const userId = asNumber(chat.userId ?? chat.UserId);
|
|
918
|
+
const name = userId !== undefined ? userNames.get(userId) : undefined;
|
|
919
|
+
return {
|
|
920
|
+
title: String(name ?? chat.name ?? chat.userName ?? userId ?? "私信"),
|
|
921
|
+
meta: userId !== undefined ? `user #${userId}` : undefined,
|
|
922
|
+
detail: normalizeInline(String(chat.lastContent ?? chat.lastMessage ?? chat.content ?? "")),
|
|
923
|
+
chatUserId: userId
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function chatMessageItems(messages, otherName, otherUserId) {
|
|
927
|
+
return [...messages].reverse().map((messageRaw) => {
|
|
928
|
+
const message = asObject(messageRaw);
|
|
929
|
+
const receiverId = asNumber(message.receiverId ?? message.ReceiverId);
|
|
930
|
+
const isMine = receiverId === otherUserId;
|
|
931
|
+
const time = typeof message.time === "string"
|
|
932
|
+
? message.time.replace("T", " ").slice(0, 16)
|
|
933
|
+
: "";
|
|
934
|
+
const content = normalizeInline(String(message.content ?? message.Content ?? ""));
|
|
935
|
+
return {
|
|
936
|
+
title: isMine ? `我 -> ${otherName}` : `${otherName} -> 我`,
|
|
937
|
+
meta: [time, receiverId !== undefined ? `receiver #${receiverId}` : undefined].filter(Boolean).join(" · "),
|
|
938
|
+
detail: content || "(空消息)"
|
|
939
|
+
};
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
function unreadStats(value) {
|
|
943
|
+
return [
|
|
944
|
+
item("系统", value.systemCount),
|
|
945
|
+
item("@", value.atCount),
|
|
946
|
+
item("回复", value.replyCount),
|
|
947
|
+
item("私信", value.messageCount)
|
|
948
|
+
];
|
|
949
|
+
}
|
|
950
|
+
function overviewStats(index, unread) {
|
|
951
|
+
const unreadTotal = ["systemCount", "atCount", "replyCount", "messageCount"].reduce((total, key) => {
|
|
952
|
+
const value = unread[key];
|
|
953
|
+
return total + (typeof value === "number" ? value : 0);
|
|
954
|
+
}, 0);
|
|
955
|
+
return [
|
|
956
|
+
item("今日主题", index.todayTopicCount),
|
|
957
|
+
item("今日回复", index.todayCount),
|
|
958
|
+
item("在线", index.onlineUserCount),
|
|
959
|
+
item("用户", index.userCount),
|
|
960
|
+
item("未读", unreadTotal)
|
|
961
|
+
];
|
|
962
|
+
}
|
|
963
|
+
async function mapLimit(values, limit, mapper) {
|
|
964
|
+
const results = [];
|
|
965
|
+
let nextIndex = 0;
|
|
966
|
+
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
967
|
+
while (nextIndex < values.length) {
|
|
968
|
+
const index = nextIndex;
|
|
969
|
+
nextIndex += 1;
|
|
970
|
+
results[index] = await mapper(values[index]);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
await Promise.all(workers);
|
|
974
|
+
return results;
|
|
975
|
+
}
|
|
976
|
+
function listStatus(state) {
|
|
977
|
+
if (state.currentBoard) {
|
|
978
|
+
return "版面帖子:j/k 选择 l/Enter 打开帖子 Esc/Backspace 返回版面列表 h 返回左栏";
|
|
979
|
+
}
|
|
980
|
+
if (state.currentChat) {
|
|
981
|
+
return state.currentChat.hasMore
|
|
982
|
+
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
983
|
+
: "私信:j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
984
|
+
}
|
|
985
|
+
if (state.focus === "nav") {
|
|
986
|
+
return "左栏:j/k 选栏目 l/Enter 进入内容 r 刷新 q 退出";
|
|
987
|
+
}
|
|
988
|
+
return "内容:j/k 选择 l/Enter 打开帖子/版面/私信 h 返回左栏 r 刷新 q 退出";
|
|
989
|
+
}
|
|
990
|
+
function flattenBoards(sections) {
|
|
991
|
+
const boards = [];
|
|
992
|
+
for (const section of sections) {
|
|
993
|
+
const sectionObject = asObject(section);
|
|
994
|
+
const sectionName = String(sectionObject.name ?? sectionObject.title ?? "分区");
|
|
995
|
+
const candidates = [sectionObject.boards, sectionObject.children, sectionObject.boardList];
|
|
996
|
+
for (const candidate of candidates) {
|
|
997
|
+
if (!Array.isArray(candidate)) {
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
for (const board of candidate) {
|
|
1001
|
+
const boardObject = asObject(board);
|
|
1002
|
+
boards.push({
|
|
1003
|
+
title: String(boardObject.name ?? boardObject.title ?? `#${boardObject.id ?? ""}`),
|
|
1004
|
+
meta: `${sectionName}${boardObject.id !== undefined ? ` · #${boardObject.id}` : ""}`,
|
|
1005
|
+
detail: typeof boardObject.description === "string" ? boardObject.description : undefined,
|
|
1006
|
+
boardId: typeof boardObject.id === "number" ? boardObject.id : undefined
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return boards;
|
|
1012
|
+
}
|
|
1013
|
+
function asObject(value) {
|
|
1014
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1015
|
+
}
|
|
1016
|
+
function asArray(value) {
|
|
1017
|
+
return Array.isArray(value) ? value : [];
|
|
1018
|
+
}
|
|
1019
|
+
function asNumber(value) {
|
|
1020
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1021
|
+
}
|
|
1022
|
+
function normalizeInline(value) {
|
|
1023
|
+
return value.replace(/\s+/g, " ").trim();
|
|
1024
|
+
}
|
|
1025
|
+
function timestampOf(value) {
|
|
1026
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
const timestamp = new Date(value).getTime();
|
|
1030
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
1031
|
+
}
|
|
1032
|
+
function isAbortError(error) {
|
|
1033
|
+
return error instanceof Error && error.name === "AbortError";
|
|
1034
|
+
}
|
|
1035
|
+
function blank(count, width) {
|
|
1036
|
+
return Array.from({ length: Math.max(0, count) }, () => " ".repeat(width));
|
|
1037
|
+
}
|
|
1038
|
+
function fit(value, width) {
|
|
1039
|
+
const truncated = truncate(value, width);
|
|
1040
|
+
return `${truncated}${" ".repeat(Math.max(0, width - cellWidth(truncated)))}`;
|
|
1041
|
+
}
|
|
1042
|
+
function truncate(value, width) {
|
|
1043
|
+
let out = "";
|
|
1044
|
+
let used = 0;
|
|
1045
|
+
let inEscape = false;
|
|
1046
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1047
|
+
const char = value[index];
|
|
1048
|
+
if (char === "\x1b") {
|
|
1049
|
+
inEscape = true;
|
|
1050
|
+
out += char;
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (inEscape) {
|
|
1054
|
+
out += char;
|
|
1055
|
+
if (/[A-Za-z]/.test(char)) {
|
|
1056
|
+
inEscape = false;
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
const charWidth = charCellWidth(char);
|
|
1061
|
+
if (used + charWidth > width) {
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
out += char;
|
|
1065
|
+
used += charWidth;
|
|
1066
|
+
}
|
|
1067
|
+
return out;
|
|
1068
|
+
}
|
|
1069
|
+
function cellWidth(value) {
|
|
1070
|
+
let width = 0;
|
|
1071
|
+
for (const char of stripAnsi(value)) {
|
|
1072
|
+
width += charCellWidth(char);
|
|
1073
|
+
}
|
|
1074
|
+
return width;
|
|
1075
|
+
}
|
|
1076
|
+
function charCellWidth(char) {
|
|
1077
|
+
const code = char.codePointAt(0) ?? 0;
|
|
1078
|
+
if (code === 0) {
|
|
1079
|
+
return 0;
|
|
1080
|
+
}
|
|
1081
|
+
if (code >= 0x1100 &&
|
|
1082
|
+
(code <= 0x115f ||
|
|
1083
|
+
code === 0x2329 ||
|
|
1084
|
+
code === 0x232a ||
|
|
1085
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
1086
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
1087
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
1088
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
1089
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
1090
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
1091
|
+
(code >= 0xffe0 && code <= 0xffe6))) {
|
|
1092
|
+
return 2;
|
|
1093
|
+
}
|
|
1094
|
+
return 1;
|
|
1095
|
+
}
|
|
1096
|
+
//# sourceMappingURL=app.js.map
|