cc98-cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +4 -2
- package/dist/api/client.d.ts +40 -15
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +179 -38
- package/dist/api/client.js.map +1 -1
- package/dist/api/endpoints.d.ts +14 -0
- package/dist/api/endpoints.d.ts.map +1 -1
- package/dist/api/endpoints.js +21 -1
- package/dist/api/endpoints.js.map +1 -1
- package/dist/api/types.d.ts +13 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/webvpn.d.ts +68 -0
- package/dist/api/webvpn.d.ts.map +1 -0
- package/dist/api/webvpn.js +311 -0
- package/dist/api/webvpn.js.map +1 -0
- package/dist/cli/commands/board.d.ts.map +1 -1
- package/dist/cli/commands/board.js +16 -1
- package/dist/cli/commands/board.js.map +1 -1
- package/dist/cli/commands/forum.js +1 -1
- package/dist/cli/commands/forum.js.map +1 -1
- package/dist/cli/commands/login.js +1 -1
- package/dist/cli/commands/login.js.map +1 -1
- package/dist/cli/commands/logout.js +2 -2
- package/dist/cli/commands/logout.js.map +1 -1
- package/dist/cli/commands/me.d.ts.map +1 -1
- package/dist/cli/commands/me.js +18 -3
- package/dist/cli/commands/me.js.map +1 -1
- package/dist/cli/commands/message.d.ts.map +1 -1
- package/dist/cli/commands/message.js +11 -1
- package/dist/cli/commands/message.js.map +1 -1
- package/dist/cli/commands/notice.js +1 -1
- package/dist/cli/commands/notice.js.map +1 -1
- package/dist/cli/commands/post.d.ts.map +1 -1
- package/dist/cli/commands/post.js +13 -1
- package/dist/cli/commands/post.js.map +1 -1
- package/dist/cli/commands/search.js +1 -1
- package/dist/cli/commands/search.js.map +1 -1
- package/dist/cli/commands/topic.d.ts.map +1 -1
- package/dist/cli/commands/topic.js +42 -6
- package/dist/cli/commands/topic.js.map +1 -1
- package/dist/cli/commands/user.d.ts.map +1 -1
- package/dist/cli/commands/user.js +13 -1
- package/dist/cli/commands/user.js.map +1 -1
- package/dist/cli/commands/vpn.d.ts +2 -0
- package/dist/cli/commands/vpn.d.ts.map +1 -0
- package/dist/cli/commands/vpn.js +200 -0
- package/dist/cli/commands/vpn.js.map +1 -0
- package/dist/cli/context.d.ts +2 -2
- package/dist/cli/context.d.ts.map +1 -1
- package/dist/cli/context.js +20 -2
- package/dist/cli/context.js.map +1 -1
- package/dist/cli/router.d.ts.map +1 -1
- package/dist/cli/router.js +13 -2
- package/dist/cli/router.js.map +1 -1
- package/dist/main.js +0 -0
- package/dist/storage/image-cache.d.ts +32 -0
- package/dist/storage/image-cache.d.ts.map +1 -0
- package/dist/storage/image-cache.js +90 -0
- package/dist/storage/image-cache.js.map +1 -0
- package/dist/storage/vpn-store.d.ts +39 -0
- package/dist/storage/vpn-store.d.ts.map +1 -0
- package/dist/storage/vpn-store.js +94 -0
- package/dist/storage/vpn-store.js.map +1 -0
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +27 -1725
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/borders.d.ts +183 -0
- package/dist/tui/borders.d.ts.map +1 -0
- package/dist/tui/borders.js +143 -0
- package/dist/tui/borders.js.map +1 -0
- package/dist/tui/cached-client.d.ts +23 -0
- package/dist/tui/cached-client.d.ts.map +1 -1
- package/dist/tui/cached-client.js +88 -0
- package/dist/tui/cached-client.js.map +1 -1
- package/dist/tui/components/content.d.ts +11 -0
- package/dist/tui/components/content.d.ts.map +1 -0
- package/dist/tui/components/content.js +129 -0
- package/dist/tui/components/content.js.map +1 -0
- package/dist/tui/components/header.d.ts +7 -0
- package/dist/tui/components/header.d.ts.map +1 -0
- package/dist/tui/components/header.js +38 -0
- package/dist/tui/components/header.js.map +1 -0
- package/dist/tui/components/index.d.ts +8 -0
- package/dist/tui/components/index.d.ts.map +1 -0
- package/dist/tui/components/index.js +9 -0
- package/dist/tui/components/index.js.map +1 -0
- package/dist/tui/components/layout.d.ts +3 -0
- package/dist/tui/components/layout.d.ts.map +1 -0
- package/dist/tui/components/layout.js +453 -0
- package/dist/tui/components/layout.js.map +1 -0
- package/dist/tui/components/overview.d.ts +7 -0
- package/dist/tui/components/overview.d.ts.map +1 -0
- package/dist/tui/components/overview.js +30 -0
- package/dist/tui/components/overview.js.map +1 -0
- package/dist/tui/components/sidebar.d.ts +7 -0
- package/dist/tui/components/sidebar.d.ts.map +1 -0
- package/dist/tui/components/sidebar.js +57 -0
- package/dist/tui/components/sidebar.js.map +1 -0
- package/dist/tui/components/status.d.ts +8 -0
- package/dist/tui/components/status.d.ts.map +1 -0
- package/dist/tui/components/status.js +42 -0
- package/dist/tui/components/status.js.map +1 -0
- package/dist/tui/components/types.d.ts +17 -0
- package/dist/tui/components/types.d.ts.map +1 -0
- package/dist/tui/components/types.js +3 -0
- package/dist/tui/components/types.js.map +1 -0
- package/dist/tui/components/utils.d.ts +5 -0
- package/dist/tui/components/utils.d.ts.map +1 -0
- package/dist/tui/components/utils.js +68 -0
- package/dist/tui/components/utils.js.map +1 -0
- package/dist/tui/controller.d.ts +66 -0
- package/dist/tui/controller.d.ts.map +1 -0
- package/dist/tui/controller.js +1200 -0
- package/dist/tui/controller.js.map +1 -0
- package/dist/tui/helpers.d.ts +25 -0
- package/dist/tui/helpers.d.ts.map +1 -0
- package/dist/tui/helpers.js +249 -0
- package/dist/tui/helpers.js.map +1 -0
- package/dist/tui/navigation.d.ts +4 -0
- package/dist/tui/navigation.d.ts.map +1 -0
- package/dist/tui/navigation.js +19 -0
- package/dist/tui/navigation.js.map +1 -0
- package/dist/tui/renderer.d.ts +6 -0
- package/dist/tui/renderer.d.ts.map +1 -0
- package/dist/tui/renderer.js +305 -0
- package/dist/tui/renderer.js.map +1 -0
- package/dist/tui/state/index.d.ts +3 -0
- package/dist/tui/state/index.d.ts.map +1 -0
- package/dist/tui/state/index.js +4 -0
- package/dist/tui/state/index.js.map +1 -0
- package/dist/tui/state/store.d.ts +4 -0
- package/dist/tui/state/store.d.ts.map +1 -0
- package/dist/tui/state/store.js +62 -0
- package/dist/tui/state/store.js.map +1 -0
- package/dist/tui/state/types.d.ts +141 -0
- package/dist/tui/state/types.d.ts.map +1 -0
- package/dist/tui/state/types.js +3 -0
- package/dist/tui/state/types.js.map +1 -0
- package/dist/tui/topic-reader.d.ts +10 -0
- package/dist/tui/topic-reader.d.ts.map +1 -0
- package/dist/tui/topic-reader.js +178 -0
- package/dist/tui/topic-reader.js.map +1 -0
- package/dist/tui/ubb-renderer.d.ts.map +1 -1
- package/dist/tui/ubb-renderer.js +176 -12
- package/dist/tui/ubb-renderer.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- /package/{docs/images → images}/tui.jpg +0 -0
package/dist/tui/app.js
CHANGED
|
@@ -1,502 +1,59 @@
|
|
|
1
1
|
import { Cc98Client } from "../api/client.js";
|
|
2
2
|
import { TokenStore } from "../storage/token-store.js";
|
|
3
|
-
import {
|
|
4
|
-
import { appVersion } from "../version.js";
|
|
5
|
-
import { ansi, bg, fg, stripAnsi } from "./ansi.js";
|
|
3
|
+
import { VpnStore } from "../storage/vpn-store.js";
|
|
6
4
|
import { CachedCc98Client } from "./cached-client.js";
|
|
5
|
+
import { TuiController } from "./controller.js";
|
|
6
|
+
import { draw } from "./renderer.js";
|
|
7
|
+
import { createInitialState } from "./state/store.js";
|
|
7
8
|
import { Terminal } from "./terminal.js";
|
|
8
|
-
import { renderUbbToLines } from "./ubb-renderer.js";
|
|
9
|
-
const cc98Blue = fg(0, 130, 202);
|
|
10
|
-
const cc98BlueSoft = fg(94, 180, 232);
|
|
11
|
-
const cc98BlueBg = bg(0, 104, 176);
|
|
12
|
-
const white = fg(245, 250, 255);
|
|
13
|
-
const muted = fg(139, 152, 166);
|
|
14
|
-
const line = fg(52, 84, 112);
|
|
15
|
-
const danger = fg(245, 101, 101);
|
|
16
|
-
const ok = fg(91, 207, 140);
|
|
17
|
-
const mascotMini = [
|
|
18
|
-
" ▄▄▄ ▄▄▄ ▄███",
|
|
19
|
-
" ██▀█████▀█▄ ██",
|
|
20
|
-
"█▀ ▀ ▀ ██ ██",
|
|
21
|
-
"█ ██▄█ █▄▄ ██",
|
|
22
|
-
"██ ▀ ████▄██",
|
|
23
|
-
" ▀██▄▄██████▀"
|
|
24
|
-
];
|
|
25
|
-
const navItems = [
|
|
26
|
-
{ id: "hot", label: "十大", hint: "热门话题" },
|
|
27
|
-
{ id: "favorite", label: "收藏", hint: "版面帖子" },
|
|
28
|
-
{ id: "new", label: "最新", hint: "新帖流" },
|
|
29
|
-
{ id: "boards", label: "版面", hint: "所有分区" },
|
|
30
|
-
{ id: "following", label: "关注", hint: "用户动态" },
|
|
31
|
-
{ id: "messages", label: "消息", hint: "未读与私信" },
|
|
32
|
-
{ id: "me", label: "我的", hint: "当前账号" },
|
|
33
|
-
{ id: "settings", label: "设置", hint: "账号与配置" }
|
|
34
|
-
];
|
|
35
|
-
const settingsItems = [
|
|
36
|
-
{ title: "切换账号", meta: "account", detail: "选择或管理登录账号" },
|
|
37
|
-
{ title: "检查更新", meta: "update", detail: "检查 CC98-CLI 新版本" },
|
|
38
|
-
{ title: "缓存管理", meta: "cache", detail: "查看和清理本地缓存" },
|
|
39
|
-
{ title: "快捷键帮助", meta: "help", detail: "查看所有可用快捷键" },
|
|
40
|
-
{ title: "退出登录", meta: "logout", detail: "清除本地登录信息" }
|
|
41
|
-
];
|
|
42
9
|
export async function runTui() {
|
|
43
10
|
const terminal = new Terminal();
|
|
44
11
|
const tokenStore = new TokenStore();
|
|
45
|
-
const
|
|
12
|
+
const vpnStore = new VpnStore();
|
|
13
|
+
// 读取 VPN 配置
|
|
14
|
+
const vpnConfig = await vpnStore.getConfig();
|
|
15
|
+
const webVpnOptions = vpnConfig.mode === "vpn" || vpnConfig.cookies
|
|
16
|
+
? { mode: vpnConfig.mode, cookies: vpnConfig.cookies }
|
|
17
|
+
: undefined;
|
|
18
|
+
const cc98Client = new Cc98Client({ tokenStore, webVpn: webVpnOptions });
|
|
19
|
+
// 初始化 WebVPN(如果需要)
|
|
20
|
+
if (webVpnOptions) {
|
|
21
|
+
await cc98Client.initWebVpn();
|
|
22
|
+
}
|
|
23
|
+
const client = new CachedCc98Client(cc98Client);
|
|
24
|
+
const state = createInitialState();
|
|
46
25
|
let exitRequested = false;
|
|
47
|
-
const state = {
|
|
48
|
-
mode: "list",
|
|
49
|
-
focus: "nav",
|
|
50
|
-
navIndex: 0,
|
|
51
|
-
itemIndex: 0,
|
|
52
|
-
scroll: 0,
|
|
53
|
-
loading: true,
|
|
54
|
-
loadingMore: false,
|
|
55
|
-
status: "",
|
|
56
|
-
viewTitle: "十大",
|
|
57
|
-
items: [],
|
|
58
|
-
stats: [],
|
|
59
|
-
overview: [],
|
|
60
|
-
modal: null,
|
|
61
|
-
menuIndex: 0,
|
|
62
|
-
menuItems: []
|
|
63
|
-
};
|
|
64
26
|
terminal.enter();
|
|
65
27
|
try {
|
|
66
28
|
await new Promise((resolve) => {
|
|
67
29
|
let closed = false;
|
|
68
|
-
let loadVersion = 0;
|
|
69
30
|
let currentAbort;
|
|
31
|
+
const abortCurrent = () => currentAbort?.abort();
|
|
70
32
|
const nextSignal = () => {
|
|
71
|
-
|
|
33
|
+
abortCurrent();
|
|
72
34
|
currentAbort = new AbortController();
|
|
73
35
|
return currentAbort.signal;
|
|
74
36
|
};
|
|
75
|
-
const render = () =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const signal = nextSignal();
|
|
79
|
-
const nav = navItems[state.navIndex];
|
|
80
|
-
state.viewTitle = nav.label;
|
|
81
|
-
state.loading = true;
|
|
82
|
-
state.error = undefined;
|
|
83
|
-
state.itemIndex = 0;
|
|
84
|
-
state.scroll = 0;
|
|
85
|
-
state.mode = nav.id === "settings" && state.mode === "settings" ? "settings" : "list";
|
|
86
|
-
if (state.mode === "settings") {
|
|
87
|
-
state.focus = "content";
|
|
88
|
-
}
|
|
89
|
-
state.items = [];
|
|
90
|
-
state.stats = [];
|
|
91
|
-
state.topic = undefined;
|
|
92
|
-
state.parentList = undefined;
|
|
93
|
-
state.currentBoard = undefined;
|
|
94
|
-
state.currentChat = undefined;
|
|
95
|
-
render();
|
|
96
|
-
try {
|
|
97
|
-
state.account = await tokenStore.getCurrentAccountName();
|
|
98
|
-
const next = await loadView(client, nav.id, force, signal);
|
|
99
|
-
if (closed || version !== loadVersion) {
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
state.viewTitle = next.title;
|
|
103
|
-
state.items = next.items;
|
|
104
|
-
state.stats = next.stats;
|
|
105
|
-
if (next.overview) {
|
|
106
|
-
state.overview = next.overview;
|
|
107
|
-
}
|
|
108
|
-
state.status = next.status ?? getStatus(state);
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
if (isAbortError(error)) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (closed || version !== loadVersion) {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
118
|
-
state.items = [];
|
|
119
|
-
state.stats = [];
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
if (!closed && version === loadVersion) {
|
|
123
|
-
state.loading = false;
|
|
124
|
-
render();
|
|
125
|
-
}
|
|
37
|
+
const render = () => {
|
|
38
|
+
if (!closed) {
|
|
39
|
+
terminal.render(draw(state, terminal.size()));
|
|
126
40
|
}
|
|
127
41
|
};
|
|
128
42
|
const close = () => {
|
|
129
|
-
if (closed)
|
|
43
|
+
if (closed)
|
|
130
44
|
return;
|
|
131
|
-
}
|
|
132
45
|
closed = true;
|
|
133
46
|
exitRequested = true;
|
|
134
|
-
|
|
47
|
+
abortCurrent();
|
|
135
48
|
offKey();
|
|
136
49
|
offResize();
|
|
137
50
|
resolve();
|
|
138
51
|
};
|
|
52
|
+
const controller = new TuiController(state, client, tokenStore, render, close, nextSignal, abortCurrent);
|
|
139
53
|
const offResize = terminal.onResize(render);
|
|
140
|
-
|
|
141
|
-
const getMenuItems = () => {
|
|
142
|
-
const items = [];
|
|
143
|
-
if (state.mode === "topic") {
|
|
144
|
-
items.push({ label: "刷新", key: "r", action: "refresh" });
|
|
145
|
-
items.push({ label: "返回列表", key: "h", action: "back" });
|
|
146
|
-
}
|
|
147
|
-
else if (state.mode === "list") {
|
|
148
|
-
items.push({ label: "刷新", key: "r", action: "refresh" });
|
|
149
|
-
if (state.currentBoard) {
|
|
150
|
-
items.push({ label: "返回版面列表", key: "h", action: "back" });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return items;
|
|
154
|
-
};
|
|
155
|
-
const offKey = terminal.onKey((key) => {
|
|
156
|
-
// Global: Ctrl+C or q to quit
|
|
157
|
-
if (key === "\u0003" || key === "q") {
|
|
158
|
-
close();
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
// Global: ? for help
|
|
162
|
-
if (key === "?") {
|
|
163
|
-
state.modal = state.modal === "help" ? null : "help";
|
|
164
|
-
render();
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
// Handle modal states
|
|
168
|
-
if (state.modal === "help") {
|
|
169
|
-
if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "?" || key === "\r") {
|
|
170
|
-
state.modal = null;
|
|
171
|
-
render();
|
|
172
|
-
}
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
if (state.modal === "menu") {
|
|
176
|
-
if (key === "j" || key === "\x1b[B") {
|
|
177
|
-
state.menuIndex = Math.min(state.menuItems.length - 1, state.menuIndex + 1);
|
|
178
|
-
render();
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
if (key === "k" || key === "\x1b[A") {
|
|
182
|
-
state.menuIndex = Math.max(0, state.menuIndex - 1);
|
|
183
|
-
render();
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (key === "\r" || key === "l" || key === "\x1b[C") {
|
|
187
|
-
const selected = state.menuItems[state.menuIndex];
|
|
188
|
-
state.modal = null;
|
|
189
|
-
if (selected?.action === "refresh") {
|
|
190
|
-
void load(true);
|
|
191
|
-
}
|
|
192
|
-
else if (selected?.action === "back") {
|
|
193
|
-
if (state.mode === "topic") {
|
|
194
|
-
currentAbort?.abort();
|
|
195
|
-
state.mode = "list";
|
|
196
|
-
state.focus = "content";
|
|
197
|
-
state.status = getStatus(state);
|
|
198
|
-
render();
|
|
199
|
-
}
|
|
200
|
-
else if (state.parentList) {
|
|
201
|
-
currentAbort?.abort();
|
|
202
|
-
restoreParentList(state);
|
|
203
|
-
render();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "o") {
|
|
209
|
-
state.modal = null;
|
|
210
|
-
render();
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
// Topic mode
|
|
216
|
-
if (state.mode === "topic") {
|
|
217
|
-
if (/^\d$/.test(key) && state.topic) {
|
|
218
|
-
state.topic.floorInput = `${state.topic.floorInput}${key}`.slice(0, 6);
|
|
219
|
-
state.status = `跳转到 ${state.topic.floorInput} 楼:Enter 确认 Esc 取消`;
|
|
220
|
-
render();
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
if (key === "\x7f" && state.topic?.floorInput) {
|
|
224
|
-
state.topic.floorInput = state.topic.floorInput.slice(0, -1);
|
|
225
|
-
state.status = state.topic.floorInput
|
|
226
|
-
? `跳转到 ${state.topic.floorInput} 楼:Enter 确认 Esc 取消`
|
|
227
|
-
: getStatus(state);
|
|
228
|
-
render();
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (key === "\r" && state.topic?.floorInput) {
|
|
232
|
-
const floor = Number(state.topic.floorInput);
|
|
233
|
-
state.topic.floorInput = "";
|
|
234
|
-
if (Number.isInteger(floor) && floor > 0) {
|
|
235
|
-
void jumpToTopicFloor(client, state, floor, render, nextSignal());
|
|
236
|
-
}
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
if ((key === "]" || key === "】") && state.topic) {
|
|
240
|
-
jumpRelativeTopicFloor(state, 1);
|
|
241
|
-
state.status = getStatus(state);
|
|
242
|
-
render();
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
if ((key === "[" || key === "【") && state.topic) {
|
|
246
|
-
jumpRelativeTopicFloor(state, -1);
|
|
247
|
-
state.status = getStatus(state);
|
|
248
|
-
render();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
if (key === "h" || key === "\x1b[D") {
|
|
252
|
-
currentAbort?.abort();
|
|
253
|
-
state.mode = "list";
|
|
254
|
-
state.focus = "content";
|
|
255
|
-
state.status = getStatus(state);
|
|
256
|
-
render();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (key === "\x1b" && state.topic?.floorInput) {
|
|
260
|
-
state.topic.floorInput = "";
|
|
261
|
-
state.status = getStatus(state);
|
|
262
|
-
render();
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
if (key === "j" || key === "\x1b[B") {
|
|
266
|
-
const maxScroll = Math.max(0, (state.topic?.lines.length ?? 0) - 1);
|
|
267
|
-
const wasAtEnd = state.scroll >= maxScroll;
|
|
268
|
-
state.scroll = Math.min(maxScroll, state.scroll + 1);
|
|
269
|
-
render();
|
|
270
|
-
if (wasAtEnd && state.topic?.hasMore && !state.loadingMore) {
|
|
271
|
-
void loadNextTopicPage(client, state, render, nextSignal(), true);
|
|
272
|
-
}
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (key === "k" || key === "\x1b[A") {
|
|
276
|
-
state.scroll = Math.max(0, state.scroll - 1);
|
|
277
|
-
render();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (key === "n" || key === " ") {
|
|
281
|
-
void loadNextTopicPage(client, state, render, nextSignal());
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
if (key === "r") {
|
|
285
|
-
if (state.topic) {
|
|
286
|
-
void openTopic(client, state, state.topic.topicId, render, true, nextSignal());
|
|
287
|
-
}
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
if (key === "o") {
|
|
291
|
-
state.modal = "menu";
|
|
292
|
-
state.menuItems = getMenuItems();
|
|
293
|
-
state.menuIndex = 0;
|
|
294
|
-
render();
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
// Settings mode
|
|
300
|
-
if (state.mode === "settings") {
|
|
301
|
-
if (key === "j" || key === "\x1b[B") {
|
|
302
|
-
state.itemIndex = Math.min(settingsItems.length - 1, state.itemIndex + 1);
|
|
303
|
-
render();
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
if (key === "k" || key === "\x1b[A") {
|
|
307
|
-
state.itemIndex = Math.max(0, state.itemIndex - 1);
|
|
308
|
-
render();
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
if (key === "h" || key === "\x1b[D") {
|
|
312
|
-
state.mode = "list";
|
|
313
|
-
state.focus = "nav";
|
|
314
|
-
state.status = getStatus(state);
|
|
315
|
-
render();
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
if (key === "l" || key === "\x1b[C" || key === "\r") {
|
|
319
|
-
const selected = settingsItems[state.itemIndex];
|
|
320
|
-
if (selected?.meta === "help") {
|
|
321
|
-
state.modal = "help";
|
|
322
|
-
render();
|
|
323
|
-
}
|
|
324
|
-
else if (selected?.meta === "cache") {
|
|
325
|
-
state.status = "正在清理缓存...";
|
|
326
|
-
render();
|
|
327
|
-
void client.clearCache().then(() => {
|
|
328
|
-
state.status = "缓存已清理";
|
|
329
|
-
void load(true);
|
|
330
|
-
}).catch(() => {
|
|
331
|
-
state.status = "缓存清理失败";
|
|
332
|
-
render();
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
else if (selected?.meta === "logout") {
|
|
336
|
-
state.status = "退出登录功能开发中...";
|
|
337
|
-
render();
|
|
338
|
-
}
|
|
339
|
-
else if (selected?.meta === "account") {
|
|
340
|
-
state.status = "账号切换功能开发中...";
|
|
341
|
-
render();
|
|
342
|
-
}
|
|
343
|
-
else if (selected?.meta === "update") {
|
|
344
|
-
state.status = "正在检查 GitHub Release...";
|
|
345
|
-
render();
|
|
346
|
-
void checkForUpdate().then((result) => {
|
|
347
|
-
state.status = result.message;
|
|
348
|
-
render();
|
|
349
|
-
}).catch((error) => {
|
|
350
|
-
state.status = error instanceof Error ? error.message : "检查更新失败";
|
|
351
|
-
render();
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
// Nav focus
|
|
359
|
-
if (state.focus === "nav") {
|
|
360
|
-
if (key === "j" || key === "\x1b[B") {
|
|
361
|
-
state.navIndex = Math.min(navItems.length - 1, state.navIndex + 1);
|
|
362
|
-
void load();
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
if (key === "k" || key === "\x1b[A") {
|
|
366
|
-
state.navIndex = Math.max(0, state.navIndex - 1);
|
|
367
|
-
void load();
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
if (key === "l" || key === "\x1b[C") {
|
|
371
|
-
if (!state.loading && state.items.length > 0) {
|
|
372
|
-
if (navItems[state.navIndex]?.id === "settings") {
|
|
373
|
-
state.mode = "settings";
|
|
374
|
-
}
|
|
375
|
-
state.focus = "content";
|
|
376
|
-
state.status = getStatus(state);
|
|
377
|
-
render();
|
|
378
|
-
}
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
if (key === "\r") {
|
|
382
|
-
if (!state.loading && state.items.length > 0) {
|
|
383
|
-
if (navItems[state.navIndex]?.id === "settings") {
|
|
384
|
-
state.mode = "settings";
|
|
385
|
-
}
|
|
386
|
-
state.focus = "content";
|
|
387
|
-
state.itemIndex = 0;
|
|
388
|
-
state.status = getStatus(state);
|
|
389
|
-
render();
|
|
390
|
-
}
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
if (key === "r") {
|
|
394
|
-
void load(true);
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
// Content focus
|
|
400
|
-
if (key === "j" || key === "\x1b[B") {
|
|
401
|
-
state.itemIndex = Math.min(Math.max(0, state.items.length - 1), state.itemIndex + 1);
|
|
402
|
-
render();
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (key === "k" || key === "\x1b[A") {
|
|
406
|
-
state.itemIndex = Math.max(0, state.itemIndex - 1);
|
|
407
|
-
render();
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
if (key === "h" || key === "\x1b[D") {
|
|
411
|
-
if (state.parentList) {
|
|
412
|
-
currentAbort?.abort();
|
|
413
|
-
restoreParentList(state);
|
|
414
|
-
render();
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
currentAbort?.abort();
|
|
418
|
-
state.focus = "nav";
|
|
419
|
-
state.status = getStatus(state);
|
|
420
|
-
render();
|
|
421
|
-
}
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
if (key === "\x1b") {
|
|
425
|
-
if (state.parentList) {
|
|
426
|
-
currentAbort?.abort();
|
|
427
|
-
restoreParentList(state);
|
|
428
|
-
render();
|
|
429
|
-
}
|
|
430
|
-
else {
|
|
431
|
-
currentAbort?.abort();
|
|
432
|
-
state.focus = "nav";
|
|
433
|
-
state.status = getStatus(state);
|
|
434
|
-
render();
|
|
435
|
-
}
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
if (key === "l" || key === "\x1b[C") {
|
|
439
|
-
const selected = state.items[state.itemIndex];
|
|
440
|
-
if (selected?.topicId !== undefined) {
|
|
441
|
-
void openTopic(client, state, selected.topicId, render, false, nextSignal());
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
if (selected?.boardId !== undefined) {
|
|
445
|
-
void openBoard(client, state, selected.boardId, selected.title, render, false, nextSignal());
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if (selected?.chatUserId !== undefined) {
|
|
449
|
-
void openChat(client, state, selected.chatUserId, selected.title, render, false, nextSignal());
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
state.status = "当前条目不可进入";
|
|
453
|
-
render();
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
if (key === "\r") {
|
|
457
|
-
const selected = state.items[state.itemIndex];
|
|
458
|
-
if (selected?.topicId !== undefined) {
|
|
459
|
-
void openTopic(client, state, selected.topicId, render, false, nextSignal());
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
if (selected?.boardId !== undefined) {
|
|
463
|
-
void openBoard(client, state, selected.boardId, selected.title, render, false, nextSignal());
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
if (selected?.chatUserId !== undefined) {
|
|
467
|
-
void openChat(client, state, selected.chatUserId, selected.title, render, false, nextSignal());
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
state.status = "当前条目不可进入";
|
|
471
|
-
render();
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if ((key === "n" || key === " ") && state.currentChat) {
|
|
475
|
-
void loadNextChatPage(client, state, render, nextSignal());
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
if (key === "r") {
|
|
479
|
-
if (state.currentBoard) {
|
|
480
|
-
void openBoard(client, state, state.currentBoard.boardId, state.currentBoard.title, render, true, nextSignal(), false);
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
if (state.currentChat) {
|
|
484
|
-
void openChat(client, state, state.currentChat.userId, state.currentChat.title, render, true, nextSignal(), false);
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
void load(true);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
if (key === "o") {
|
|
491
|
-
state.modal = "menu";
|
|
492
|
-
state.menuItems = getMenuItems();
|
|
493
|
-
state.menuIndex = 0;
|
|
494
|
-
render();
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
});
|
|
54
|
+
const offKey = terminal.onKey((key) => controller.handleKey(key));
|
|
498
55
|
render();
|
|
499
|
-
void load();
|
|
56
|
+
void controller.load();
|
|
500
57
|
});
|
|
501
58
|
}
|
|
502
59
|
finally {
|
|
@@ -507,1259 +64,4 @@ export async function runTui() {
|
|
|
507
64
|
}
|
|
508
65
|
}
|
|
509
66
|
}
|
|
510
|
-
async function openTopic(client, state, topicId, render, force = false, signal) {
|
|
511
|
-
state.mode = "topic";
|
|
512
|
-
state.loading = true;
|
|
513
|
-
state.loadingMore = false;
|
|
514
|
-
state.error = undefined;
|
|
515
|
-
state.scroll = 0;
|
|
516
|
-
state.topic = {
|
|
517
|
-
topicId,
|
|
518
|
-
title: `#${topicId}`,
|
|
519
|
-
meta: "",
|
|
520
|
-
lines: [],
|
|
521
|
-
posts: [],
|
|
522
|
-
loaded: 0,
|
|
523
|
-
size: 10,
|
|
524
|
-
hasMore: true,
|
|
525
|
-
imageCount: 0,
|
|
526
|
-
linkCount: 0,
|
|
527
|
-
floorInput: ""
|
|
528
|
-
};
|
|
529
|
-
state.status = "正在打开帖子...";
|
|
530
|
-
render();
|
|
531
|
-
try {
|
|
532
|
-
const [topicRaw, postsRaw] = await Promise.all([
|
|
533
|
-
client.getTopic(topicId, force, signal),
|
|
534
|
-
client.getTopicPosts(topicId, 0, 10, force, signal)
|
|
535
|
-
]);
|
|
536
|
-
const topic = asObject(topicRaw);
|
|
537
|
-
const posts = asArray(postsRaw);
|
|
538
|
-
const reader = buildTopicReader(topicId, topic, posts, 10);
|
|
539
|
-
state.topic = reader;
|
|
540
|
-
state.viewTitle = reader.title;
|
|
541
|
-
state.status = reader.hasMore
|
|
542
|
-
? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
|
|
543
|
-
: "j/k 滚动 h/Esc 返回 r 刷新";
|
|
544
|
-
}
|
|
545
|
-
catch (error) {
|
|
546
|
-
if (isAbortError(error)) {
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
550
|
-
state.status = state.parentList
|
|
551
|
-
? "版面读取失败;Esc/Backspace 返回版面列表 h 返回左栏 r 重试"
|
|
552
|
-
: "版面读取失败;h 返回左栏 r 重试";
|
|
553
|
-
}
|
|
554
|
-
finally {
|
|
555
|
-
state.loading = false;
|
|
556
|
-
render();
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
async function openBoard(client, state, boardId, boardTitle, render, force = false, signal, pushParent = true) {
|
|
560
|
-
if (pushParent) {
|
|
561
|
-
state.parentList = {
|
|
562
|
-
title: state.viewTitle,
|
|
563
|
-
items: state.items,
|
|
564
|
-
stats: state.stats,
|
|
565
|
-
itemIndex: state.itemIndex,
|
|
566
|
-
status: state.status
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
state.mode = "list";
|
|
570
|
-
state.focus = "content";
|
|
571
|
-
state.loading = true;
|
|
572
|
-
state.error = undefined;
|
|
573
|
-
state.itemIndex = 0;
|
|
574
|
-
state.scroll = 0;
|
|
575
|
-
state.topic = undefined;
|
|
576
|
-
state.currentChat = undefined;
|
|
577
|
-
state.currentBoard = { boardId, title: boardTitle };
|
|
578
|
-
state.viewTitle = boardTitle;
|
|
579
|
-
state.items = [];
|
|
580
|
-
state.stats = [
|
|
581
|
-
{ title: "版面", detail: `#${boardId}` },
|
|
582
|
-
{ title: "缓存", detail: "topics 30s" }
|
|
583
|
-
];
|
|
584
|
-
state.status = "正在读取版面帖子...";
|
|
585
|
-
render();
|
|
586
|
-
try {
|
|
587
|
-
const topics = asArray(await client.getBoardTopics(boardId, 0, 12, false, force, signal));
|
|
588
|
-
state.items = topics.map((topic) => topicItem(topic));
|
|
589
|
-
state.stats = [
|
|
590
|
-
{ title: "版面", detail: `#${boardId}` },
|
|
591
|
-
{ title: "主题", detail: `${topics.length} 条` },
|
|
592
|
-
{ title: "缓存", detail: "topics 30s" }
|
|
593
|
-
];
|
|
594
|
-
state.status = "版面帖子:j/k 选择 l 打开帖子 h 返回 r 刷新";
|
|
595
|
-
}
|
|
596
|
-
catch (error) {
|
|
597
|
-
if (isAbortError(error)) {
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
601
|
-
}
|
|
602
|
-
finally {
|
|
603
|
-
state.loading = false;
|
|
604
|
-
render();
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
async function openChat(client, state, userId, title, render, force = false, signal, pushParent = true) {
|
|
608
|
-
if (pushParent) {
|
|
609
|
-
state.parentList = {
|
|
610
|
-
title: state.viewTitle,
|
|
611
|
-
items: state.items,
|
|
612
|
-
stats: state.stats,
|
|
613
|
-
itemIndex: state.itemIndex,
|
|
614
|
-
status: state.status
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
state.mode = "list";
|
|
618
|
-
state.focus = "content";
|
|
619
|
-
state.loading = true;
|
|
620
|
-
state.error = undefined;
|
|
621
|
-
state.itemIndex = 0;
|
|
622
|
-
state.scroll = 0;
|
|
623
|
-
state.topic = undefined;
|
|
624
|
-
state.currentBoard = undefined;
|
|
625
|
-
state.currentChat = { userId, title, loaded: 0, size: 10, hasMore: true };
|
|
626
|
-
state.viewTitle = title;
|
|
627
|
-
state.items = [];
|
|
628
|
-
state.stats = [
|
|
629
|
-
{ title: "用户", detail: `#${userId}` },
|
|
630
|
-
{ title: "缓存", detail: "history 15s" }
|
|
631
|
-
];
|
|
632
|
-
state.status = "正在读取私信...";
|
|
633
|
-
render();
|
|
634
|
-
try {
|
|
635
|
-
const messages = asArray(await client.getChatHistory(userId, 0, 10, force, signal));
|
|
636
|
-
state.items = chatMessageItems(messages, title, userId);
|
|
637
|
-
state.currentChat.loaded = messages.length;
|
|
638
|
-
state.currentChat.hasMore = messages.length === state.currentChat.size;
|
|
639
|
-
state.itemIndex = Math.max(0, state.items.length - 1);
|
|
640
|
-
state.stats = [
|
|
641
|
-
{ title: "用户", detail: `#${userId}` },
|
|
642
|
-
{ title: "消息", detail: `${messages.length} 条` },
|
|
643
|
-
{ title: "缓存", detail: "history 15s" }
|
|
644
|
-
];
|
|
645
|
-
state.status = state.currentChat.hasMore
|
|
646
|
-
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
647
|
-
: "私信:j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
648
|
-
}
|
|
649
|
-
catch (error) {
|
|
650
|
-
if (isAbortError(error)) {
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
654
|
-
state.status = "私信读取失败;Esc/Backspace 返回联系人 h 返回左栏 r 重试";
|
|
655
|
-
}
|
|
656
|
-
finally {
|
|
657
|
-
state.loading = false;
|
|
658
|
-
render();
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
async function loadNextChatPage(client, state, render, signal) {
|
|
662
|
-
if (!state.currentChat || state.loadingMore || !state.currentChat.hasMore) {
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
state.loadingMore = true;
|
|
666
|
-
state.status = "正在读取更早私信...";
|
|
667
|
-
render();
|
|
668
|
-
try {
|
|
669
|
-
const chat = state.currentChat;
|
|
670
|
-
const messages = asArray(await client.getChatHistory(chat.userId, chat.loaded, chat.size, false, signal));
|
|
671
|
-
const olderItems = chatMessageItems(messages, chat.title, chat.userId);
|
|
672
|
-
state.items = [...olderItems, ...state.items];
|
|
673
|
-
state.itemIndex += olderItems.length;
|
|
674
|
-
state.scroll += olderItems.length;
|
|
675
|
-
chat.loaded += messages.length;
|
|
676
|
-
chat.hasMore = messages.length === chat.size;
|
|
677
|
-
state.stats = [
|
|
678
|
-
{ title: "用户", detail: `#${chat.userId}` },
|
|
679
|
-
{ title: "消息", detail: `${chat.loaded} 条` },
|
|
680
|
-
{ title: "缓存", detail: "history 15s" }
|
|
681
|
-
];
|
|
682
|
-
state.status = chat.hasMore
|
|
683
|
-
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
684
|
-
: "已到最早私信;j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
685
|
-
}
|
|
686
|
-
catch (error) {
|
|
687
|
-
if (isAbortError(error)) {
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
691
|
-
state.status = "更早私信读取失败;n/Space 重试 Esc/Backspace 返回联系人";
|
|
692
|
-
}
|
|
693
|
-
finally {
|
|
694
|
-
state.loadingMore = false;
|
|
695
|
-
render();
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
function restoreParentList(state) {
|
|
699
|
-
if (!state.parentList) {
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
const parent = state.parentList;
|
|
703
|
-
state.mode = "list";
|
|
704
|
-
state.focus = "content";
|
|
705
|
-
state.loading = false;
|
|
706
|
-
state.loadingMore = false;
|
|
707
|
-
state.error = undefined;
|
|
708
|
-
state.topic = undefined;
|
|
709
|
-
state.currentBoard = undefined;
|
|
710
|
-
state.currentChat = undefined;
|
|
711
|
-
state.parentList = undefined;
|
|
712
|
-
state.viewTitle = parent.title;
|
|
713
|
-
state.items = parent.items;
|
|
714
|
-
state.stats = parent.stats;
|
|
715
|
-
state.itemIndex = parent.itemIndex;
|
|
716
|
-
state.status = parent.status;
|
|
717
|
-
}
|
|
718
|
-
async function loadNextTopicPage(client, state, render, signal, advanceAfterLoad = false) {
|
|
719
|
-
if (!state.topic || state.loadingMore || !state.topic.hasMore) {
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
state.loadingMore = true;
|
|
723
|
-
state.status = "正在加载下一页...";
|
|
724
|
-
render();
|
|
725
|
-
try {
|
|
726
|
-
const posts = asArray(await client.getTopicPosts(state.topic.topicId, state.topic.loaded, state.topic.size, false, signal));
|
|
727
|
-
const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), state.topic.lines.length);
|
|
728
|
-
state.topic.lines.push(...next.lines);
|
|
729
|
-
state.topic.posts.push(...next.posts);
|
|
730
|
-
state.topic.imageCount += next.imageCount;
|
|
731
|
-
state.topic.linkCount += next.linkCount;
|
|
732
|
-
state.topic.loaded += posts.length;
|
|
733
|
-
state.topic.hasMore = posts.length === state.topic.size;
|
|
734
|
-
if (advanceAfterLoad && posts.length > 0) {
|
|
735
|
-
state.scroll = Math.min(Math.max(0, state.topic.lines.length - 1), state.scroll + 1);
|
|
736
|
-
}
|
|
737
|
-
state.status = state.topic.hasMore
|
|
738
|
-
? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
|
|
739
|
-
: "已到最后一页 j/k 滚动 h/Esc 返回 r 刷新";
|
|
740
|
-
}
|
|
741
|
-
catch (error) {
|
|
742
|
-
if (isAbortError(error)) {
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
746
|
-
}
|
|
747
|
-
finally {
|
|
748
|
-
state.loadingMore = false;
|
|
749
|
-
render();
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
function currentTopicWidthEstimate() {
|
|
753
|
-
return Number(process.env.COLUMNS) > 90 ? 56 : 44;
|
|
754
|
-
}
|
|
755
|
-
function buildTopicReader(topicId, topic, posts, size) {
|
|
756
|
-
const title = String(topic.title ?? `#${topicId}`);
|
|
757
|
-
const meta = [
|
|
758
|
-
topic.userName,
|
|
759
|
-
topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
|
|
760
|
-
topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
|
|
761
|
-
].filter(Boolean).join(" · ");
|
|
762
|
-
const rendered = renderPosts(posts, currentTopicWidthEstimate());
|
|
763
|
-
return {
|
|
764
|
-
topicId,
|
|
765
|
-
title,
|
|
766
|
-
meta,
|
|
767
|
-
lines: rendered.lines,
|
|
768
|
-
posts: rendered.posts,
|
|
769
|
-
loaded: posts.length,
|
|
770
|
-
size,
|
|
771
|
-
hasMore: posts.length === size,
|
|
772
|
-
imageCount: rendered.imageCount,
|
|
773
|
-
linkCount: rendered.linkCount,
|
|
774
|
-
floorInput: ""
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
function renderPosts(posts, width, lineOffset = 0) {
|
|
778
|
-
const lines = [];
|
|
779
|
-
const entries = [];
|
|
780
|
-
let imageCount = 0;
|
|
781
|
-
let linkCount = 0;
|
|
782
|
-
posts.forEach((postRaw) => {
|
|
783
|
-
const post = asObject(postRaw);
|
|
784
|
-
const lineStart = lineOffset + lines.length;
|
|
785
|
-
const postLines = [];
|
|
786
|
-
const floorNumber = asNumber(post.floor);
|
|
787
|
-
const floor = floorNumber !== undefined ? `#${floorNumber}` : "#?";
|
|
788
|
-
const author = String(post.userName ?? "匿名");
|
|
789
|
-
const time = typeof post.time === "string" ? post.time.replace("T", " ").slice(0, 16) : "";
|
|
790
|
-
const likeCount = asNumber(post.likeCount) ?? 0;
|
|
791
|
-
const dislikeCount = asNumber(post.dislikeCount) ?? 0;
|
|
792
|
-
const like = likeCount > 0 ? ` · ${likeCount} 赞` : "";
|
|
793
|
-
const push = (text, kind, extra = {}) => {
|
|
794
|
-
const line = lineOffset + lines.length;
|
|
795
|
-
lines.push(text);
|
|
796
|
-
postLines.push({
|
|
797
|
-
line,
|
|
798
|
-
row: postLines.length,
|
|
799
|
-
floor: floorNumber,
|
|
800
|
-
kind,
|
|
801
|
-
text,
|
|
802
|
-
...extra
|
|
803
|
-
});
|
|
804
|
-
};
|
|
805
|
-
push(`${floor} ${author}${time ? ` · ${time}` : ""}${like}`, "header");
|
|
806
|
-
push("─".repeat(Math.max(8, width)), "divider");
|
|
807
|
-
const content = typeof post.content === "string" ? post.content : "";
|
|
808
|
-
const rendered = renderUbbToLines(content, width);
|
|
809
|
-
rendered.lines.forEach((renderedLine) => {
|
|
810
|
-
const imageIndex = parseBracketIndex(renderedLine, "image");
|
|
811
|
-
const linkIndex = parseBracketIndex(renderedLine, "link");
|
|
812
|
-
const kind = renderedLine.trim() === ""
|
|
813
|
-
? "blank"
|
|
814
|
-
: imageIndex !== undefined
|
|
815
|
-
? "image"
|
|
816
|
-
: linkIndex !== undefined
|
|
817
|
-
? "link"
|
|
818
|
-
: renderedLine.startsWith("│ ")
|
|
819
|
-
? "quote"
|
|
820
|
-
: "text";
|
|
821
|
-
push(renderedLine, kind, {
|
|
822
|
-
imageIndex,
|
|
823
|
-
imageUrl: imageIndex !== undefined ? rendered.images[imageIndex - 1] : undefined,
|
|
824
|
-
linkIndex,
|
|
825
|
-
linkUrl: linkIndex !== undefined ? rendered.links[linkIndex - 1] : undefined
|
|
826
|
-
});
|
|
827
|
-
});
|
|
828
|
-
push("", "blank");
|
|
829
|
-
const preview = rendered.lines.find((value) => value.trim() &&
|
|
830
|
-
!value.startsWith("[image ") &&
|
|
831
|
-
!value.startsWith("[link ")) ?? "";
|
|
832
|
-
entries.push({
|
|
833
|
-
id: asNumber(post.id),
|
|
834
|
-
floor: floorNumber,
|
|
835
|
-
author,
|
|
836
|
-
time,
|
|
837
|
-
likeCount,
|
|
838
|
-
dislikeCount,
|
|
839
|
-
rating: formatRating(post),
|
|
840
|
-
preview,
|
|
841
|
-
lineStart,
|
|
842
|
-
lineEnd: lineOffset + lines.length - 1,
|
|
843
|
-
imageCount: rendered.images.length,
|
|
844
|
-
linkCount: rendered.links.length,
|
|
845
|
-
images: rendered.images,
|
|
846
|
-
links: rendered.links,
|
|
847
|
-
lines: postLines
|
|
848
|
-
});
|
|
849
|
-
imageCount += rendered.images.length;
|
|
850
|
-
linkCount += rendered.links.length;
|
|
851
|
-
});
|
|
852
|
-
return { lines, posts: entries, imageCount, linkCount };
|
|
853
|
-
}
|
|
854
|
-
async function jumpToTopicFloor(client, state, floor, render, signal) {
|
|
855
|
-
const topic = state.topic;
|
|
856
|
-
if (!topic) {
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
const loaded = findTopicPostByFloor(topic, floor);
|
|
860
|
-
if (loaded) {
|
|
861
|
-
state.scroll = loaded.lineStart;
|
|
862
|
-
state.status = getStatus(state);
|
|
863
|
-
render();
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
const from = Math.floor((floor - 1) / topic.size) * topic.size;
|
|
867
|
-
state.loadingMore = true;
|
|
868
|
-
state.status = `正在读取 ${floor} 楼...`;
|
|
869
|
-
render();
|
|
870
|
-
try {
|
|
871
|
-
const posts = asArray(await client.getTopicPosts(topic.topicId, from, topic.size, false, signal));
|
|
872
|
-
const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), topic.lines.length);
|
|
873
|
-
topic.lines.push(...next.lines);
|
|
874
|
-
topic.posts.push(...next.posts);
|
|
875
|
-
topic.posts.sort((left, right) => (left.floor ?? 0) - (right.floor ?? 0));
|
|
876
|
-
topic.imageCount += next.imageCount;
|
|
877
|
-
topic.linkCount += next.linkCount;
|
|
878
|
-
topic.loaded = Math.max(topic.loaded, from + posts.length);
|
|
879
|
-
topic.hasMore = posts.length === topic.size;
|
|
880
|
-
const target = findTopicPostByFloor(topic, floor);
|
|
881
|
-
if (target) {
|
|
882
|
-
state.scroll = target.lineStart;
|
|
883
|
-
state.status = getStatus(state);
|
|
884
|
-
}
|
|
885
|
-
else {
|
|
886
|
-
state.status = `未找到 ${floor} 楼`;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
catch (error) {
|
|
890
|
-
if (!isAbortError(error)) {
|
|
891
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
finally {
|
|
895
|
-
state.loadingMore = false;
|
|
896
|
-
render();
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
function jumpRelativeTopicFloor(state, delta) {
|
|
900
|
-
const topic = state.topic;
|
|
901
|
-
if (!topic || topic.posts.length === 0) {
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
const current = currentTopicPost(topic, state.scroll);
|
|
905
|
-
const currentIndex = current ? topic.posts.indexOf(current) : 0;
|
|
906
|
-
const next = topic.posts[Math.min(topic.posts.length - 1, Math.max(0, currentIndex + delta))];
|
|
907
|
-
if (next) {
|
|
908
|
-
state.scroll = next.lineStart;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
function findTopicPostByFloor(topic, floor) {
|
|
912
|
-
return topic.posts.find((entry) => entry.floor === floor);
|
|
913
|
-
}
|
|
914
|
-
function currentTopicPost(topic, scroll) {
|
|
915
|
-
return topic.posts.find((entry) => scroll >= entry.lineStart && scroll <= entry.lineEnd) ??
|
|
916
|
-
[...topic.posts].reverse().find((entry) => entry.lineStart <= scroll) ??
|
|
917
|
-
topic.posts[0];
|
|
918
|
-
}
|
|
919
|
-
function currentTopicLine(topic, scroll) {
|
|
920
|
-
const post = currentTopicPost(topic, scroll);
|
|
921
|
-
if (!post) {
|
|
922
|
-
return undefined;
|
|
923
|
-
}
|
|
924
|
-
return post.lines.find((entry) => entry.line === scroll) ??
|
|
925
|
-
post.lines.find((entry) => entry.line > scroll && entry.kind !== "blank") ??
|
|
926
|
-
post.lines.at(-1);
|
|
927
|
-
}
|
|
928
|
-
function lineKindLabel(kind) {
|
|
929
|
-
switch (kind) {
|
|
930
|
-
case "header":
|
|
931
|
-
return "楼层标题";
|
|
932
|
-
case "divider":
|
|
933
|
-
return "分隔线";
|
|
934
|
-
case "quote":
|
|
935
|
-
return "引用";
|
|
936
|
-
case "image":
|
|
937
|
-
return "图片";
|
|
938
|
-
case "link":
|
|
939
|
-
return "链接";
|
|
940
|
-
case "blank":
|
|
941
|
-
return "空行";
|
|
942
|
-
case "text":
|
|
943
|
-
return "正文";
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
function parseBracketIndex(value, label) {
|
|
947
|
-
const match = new RegExp(`\\[${label} (\\d+)`).exec(value);
|
|
948
|
-
return match ? Number(match[1]) : undefined;
|
|
949
|
-
}
|
|
950
|
-
function formatRating(post) {
|
|
951
|
-
const value = post.rating ?? post.ratingCount ?? post.wealth ?? post.score;
|
|
952
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
953
|
-
return String(value);
|
|
954
|
-
}
|
|
955
|
-
if (typeof value === "string" && value.trim()) {
|
|
956
|
-
return value.trim();
|
|
957
|
-
}
|
|
958
|
-
return undefined;
|
|
959
|
-
}
|
|
960
|
-
async function loadView(client, view, force, signal) {
|
|
961
|
-
switch (view) {
|
|
962
|
-
case "hot": {
|
|
963
|
-
const [index, unread] = await Promise.all([
|
|
964
|
-
client.getForumIndex(force, signal),
|
|
965
|
-
client.getUnreadCount(force, signal)
|
|
966
|
-
]);
|
|
967
|
-
const indexObject = asObject(index);
|
|
968
|
-
const unreadObject = asObject(unread);
|
|
969
|
-
const hotTopics = asArray(indexObject.hotTopic ?? indexObject.manualHotTopic);
|
|
970
|
-
return {
|
|
971
|
-
title: "十大",
|
|
972
|
-
items: hotTopics.map((topic) => topicItem(topic)),
|
|
973
|
-
stats: unreadStats(unreadObject),
|
|
974
|
-
overview: overviewStats(indexObject, unreadObject)
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
case "new": {
|
|
978
|
-
const topics = asArray(await client.getNewTopics(0, 12, force, signal));
|
|
979
|
-
return {
|
|
980
|
-
title: "最新",
|
|
981
|
-
items: topics.map((topic) => topicItem(topic)),
|
|
982
|
-
stats: [{ title: "新帖流", detail: `${topics.length} 条` }]
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
case "boards": {
|
|
986
|
-
const sections = asArray(await client.getAllBoards(force, signal));
|
|
987
|
-
const boards = flattenBoards(sections).slice(0, 14);
|
|
988
|
-
return {
|
|
989
|
-
title: "版面",
|
|
990
|
-
items: boards,
|
|
991
|
-
stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${flattenBoards(sections).length}` }],
|
|
992
|
-
status: "版面:j/k 选择 l 进入版面 h 返回 r 刷新"
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
case "following": {
|
|
996
|
-
const topics = asArray(await client.getFolloweeTopics(0, 12, force, signal));
|
|
997
|
-
return {
|
|
998
|
-
title: "关注",
|
|
999
|
-
items: topics.map((topic) => topicItem(topic)),
|
|
1000
|
-
stats: [
|
|
1001
|
-
{ title: "关注动态", detail: `${topics.length} 条` },
|
|
1002
|
-
{ title: "缓存", detail: "30s" }
|
|
1003
|
-
],
|
|
1004
|
-
status: "关注:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
case "favorite": {
|
|
1008
|
-
const [meRaw, sectionsRaw] = await Promise.all([
|
|
1009
|
-
client.getMe(force, signal),
|
|
1010
|
-
client.getAllBoards(false, signal)
|
|
1011
|
-
]);
|
|
1012
|
-
const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
|
|
1013
|
-
const allBoards = flattenBoards(asArray(sectionsRaw));
|
|
1014
|
-
const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
|
|
1015
|
-
const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
|
|
1016
|
-
const board = boardById.get(boardId);
|
|
1017
|
-
const topics = asArray(await client.getBoardTopics(boardId, 0, 3, false, force, signal));
|
|
1018
|
-
return topics.map((topic) => topicItem(topic, board));
|
|
1019
|
-
});
|
|
1020
|
-
const items = topicGroups.flat().sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0)).slice(0, 18);
|
|
1021
|
-
return {
|
|
1022
|
-
title: "收藏",
|
|
1023
|
-
items,
|
|
1024
|
-
stats: [
|
|
1025
|
-
{ title: "收藏版面", detail: `${customBoards.length} 个` },
|
|
1026
|
-
{ title: "主题", detail: `${items.length} 条` },
|
|
1027
|
-
{ title: "缓存", detail: "boards 24h / topics 30s" }
|
|
1028
|
-
],
|
|
1029
|
-
status: "收藏:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
1030
|
-
};
|
|
1031
|
-
}
|
|
1032
|
-
case "messages": {
|
|
1033
|
-
const [unread, recent] = await Promise.all([
|
|
1034
|
-
client.getUnreadCount(force, signal),
|
|
1035
|
-
client.getRecentChats(0, 10, force, signal)
|
|
1036
|
-
]);
|
|
1037
|
-
const unreadObject = asObject(unread);
|
|
1038
|
-
const chats = asArray(recent);
|
|
1039
|
-
const userNames = await loadChatUserNames(client, chats, force, signal);
|
|
1040
|
-
return {
|
|
1041
|
-
title: "消息",
|
|
1042
|
-
items: chats.length > 0 ? chats.map((chat) => chatItem(chat, userNames)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
|
|
1043
|
-
stats: unreadStats(unreadObject),
|
|
1044
|
-
status: "消息:j/k 选择 l 打开会话 h 返回 r 刷新"
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
case "me": {
|
|
1048
|
-
const [me, cacheStats] = await Promise.all([
|
|
1049
|
-
client.getMe(force, signal),
|
|
1050
|
-
client.getCacheStats()
|
|
1051
|
-
]);
|
|
1052
|
-
const meObject = asObject(me);
|
|
1053
|
-
return {
|
|
1054
|
-
title: "我的",
|
|
1055
|
-
items: [
|
|
1056
|
-
item("昵称", meObject.name),
|
|
1057
|
-
item("用户 ID", meObject.id),
|
|
1058
|
-
item("等级", meObject.levelTitle ?? meObject.groupName),
|
|
1059
|
-
item("发帖数", meObject.postCount),
|
|
1060
|
-
item("财富", meObject.wealth),
|
|
1061
|
-
item("关注", meObject.followCount),
|
|
1062
|
-
item("粉丝", meObject.fanCount)
|
|
1063
|
-
],
|
|
1064
|
-
stats: [
|
|
1065
|
-
{ title: "登录状态", detail: "已登录" }
|
|
1066
|
-
]
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
case "settings": {
|
|
1070
|
-
const cacheStats = await client.getCacheStats();
|
|
1071
|
-
return {
|
|
1072
|
-
title: "设置",
|
|
1073
|
-
items: settingsItems,
|
|
1074
|
-
stats: [
|
|
1075
|
-
{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` },
|
|
1076
|
-
{ title: "版本", detail: `v${appVersion}` }
|
|
1077
|
-
],
|
|
1078
|
-
status: "设置:j/k 选择 l 执行 h 返回"
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
function draw(state, size) {
|
|
1084
|
-
const width = Math.max(60, size.columns);
|
|
1085
|
-
const height = Math.max(20, size.rows);
|
|
1086
|
-
const sidebarWidth = width < 90 ? 14 : 18;
|
|
1087
|
-
const rightWidth = width < 78 ? 0 : Math.min(42, Math.max(34, Math.floor(width * 0.30)));
|
|
1088
|
-
const mainWidth = width - sidebarWidth - rightWidth - (rightWidth > 0 ? 2 : 1);
|
|
1089
|
-
const overviewHeight = height < 24 ? 1 : 2;
|
|
1090
|
-
const bodyHeight = height - 4 - overviewHeight;
|
|
1091
|
-
const lines = [];
|
|
1092
|
-
lines.push(header(width, state));
|
|
1093
|
-
lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1094
|
-
lines.push(...drawOverview(state, width, overviewHeight));
|
|
1095
|
-
const sidebar = drawSidebar(state, sidebarWidth, bodyHeight);
|
|
1096
|
-
const main = drawMain(state, mainWidth, bodyHeight);
|
|
1097
|
-
const right = rightWidth > 0 ? drawRight(state, rightWidth, bodyHeight) : [];
|
|
1098
|
-
for (let row = 0; row < bodyHeight; row += 1) {
|
|
1099
|
-
const parts = [
|
|
1100
|
-
fit(sidebar[row] ?? "", sidebarWidth),
|
|
1101
|
-
`${line}│${ansi.reset}`,
|
|
1102
|
-
fit(main[row] ?? "", mainWidth)
|
|
1103
|
-
];
|
|
1104
|
-
if (rightWidth > 0) {
|
|
1105
|
-
parts.push(`${line}│${ansi.reset}`, fit(right[row] ?? "", rightWidth));
|
|
1106
|
-
}
|
|
1107
|
-
lines.push(parts.join(""));
|
|
1108
|
-
}
|
|
1109
|
-
lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1110
|
-
lines.push(drawStatusBar(state, width));
|
|
1111
|
-
// Draw modal overlays
|
|
1112
|
-
if (state.modal === "help") {
|
|
1113
|
-
return drawHelpModal(lines, width, height);
|
|
1114
|
-
}
|
|
1115
|
-
if (state.modal === "menu") {
|
|
1116
|
-
return drawMenuModal(lines, state, width, height);
|
|
1117
|
-
}
|
|
1118
|
-
return lines.slice(0, height).join("\n");
|
|
1119
|
-
}
|
|
1120
|
-
function header(width, state) {
|
|
1121
|
-
const account = state.account ? `@${state.account}` : "未登录";
|
|
1122
|
-
const title = ` CC98 ${state.viewTitle} `;
|
|
1123
|
-
const padding = Math.max(1, width - cellWidth(title) - cellWidth(account));
|
|
1124
|
-
return `${cc98BlueBg}${white}${ansi.bold}${fit(`${title}${" ".repeat(padding)}${account}`, width)}${ansi.reset}`;
|
|
1125
|
-
}
|
|
1126
|
-
function drawOverview(state, width, height) {
|
|
1127
|
-
const rows = [];
|
|
1128
|
-
const summary = state.overview.length > 0
|
|
1129
|
-
? state.overview.map((entry) => `${entry.title} ${entry.detail ?? "-"}`).join(" ")
|
|
1130
|
-
: "全站概览会在读取十大时更新";
|
|
1131
|
-
rows.push(fit(`${cc98BlueSoft} ${summary}${ansi.reset}`, width));
|
|
1132
|
-
if (height > 1) {
|
|
1133
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1134
|
-
}
|
|
1135
|
-
return rows.slice(0, height);
|
|
1136
|
-
}
|
|
1137
|
-
function drawSidebar(state, width, height) {
|
|
1138
|
-
const rows = [];
|
|
1139
|
-
for (let index = 0; index < height; index += 1) {
|
|
1140
|
-
const nav = navItems[index];
|
|
1141
|
-
if (!nav) {
|
|
1142
|
-
rows.push(" ".repeat(width));
|
|
1143
|
-
continue;
|
|
1144
|
-
}
|
|
1145
|
-
const active = index === state.navIndex;
|
|
1146
|
-
const focused = state.focus === "nav";
|
|
1147
|
-
const label = ` ${nav.label}`;
|
|
1148
|
-
const hint = width > 16 ? ` ${nav.hint}` : "";
|
|
1149
|
-
const text = fit(`${label}${hint}`, width);
|
|
1150
|
-
if (active && focused) {
|
|
1151
|
-
rows.push(`${bg(0, 130, 202)}${white}${text}${ansi.reset}`);
|
|
1152
|
-
}
|
|
1153
|
-
else if (active) {
|
|
1154
|
-
rows.push(`${bg(5, 46, 74)}${cc98BlueSoft}${text}${ansi.reset}`);
|
|
1155
|
-
}
|
|
1156
|
-
else {
|
|
1157
|
-
rows.push(`${cc98Blue}${label}${ansi.reset}${muted}${fit(hint, Math.max(0, width - cellWidth(label)))}${ansi.reset}`);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
return rows;
|
|
1161
|
-
}
|
|
1162
|
-
function drawMain(state, width, height) {
|
|
1163
|
-
if (state.mode === "topic") {
|
|
1164
|
-
return drawTopic(state, width, height);
|
|
1165
|
-
}
|
|
1166
|
-
if (state.loading) {
|
|
1167
|
-
return [
|
|
1168
|
-
`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
|
|
1169
|
-
fit(`${muted} 正在加载...${ansi.reset}`, width),
|
|
1170
|
-
`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
|
|
1171
|
-
`${muted} ${"· ".repeat(Math.max(1, Math.floor((width - 2) / 2))).slice(0, width - 1)}${ansi.reset}`
|
|
1172
|
-
].concat(blank(height - 4, width)).slice(0, height);
|
|
1173
|
-
}
|
|
1174
|
-
if (state.error) {
|
|
1175
|
-
return [
|
|
1176
|
-
`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
|
|
1177
|
-
`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
|
|
1178
|
-
`${danger} 请求失败${ansi.reset}`,
|
|
1179
|
-
fit(` ${state.error}`, width)
|
|
1180
|
-
].concat(blank(height - 4, width)).slice(0, height);
|
|
1181
|
-
}
|
|
1182
|
-
const rows = [];
|
|
1183
|
-
rows.push(`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`);
|
|
1184
|
-
rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
|
|
1185
|
-
const visibleCapacity = Math.max(1, Math.floor(Math.max(1, height - 3) / 3));
|
|
1186
|
-
if (state.itemIndex < state.scroll) {
|
|
1187
|
-
state.scroll = state.itemIndex;
|
|
1188
|
-
}
|
|
1189
|
-
else if (state.itemIndex >= state.scroll + visibleCapacity) {
|
|
1190
|
-
state.scroll = state.itemIndex - visibleCapacity + 1;
|
|
1191
|
-
}
|
|
1192
|
-
const visible = state.items.slice(state.scroll);
|
|
1193
|
-
visible.forEach((itemValue, offset) => {
|
|
1194
|
-
if (rows.length >= height) {
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
const index = state.scroll + offset;
|
|
1198
|
-
const active = index === state.itemIndex && (state.focus === "content" || state.mode === "settings");
|
|
1199
|
-
const prefix = active ? `${ok}●${ansi.reset}` : `${muted}•${ansi.reset}`;
|
|
1200
|
-
const title = fit(` ${itemValue.title}`, Math.max(10, width - 2));
|
|
1201
|
-
rows.push(active ? `${bg(5, 46, 74)}${prefix}${title}${ansi.reset}` : fit(`${prefix}${title}`, width));
|
|
1202
|
-
if (itemValue.meta && rows.length < height) {
|
|
1203
|
-
rows.push(fit(` ${muted}${itemValue.meta}${ansi.reset}`, width));
|
|
1204
|
-
}
|
|
1205
|
-
// Note: detail is shown in right panel, not here
|
|
1206
|
-
});
|
|
1207
|
-
if (visible.length === 0) {
|
|
1208
|
-
rows.push(`${muted} 暂无数据${ansi.reset}`);
|
|
1209
|
-
}
|
|
1210
|
-
if (state.scroll + visibleCapacity < state.items.length && rows.length < height) {
|
|
1211
|
-
rows.push(fit(`${muted} ↓ 还有 ${state.items.length - state.scroll - visibleCapacity} 项${ansi.reset}`, width));
|
|
1212
|
-
}
|
|
1213
|
-
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1214
|
-
}
|
|
1215
|
-
function drawTopic(state, width, height) {
|
|
1216
|
-
if (state.loading && (!state.topic || state.topic.lines.length === 0)) {
|
|
1217
|
-
return [
|
|
1218
|
-
`${cc98Blue} 正在打开帖子...${ansi.reset}`,
|
|
1219
|
-
"",
|
|
1220
|
-
`${muted} 只加载第一页,不预取未读楼层。${ansi.reset}`
|
|
1221
|
-
].concat(blank(height - 3, width)).slice(0, height);
|
|
1222
|
-
}
|
|
1223
|
-
if (state.error) {
|
|
1224
|
-
return [
|
|
1225
|
-
`${danger} 读取帖子失败${ansi.reset}`,
|
|
1226
|
-
fit(` ${state.error}`, width),
|
|
1227
|
-
"",
|
|
1228
|
-
`${muted} h/Esc 返回列表${ansi.reset}`
|
|
1229
|
-
].concat(blank(height - 4, width)).slice(0, height);
|
|
1230
|
-
}
|
|
1231
|
-
const topic = state.topic;
|
|
1232
|
-
if (!topic) {
|
|
1233
|
-
return blank(height, width);
|
|
1234
|
-
}
|
|
1235
|
-
const rows = [];
|
|
1236
|
-
rows.push(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`);
|
|
1237
|
-
rows.push(fit(`${muted} ${topic.meta}${ansi.reset}`, width));
|
|
1238
|
-
rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
|
|
1239
|
-
const viewport = Math.max(0, height - rows.length - 1);
|
|
1240
|
-
const maxScroll = Math.max(0, topic.lines.length - viewport);
|
|
1241
|
-
state.scroll = Math.min(state.scroll, maxScroll);
|
|
1242
|
-
const body = topic.lines.slice(state.scroll, state.scroll + viewport);
|
|
1243
|
-
for (const bodyLine of body) {
|
|
1244
|
-
if (bodyLine.startsWith("[image ")) {
|
|
1245
|
-
rows.push(fit(`${cc98BlueSoft}${bodyLine}${ansi.reset}`, width));
|
|
1246
|
-
}
|
|
1247
|
-
else if (bodyLine.startsWith("│ ")) {
|
|
1248
|
-
rows.push(fit(`${muted}${bodyLine}${ansi.reset}`, width));
|
|
1249
|
-
}
|
|
1250
|
-
else if (/^#\d+ /.test(bodyLine)) {
|
|
1251
|
-
rows.push(fit(`${ok}${bodyLine}${ansi.reset}`, width));
|
|
1252
|
-
}
|
|
1253
|
-
else {
|
|
1254
|
-
rows.push(fit(` ${bodyLine}`, width));
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
const pageInfo = topic.hasMore
|
|
1258
|
-
? `已载入 ${topic.loaded} 楼,n 下一页`
|
|
1259
|
-
: `已载入 ${topic.loaded} 楼,已到底`;
|
|
1260
|
-
rows.push(fit(`${muted}${pageInfo}${state.loadingMore ? " · 加载中" : ""}${ansi.reset}`, width));
|
|
1261
|
-
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1262
|
-
}
|
|
1263
|
-
function drawStatusBar(state, width) {
|
|
1264
|
-
const left = getStatus(state);
|
|
1265
|
-
const right = getKeyHints(state);
|
|
1266
|
-
const padding = Math.max(1, width - cellWidth(left) - cellWidth(right) - 2);
|
|
1267
|
-
return fit(`${muted} ${left}${" ".repeat(padding)}${right} `, width);
|
|
1268
|
-
}
|
|
1269
|
-
function getKeyHints(state) {
|
|
1270
|
-
const hints = [];
|
|
1271
|
-
hints.push("j/k ↑↓ 移动");
|
|
1272
|
-
hints.push("h← 返回");
|
|
1273
|
-
hints.push("l→ 进入");
|
|
1274
|
-
hints.push("Enter 确认");
|
|
1275
|
-
if (state.mode === "topic") {
|
|
1276
|
-
hints.push("n 下页");
|
|
1277
|
-
hints.push("【/】楼层");
|
|
1278
|
-
hints.push("数字跳楼");
|
|
1279
|
-
}
|
|
1280
|
-
else if (state.currentChat) {
|
|
1281
|
-
hints.push("n 更多");
|
|
1282
|
-
}
|
|
1283
|
-
hints.push("r 刷新");
|
|
1284
|
-
hints.push("o 操作");
|
|
1285
|
-
hints.push("? 帮助");
|
|
1286
|
-
hints.push("q 退出");
|
|
1287
|
-
return hints.join(" ");
|
|
1288
|
-
}
|
|
1289
|
-
function drawHelpModal(baseLines, width, height) {
|
|
1290
|
-
const modalWidth = Math.min(50, width - 4);
|
|
1291
|
-
const modalHeight = Math.min(22, height - 4);
|
|
1292
|
-
const startRow = Math.floor((height - modalHeight) / 2);
|
|
1293
|
-
const startCol = Math.floor((width - modalWidth) / 2);
|
|
1294
|
-
const helpContent = [
|
|
1295
|
-
"",
|
|
1296
|
-
`${cc98Blue}${ansi.bold} 快捷键帮助${ansi.reset}`,
|
|
1297
|
-
"",
|
|
1298
|
-
" 导航",
|
|
1299
|
-
" j/k, ↑/↓ 上下移动",
|
|
1300
|
-
" l, → 进入下一层",
|
|
1301
|
-
" h, ← 返回上一层",
|
|
1302
|
-
" Enter 确认/执行",
|
|
1303
|
-
"",
|
|
1304
|
-
" 操作",
|
|
1305
|
-
" r 刷新当前视图",
|
|
1306
|
-
" n, Space 加载更多",
|
|
1307
|
-
" o 打开操作菜单",
|
|
1308
|
-
" ? 显示/关闭帮助",
|
|
1309
|
-
" q 退出程序",
|
|
1310
|
-
"",
|
|
1311
|
-
" 按任意键关闭"
|
|
1312
|
-
];
|
|
1313
|
-
const result = [...baseLines];
|
|
1314
|
-
for (let i = 0; i < modalHeight && i < helpContent.length; i++) {
|
|
1315
|
-
const row = startRow + i;
|
|
1316
|
-
if (row >= 0 && row < result.length) {
|
|
1317
|
-
const line = helpContent[i] ?? "";
|
|
1318
|
-
const padded = fit(line, modalWidth);
|
|
1319
|
-
const bgStr = i === 0 || i === modalHeight - 1 ? `${line}${"─".repeat(modalWidth)}${ansi.reset}` : `${bg(5, 46, 74)}${padded}${ansi.reset}`;
|
|
1320
|
-
const before = result[row].slice(0, startCol);
|
|
1321
|
-
const after = " ".repeat(Math.max(0, width - startCol - modalWidth));
|
|
1322
|
-
result[row] = `${before}${bgStr}${after}`;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
return result.slice(0, height).join("\n");
|
|
1326
|
-
}
|
|
1327
|
-
function drawMenuModal(baseLines, state, width, height) {
|
|
1328
|
-
const modalWidth = Math.min(30, width - 4);
|
|
1329
|
-
const modalHeight = state.menuItems.length + 4;
|
|
1330
|
-
const startRow = Math.floor((height - modalHeight) / 2);
|
|
1331
|
-
const startCol = Math.floor((width - modalWidth) / 2);
|
|
1332
|
-
const result = [...baseLines];
|
|
1333
|
-
// Title
|
|
1334
|
-
const titleRow = startRow;
|
|
1335
|
-
if (titleRow >= 0 && titleRow < result.length) {
|
|
1336
|
-
const title = fit(`${cc98Blue}${ansi.bold} 操作菜单${ansi.reset}`, modalWidth);
|
|
1337
|
-
result[titleRow] = replaceAt(result[titleRow], startCol, `${bg(5, 46, 74)}${title}${ansi.reset}`);
|
|
1338
|
-
}
|
|
1339
|
-
// Separator
|
|
1340
|
-
const sepRow = startRow + 1;
|
|
1341
|
-
if (sepRow >= 0 && sepRow < result.length) {
|
|
1342
|
-
result[sepRow] = replaceAt(result[sepRow], startCol, `${line}${"─".repeat(modalWidth)}${ansi.reset}`);
|
|
1343
|
-
}
|
|
1344
|
-
// Menu items
|
|
1345
|
-
state.menuItems.forEach((item, i) => {
|
|
1346
|
-
const row = startRow + 2 + i;
|
|
1347
|
-
if (row >= 0 && row < result.length) {
|
|
1348
|
-
const active = i === state.menuIndex;
|
|
1349
|
-
const label = ` ${item.label}`;
|
|
1350
|
-
const key = `[${item.key}]`;
|
|
1351
|
-
const padding = Math.max(0, modalWidth - label.length - key.length - 1);
|
|
1352
|
-
const content = `${label}${" ".repeat(padding)}${key}`;
|
|
1353
|
-
const styled = active
|
|
1354
|
-
? `${bg(0, 130, 202)}${white}${fit(content, modalWidth)}${ansi.reset}`
|
|
1355
|
-
: `${bg(5, 46, 74)}${fit(content, modalWidth)}${ansi.reset}`;
|
|
1356
|
-
result[row] = replaceAt(result[row], startCol, styled);
|
|
1357
|
-
}
|
|
1358
|
-
});
|
|
1359
|
-
return result.slice(0, height).join("\n");
|
|
1360
|
-
}
|
|
1361
|
-
function replaceAt(str, index, replacement) {
|
|
1362
|
-
const before = str.slice(0, index);
|
|
1363
|
-
const afterWidth = Math.max(0, cellWidth(str) - index - cellWidth(replacement));
|
|
1364
|
-
const after = " ".repeat(afterWidth);
|
|
1365
|
-
return `${before}${replacement}${after}`;
|
|
1366
|
-
}
|
|
1367
|
-
function drawRight(state, width, height) {
|
|
1368
|
-
if (state.mode === "topic" && state.topic) {
|
|
1369
|
-
return drawTopicRight(state.topic, state.scroll, width, height);
|
|
1370
|
-
}
|
|
1371
|
-
if (state.focus === "nav") {
|
|
1372
|
-
return drawNavRight(state, width, height);
|
|
1373
|
-
}
|
|
1374
|
-
return drawItemRight(state, width, height);
|
|
1375
|
-
}
|
|
1376
|
-
function drawNavRight(state, width, height) {
|
|
1377
|
-
const rows = [];
|
|
1378
|
-
const nav = navItems[state.navIndex];
|
|
1379
|
-
rows.push(...mascotMini.map((row) => fit(`${white}${row}${ansi.reset}`, width)));
|
|
1380
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1381
|
-
rows.push(fit(`${cc98Blue}${ansi.bold} ${nav.label}${ansi.reset}`, width));
|
|
1382
|
-
rows.push(fit(`${muted} ${nav.hint}${ansi.reset}`, width));
|
|
1383
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1384
|
-
if (state.loading) {
|
|
1385
|
-
rows.push(fit(`${muted} 正在读取栏目...${ansi.reset}`, width));
|
|
1386
|
-
}
|
|
1387
|
-
else if (state.error) {
|
|
1388
|
-
rows.push(fit(`${danger} 栏目读取失败${ansi.reset}`, width));
|
|
1389
|
-
rows.push(fit(` ${state.error}`, width));
|
|
1390
|
-
}
|
|
1391
|
-
else {
|
|
1392
|
-
rows.push(fit(`${muted} 当前内容${ansi.reset}`, width));
|
|
1393
|
-
rows.push(fit(`${cc98BlueSoft} ${state.items.length} 项${ansi.reset}`, width));
|
|
1394
|
-
if (state.stats.length > 0) {
|
|
1395
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1396
|
-
state.stats.slice(0, 5).forEach((stat) => {
|
|
1397
|
-
rows.push(fit(`${muted} ${stat.title}${ansi.reset}`, width));
|
|
1398
|
-
rows.push(fit(`${cc98BlueSoft} ${stat.detail ?? "-"}${ansi.reset}`, width));
|
|
1399
|
-
});
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1403
|
-
rows.push(fit(`${muted} j/k 切换栏目${ansi.reset}`, width));
|
|
1404
|
-
rows.push(fit(`${muted} l/Enter 进入内容${ansi.reset}`, width));
|
|
1405
|
-
rows.push(fit(`${muted} r 刷新当前栏目${ansi.reset}`, width));
|
|
1406
|
-
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1407
|
-
}
|
|
1408
|
-
function drawItemRight(state, width, height) {
|
|
1409
|
-
const rows = [];
|
|
1410
|
-
const selected = state.items[state.itemIndex];
|
|
1411
|
-
if (!selected) {
|
|
1412
|
-
rows.push(fit(`${muted} 暂无选中项${ansi.reset}`, width));
|
|
1413
|
-
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1414
|
-
}
|
|
1415
|
-
rows.push(fit(`${cc98Blue}${ansi.bold} ${selected.title}${ansi.reset}`, width));
|
|
1416
|
-
if (selected.meta) {
|
|
1417
|
-
wrapText(selected.meta, width - 2).slice(0, 3).forEach((row) => {
|
|
1418
|
-
rows.push(fit(`${muted} ${row}${ansi.reset}`, width));
|
|
1419
|
-
});
|
|
1420
|
-
}
|
|
1421
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1422
|
-
if (selected.detail) {
|
|
1423
|
-
wrapText(selected.detail, width - 2).slice(0, Math.max(0, height - rows.length - 8)).forEach((row) => {
|
|
1424
|
-
rows.push(fit(` ${row}`, width));
|
|
1425
|
-
});
|
|
1426
|
-
}
|
|
1427
|
-
else {
|
|
1428
|
-
rows.push(fit(`${muted} 没有摘要内容${ansi.reset}`, width));
|
|
1429
|
-
}
|
|
1430
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1431
|
-
if (selected.topicId !== undefined) {
|
|
1432
|
-
rows.push(fit(`${muted} 主题 #${selected.topicId}${ansi.reset}`, width));
|
|
1433
|
-
if (selected.boardId !== undefined) {
|
|
1434
|
-
rows.push(fit(`${muted} 版面 #${selected.boardId}${ansi.reset}`, width));
|
|
1435
|
-
}
|
|
1436
|
-
rows.push(fit(`${cc98BlueSoft} l 打开阅读${ansi.reset}`, width));
|
|
1437
|
-
}
|
|
1438
|
-
else if (selected.boardId !== undefined) {
|
|
1439
|
-
rows.push(fit(`${muted} 版面 #${selected.boardId}${ansi.reset}`, width));
|
|
1440
|
-
rows.push(fit(`${cc98BlueSoft} l 读取主题${ansi.reset}`, width));
|
|
1441
|
-
}
|
|
1442
|
-
else if (selected.chatUserId !== undefined) {
|
|
1443
|
-
rows.push(fit(`${muted} 用户 #${selected.chatUserId}${ansi.reset}`, width));
|
|
1444
|
-
rows.push(fit(`${cc98BlueSoft} l 打开会话${ansi.reset}`, width));
|
|
1445
|
-
}
|
|
1446
|
-
else if (state.mode === "settings") {
|
|
1447
|
-
rows.push(fit(`${cc98BlueSoft} l/Enter 执行${ansi.reset}`, width));
|
|
1448
|
-
}
|
|
1449
|
-
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1450
|
-
}
|
|
1451
|
-
function drawTopicRight(topic, scroll, width, height) {
|
|
1452
|
-
const rows = [];
|
|
1453
|
-
const post = currentTopicPost(topic, scroll);
|
|
1454
|
-
const lineEntry = currentTopicLine(topic, scroll);
|
|
1455
|
-
rows.push(fit(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`, width));
|
|
1456
|
-
if (topic.meta) {
|
|
1457
|
-
wrapText(topic.meta, width - 2).slice(0, 2).forEach((row) => {
|
|
1458
|
-
rows.push(fit(`${muted} ${row}${ansi.reset}`, width));
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1462
|
-
if (post) {
|
|
1463
|
-
const floor = post.floor !== undefined ? `${post.floor} 楼` : "未知楼层";
|
|
1464
|
-
rows.push(fit(`${cc98BlueSoft} ${floor}${ansi.reset}`, width));
|
|
1465
|
-
rows.push(fit(`${muted} ${post.author}${post.time ? ` · ${post.time}` : ""}${ansi.reset}`, width));
|
|
1466
|
-
rows.push(fit(`${muted} 赞 ${post.likeCount} 踩 ${post.dislikeCount}${post.rating ? ` 评分 ${post.rating}` : ""}${ansi.reset}`, width));
|
|
1467
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1468
|
-
if (lineEntry) {
|
|
1469
|
-
rows.push(fit(`${muted} 当前行 ${lineEntry.row + 1}/${post.lines.length}${ansi.reset}`, width));
|
|
1470
|
-
rows.push(fit(`${cc98BlueSoft} ${lineKindLabel(lineEntry.kind)}${ansi.reset}`, width));
|
|
1471
|
-
if (lineEntry.imageUrl) {
|
|
1472
|
-
rows.push(fit(`${muted} 图片 ${lineEntry.imageIndex}${ansi.reset}`, width));
|
|
1473
|
-
wrapText(lineEntry.imageUrl, width - 2).slice(0, 2).forEach((row) => rows.push(fit(` ${row}`, width)));
|
|
1474
|
-
}
|
|
1475
|
-
else if (lineEntry.linkUrl) {
|
|
1476
|
-
rows.push(fit(`${muted} 链接 ${lineEntry.linkIndex}${ansi.reset}`, width));
|
|
1477
|
-
wrapText(lineEntry.linkUrl, width - 2).slice(0, 2).forEach((row) => rows.push(fit(` ${row}`, width)));
|
|
1478
|
-
}
|
|
1479
|
-
else if (lineEntry.text.trim()) {
|
|
1480
|
-
wrapText(lineEntry.text, width - 2).slice(0, 3).forEach((row) => rows.push(fit(` ${row}`, width)));
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1484
|
-
rows.push(fit(`${muted} 本楼 图片 ${post.imageCount} 链接 ${post.linkCount}${ansi.reset}`, width));
|
|
1485
|
-
}
|
|
1486
|
-
const hot = topic.posts
|
|
1487
|
-
.filter((entry) => entry.likeCount > 0)
|
|
1488
|
-
.sort((left, right) => right.likeCount - left.likeCount)
|
|
1489
|
-
.slice(0, 3);
|
|
1490
|
-
if (hot.length > 0 && rows.length < height - 5) {
|
|
1491
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1492
|
-
rows.push(fit(`${cc98Blue}${ansi.bold} 热门回复${ansi.reset}`, width));
|
|
1493
|
-
hot.forEach((entry) => {
|
|
1494
|
-
rows.push(fit(`${muted} #${entry.floor ?? "?"} ${entry.author} · ${entry.likeCount} 赞${ansi.reset}`, width));
|
|
1495
|
-
if (entry.preview) {
|
|
1496
|
-
rows.push(fit(` ${truncate(entry.preview, width - 2)}`, width));
|
|
1497
|
-
}
|
|
1498
|
-
});
|
|
1499
|
-
}
|
|
1500
|
-
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1501
|
-
rows.push(fit(`${muted} j/k 行滚动 【/】楼层切换${ansi.reset}`, width));
|
|
1502
|
-
rows.push(fit(`${muted} 数字+Enter 跳楼 n 下一页${ansi.reset}`, width));
|
|
1503
|
-
if (topic.floorInput) {
|
|
1504
|
-
rows.push(fit(`${ok} 跳转:${topic.floorInput} 楼${ansi.reset}`, width));
|
|
1505
|
-
}
|
|
1506
|
-
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1507
|
-
}
|
|
1508
|
-
function wrapText(text, maxWidth) {
|
|
1509
|
-
const lines = [];
|
|
1510
|
-
let current = "";
|
|
1511
|
-
let currentWidth = 0;
|
|
1512
|
-
for (const char of text) {
|
|
1513
|
-
const charW = charCellWidth(char);
|
|
1514
|
-
if (currentWidth + charW > maxWidth) {
|
|
1515
|
-
lines.push(current);
|
|
1516
|
-
current = char;
|
|
1517
|
-
currentWidth = charW;
|
|
1518
|
-
}
|
|
1519
|
-
else {
|
|
1520
|
-
current += char;
|
|
1521
|
-
currentWidth += charW;
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
if (current) {
|
|
1525
|
-
lines.push(current);
|
|
1526
|
-
}
|
|
1527
|
-
return lines;
|
|
1528
|
-
}
|
|
1529
|
-
function item(title, value, meta) {
|
|
1530
|
-
return {
|
|
1531
|
-
title,
|
|
1532
|
-
meta,
|
|
1533
|
-
detail: value === undefined || value === null ? "-" : String(value)
|
|
1534
|
-
};
|
|
1535
|
-
}
|
|
1536
|
-
function topicItem(value, fallbackBoard) {
|
|
1537
|
-
const topic = asObject(value);
|
|
1538
|
-
const topicId = asNumber(topic.id ?? topic.Id);
|
|
1539
|
-
const boardId = asNumber(topic.boardId ?? topic.BoardId) ?? fallbackBoard?.boardId;
|
|
1540
|
-
const boardName = topic.boardName ?? topic.BoardName ?? fallbackBoard?.title;
|
|
1541
|
-
return {
|
|
1542
|
-
title: String(topic.title ?? topic.Title ?? `#${topicId ?? ""}`),
|
|
1543
|
-
meta: [
|
|
1544
|
-
boardName,
|
|
1545
|
-
topic.userName ?? topic.authorName,
|
|
1546
|
-
topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
|
|
1547
|
-
topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
|
|
1548
|
-
]
|
|
1549
|
-
.filter(Boolean)
|
|
1550
|
-
.join(" · "),
|
|
1551
|
-
detail: typeof topic.lastPostContent === "string" ? topic.lastPostContent.replace(/\s+/g, " ") : undefined,
|
|
1552
|
-
topicId,
|
|
1553
|
-
boardId,
|
|
1554
|
-
sortTime: timestampOf(topic.lastPostTime ?? topic.updateTime ?? topic.time ?? topic.createTime)
|
|
1555
|
-
};
|
|
1556
|
-
}
|
|
1557
|
-
async function loadChatUserNames(client, chats, force, signal) {
|
|
1558
|
-
const ids = chats
|
|
1559
|
-
.map((chat) => asNumber(asObject(chat).userId ?? asObject(chat).UserId))
|
|
1560
|
-
.filter((id) => id !== undefined);
|
|
1561
|
-
const users = asArray(await client.getBasicUsers(ids, force, signal));
|
|
1562
|
-
return new Map(users.map((userRaw) => {
|
|
1563
|
-
const user = asObject(userRaw);
|
|
1564
|
-
const id = asNumber(user.id ?? user.Id);
|
|
1565
|
-
const name = String(user.name ?? user.Name ?? (id !== undefined ? `#${id}` : "用户"));
|
|
1566
|
-
return [id, name];
|
|
1567
|
-
}).filter((entry) => entry[0] !== undefined));
|
|
1568
|
-
}
|
|
1569
|
-
function chatItem(value, userNames) {
|
|
1570
|
-
const chat = asObject(value);
|
|
1571
|
-
const userId = asNumber(chat.userId ?? chat.UserId);
|
|
1572
|
-
const name = userId !== undefined ? userNames.get(userId) : undefined;
|
|
1573
|
-
return {
|
|
1574
|
-
title: String(name ?? chat.name ?? chat.userName ?? userId ?? "私信"),
|
|
1575
|
-
meta: userId !== undefined ? `user #${userId}` : undefined,
|
|
1576
|
-
detail: normalizeInline(String(chat.lastContent ?? chat.lastMessage ?? chat.content ?? "")),
|
|
1577
|
-
chatUserId: userId
|
|
1578
|
-
};
|
|
1579
|
-
}
|
|
1580
|
-
function chatMessageItems(messages, otherName, otherUserId) {
|
|
1581
|
-
return [...messages].reverse().map((messageRaw) => {
|
|
1582
|
-
const message = asObject(messageRaw);
|
|
1583
|
-
const receiverId = asNumber(message.receiverId ?? message.ReceiverId);
|
|
1584
|
-
const isMine = receiverId === otherUserId;
|
|
1585
|
-
const time = typeof message.time === "string"
|
|
1586
|
-
? message.time.replace("T", " ").slice(0, 16)
|
|
1587
|
-
: "";
|
|
1588
|
-
const content = normalizeInline(String(message.content ?? message.Content ?? ""));
|
|
1589
|
-
return {
|
|
1590
|
-
title: isMine ? `我 -> ${otherName}` : `${otherName} -> 我`,
|
|
1591
|
-
meta: [time, receiverId !== undefined ? `receiver #${receiverId}` : undefined].filter(Boolean).join(" · "),
|
|
1592
|
-
detail: content || "(空消息)"
|
|
1593
|
-
};
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
function unreadStats(value) {
|
|
1597
|
-
return [
|
|
1598
|
-
item("系统", value.systemCount),
|
|
1599
|
-
item("@", value.atCount),
|
|
1600
|
-
item("回复", value.replyCount),
|
|
1601
|
-
item("私信", value.messageCount)
|
|
1602
|
-
];
|
|
1603
|
-
}
|
|
1604
|
-
function overviewStats(index, unread) {
|
|
1605
|
-
const unreadTotal = ["systemCount", "atCount", "replyCount", "messageCount"].reduce((total, key) => {
|
|
1606
|
-
const value = unread[key];
|
|
1607
|
-
return total + (typeof value === "number" ? value : 0);
|
|
1608
|
-
}, 0);
|
|
1609
|
-
return [
|
|
1610
|
-
item("今日主题", index.todayTopicCount),
|
|
1611
|
-
item("今日回复", index.todayCount),
|
|
1612
|
-
item("在线", index.onlineUserCount),
|
|
1613
|
-
item("用户", index.userCount),
|
|
1614
|
-
item("未读", unreadTotal)
|
|
1615
|
-
];
|
|
1616
|
-
}
|
|
1617
|
-
async function mapLimit(values, limit, mapper) {
|
|
1618
|
-
const results = [];
|
|
1619
|
-
let nextIndex = 0;
|
|
1620
|
-
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
1621
|
-
while (nextIndex < values.length) {
|
|
1622
|
-
const index = nextIndex;
|
|
1623
|
-
nextIndex += 1;
|
|
1624
|
-
results[index] = await mapper(values[index]);
|
|
1625
|
-
}
|
|
1626
|
-
});
|
|
1627
|
-
await Promise.all(workers);
|
|
1628
|
-
return results;
|
|
1629
|
-
}
|
|
1630
|
-
function getStatus(state) {
|
|
1631
|
-
// Left part: context status
|
|
1632
|
-
let left = "";
|
|
1633
|
-
if (state.loading) {
|
|
1634
|
-
left = "加载中...";
|
|
1635
|
-
}
|
|
1636
|
-
else if (state.loadingMore) {
|
|
1637
|
-
left = "加载更多...";
|
|
1638
|
-
}
|
|
1639
|
-
else if (state.error) {
|
|
1640
|
-
left = "出错了";
|
|
1641
|
-
}
|
|
1642
|
-
else if (state.mode === "topic") {
|
|
1643
|
-
if (state.topic) {
|
|
1644
|
-
const post = currentTopicPost(state.topic, state.scroll);
|
|
1645
|
-
const line = currentTopicLine(state.topic, state.scroll);
|
|
1646
|
-
left = post
|
|
1647
|
-
? `${post.floor ?? "?"} 楼 · 第 ${line ? line.row + 1 : 1} 行`
|
|
1648
|
-
: `${state.topic.loaded} 楼已加载`;
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
else if (state.mode === "settings") {
|
|
1652
|
-
left = "设置";
|
|
1653
|
-
}
|
|
1654
|
-
else {
|
|
1655
|
-
left = `${state.items.length} 项`;
|
|
1656
|
-
}
|
|
1657
|
-
return left;
|
|
1658
|
-
}
|
|
1659
|
-
function flattenBoards(sections) {
|
|
1660
|
-
const boards = [];
|
|
1661
|
-
for (const section of sections) {
|
|
1662
|
-
const sectionObject = asObject(section);
|
|
1663
|
-
const sectionName = String(sectionObject.name ?? sectionObject.title ?? "分区");
|
|
1664
|
-
const candidates = [sectionObject.boards, sectionObject.children, sectionObject.boardList];
|
|
1665
|
-
for (const candidate of candidates) {
|
|
1666
|
-
if (!Array.isArray(candidate)) {
|
|
1667
|
-
continue;
|
|
1668
|
-
}
|
|
1669
|
-
for (const board of candidate) {
|
|
1670
|
-
const boardObject = asObject(board);
|
|
1671
|
-
boards.push({
|
|
1672
|
-
title: String(boardObject.name ?? boardObject.title ?? `#${boardObject.id ?? ""}`),
|
|
1673
|
-
meta: `${sectionName}${boardObject.id !== undefined ? ` · #${boardObject.id}` : ""}`,
|
|
1674
|
-
detail: typeof boardObject.description === "string" ? boardObject.description : undefined,
|
|
1675
|
-
boardId: typeof boardObject.id === "number" ? boardObject.id : undefined
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
return boards;
|
|
1681
|
-
}
|
|
1682
|
-
function asObject(value) {
|
|
1683
|
-
return typeof value === "object" && value !== null ? value : {};
|
|
1684
|
-
}
|
|
1685
|
-
function asArray(value) {
|
|
1686
|
-
return Array.isArray(value) ? value : [];
|
|
1687
|
-
}
|
|
1688
|
-
function asNumber(value) {
|
|
1689
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1690
|
-
}
|
|
1691
|
-
function normalizeInline(value) {
|
|
1692
|
-
return value.replace(/\s+/g, " ").trim();
|
|
1693
|
-
}
|
|
1694
|
-
function timestampOf(value) {
|
|
1695
|
-
if (typeof value !== "string" && typeof value !== "number") {
|
|
1696
|
-
return undefined;
|
|
1697
|
-
}
|
|
1698
|
-
const timestamp = new Date(value).getTime();
|
|
1699
|
-
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
1700
|
-
}
|
|
1701
|
-
function isAbortError(error) {
|
|
1702
|
-
return error instanceof Error && error.name === "AbortError";
|
|
1703
|
-
}
|
|
1704
|
-
function blank(count, width) {
|
|
1705
|
-
return Array.from({ length: Math.max(0, count) }, () => " ".repeat(width));
|
|
1706
|
-
}
|
|
1707
|
-
function fit(value, width) {
|
|
1708
|
-
const truncated = truncate(value, width);
|
|
1709
|
-
return `${truncated}${" ".repeat(Math.max(0, width - cellWidth(truncated)))}`;
|
|
1710
|
-
}
|
|
1711
|
-
function truncate(value, width) {
|
|
1712
|
-
let out = "";
|
|
1713
|
-
let used = 0;
|
|
1714
|
-
let inEscape = false;
|
|
1715
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
1716
|
-
const char = value[index];
|
|
1717
|
-
if (char === "\x1b") {
|
|
1718
|
-
inEscape = true;
|
|
1719
|
-
out += char;
|
|
1720
|
-
continue;
|
|
1721
|
-
}
|
|
1722
|
-
if (inEscape) {
|
|
1723
|
-
out += char;
|
|
1724
|
-
if (/[A-Za-z]/.test(char)) {
|
|
1725
|
-
inEscape = false;
|
|
1726
|
-
}
|
|
1727
|
-
continue;
|
|
1728
|
-
}
|
|
1729
|
-
const charWidth = charCellWidth(char);
|
|
1730
|
-
if (used + charWidth > width) {
|
|
1731
|
-
break;
|
|
1732
|
-
}
|
|
1733
|
-
out += char;
|
|
1734
|
-
used += charWidth;
|
|
1735
|
-
}
|
|
1736
|
-
return out;
|
|
1737
|
-
}
|
|
1738
|
-
function cellWidth(value) {
|
|
1739
|
-
let width = 0;
|
|
1740
|
-
for (const char of stripAnsi(value)) {
|
|
1741
|
-
width += charCellWidth(char);
|
|
1742
|
-
}
|
|
1743
|
-
return width;
|
|
1744
|
-
}
|
|
1745
|
-
function charCellWidth(char) {
|
|
1746
|
-
const code = char.codePointAt(0) ?? 0;
|
|
1747
|
-
if (code === 0) {
|
|
1748
|
-
return 0;
|
|
1749
|
-
}
|
|
1750
|
-
if (code >= 0x1100 &&
|
|
1751
|
-
(code <= 0x115f ||
|
|
1752
|
-
code === 0x2329 ||
|
|
1753
|
-
code === 0x232a ||
|
|
1754
|
-
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
1755
|
-
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
1756
|
-
(code >= 0xf900 && code <= 0xfaff) ||
|
|
1757
|
-
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
1758
|
-
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
1759
|
-
(code >= 0xff00 && code <= 0xff60) ||
|
|
1760
|
-
(code >= 0xffe0 && code <= 0xffe6))) {
|
|
1761
|
-
return 2;
|
|
1762
|
-
}
|
|
1763
|
-
return 1;
|
|
1764
|
-
}
|
|
1765
67
|
//# sourceMappingURL=app.js.map
|