cc98-cli 0.1.0 → 0.3.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 +44 -0
- package/README.md +46 -179
- package/dist/api/client.d.ts +29 -15
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +163 -31
- 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 +12 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/cli/commands/board.d.ts.map +1 -1
- package/dist/cli/commands/board.js +15 -0
- package/dist/cli/commands/board.js.map +1 -1
- package/dist/cli/commands/cache.d.ts +2 -0
- package/dist/cli/commands/cache.d.ts.map +1 -0
- package/dist/cli/commands/cache.js +68 -0
- package/dist/cli/commands/cache.js.map +1 -0
- package/dist/cli/commands/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 +10 -0
- package/dist/cli/commands/message.js.map +1 -1
- package/dist/cli/commands/post.d.ts.map +1 -1
- package/dist/cli/commands/post.js +12 -0
- package/dist/cli/commands/post.js.map +1 -1
- package/dist/cli/commands/topic.d.ts.map +1 -1
- package/dist/cli/commands/topic.js +41 -5
- package/dist/cli/commands/topic.js.map +1 -1
- package/dist/cli/commands/update.d.ts +2 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +24 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/commands/user.d.ts.map +1 -1
- package/dist/cli/commands/user.js +12 -0
- package/dist/cli/commands/user.js.map +1 -1
- package/dist/cli/router.d.ts.map +1 -1
- package/dist/cli/router.js +18 -2
- package/dist/cli/router.js.map +1 -1
- package/dist/storage/cache-store.d.ts +45 -2
- package/dist/storage/cache-store.d.ts.map +1 -1
- package/dist/storage/cache-store.js +158 -5
- package/dist/storage/cache-store.js.map +1 -1
- package/dist/storage/token-store.js +1 -1
- package/dist/storage/token-store.js.map +1 -1
- package/dist/tui/app-new.d.ts +2 -0
- package/dist/tui/app-new.d.ts.map +1 -0
- package/dist/tui/app-new.js +2589 -0
- package/dist/tui/app-new.js.map +1 -0
- package/dist/tui/app-old.d.ts +2 -0
- package/dist/tui/app-old.d.ts.map +1 -0
- package/dist/tui/app-old.js +2589 -0
- package/dist/tui/app-old.js.map +1 -0
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +14 -1055
- 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 +43 -1
- package/dist/tui/cached-client.d.ts.map +1 -1
- package/dist/tui/cached-client.js +108 -2
- 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 +464 -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 +20 -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 +58 -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 +36 -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 +61 -0
- package/dist/tui/controller.d.ts.map +1 -0
- package/dist/tui/controller.js +1118 -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 +240 -0
- package/dist/tui/helpers.js.map +1 -0
- package/dist/tui/keymap/actions.d.ts +9 -0
- package/dist/tui/keymap/actions.d.ts.map +1 -0
- package/dist/tui/keymap/actions.js +208 -0
- package/dist/tui/keymap/actions.js.map +1 -0
- package/dist/tui/keymap/bindings.d.ts +5 -0
- package/dist/tui/keymap/bindings.d.ts.map +1 -0
- package/dist/tui/keymap/bindings.js +138 -0
- package/dist/tui/keymap/bindings.js.map +1 -0
- package/dist/tui/keymap/index.d.ts +4 -0
- package/dist/tui/keymap/index.d.ts.map +1 -0
- package/dist/tui/keymap/index.js +5 -0
- package/dist/tui/keymap/index.js.map +1 -0
- package/dist/tui/keymap/types.d.ts +17 -0
- package/dist/tui/keymap/types.d.ts.map +1 -0
- package/dist/tui/keymap/types.js +3 -0
- package/dist/tui/keymap/types.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 +20 -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 +302 -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 +61 -0
- package/dist/tui/state/store.js.map +1 -0
- package/dist/tui/state/types.d.ts +137 -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 +167 -0
- package/dist/tui/topic-reader.js.map +1 -0
- package/dist/update.d.ts +17 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/update.js +88 -0
- package/dist/update.js.map +1 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/images/tui.jpg +0 -0
- package/package.json +4 -2
|
@@ -0,0 +1,2589 @@
|
|
|
1
|
+
import { Cc98Client } from "../api/client.js";
|
|
2
|
+
import { TokenStore } from "../storage/token-store.js";
|
|
3
|
+
import { checkForUpdate } from "../update.js";
|
|
4
|
+
import { appVersion } from "../version.js";
|
|
5
|
+
import { ansi, bg, fg, stripAnsi } from "./ansi.js";
|
|
6
|
+
import { CachedCc98Client } from "./cached-client.js";
|
|
7
|
+
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: "notices", label: "通知", hint: "系统与回复" },
|
|
33
|
+
{ id: "me", label: "我的", hint: "当前账号" },
|
|
34
|
+
{ id: "more", label: "更多", hint: "只读内容" },
|
|
35
|
+
{ id: "settings", label: "设置", hint: "账号与配置" }
|
|
36
|
+
];
|
|
37
|
+
const settingsItems = [
|
|
38
|
+
{ title: "切换账号", meta: "account", detail: "选择或管理登录账号" },
|
|
39
|
+
{ title: "检查更新", meta: "update", detail: "检查 CC98-CLI 新版本" },
|
|
40
|
+
{ title: "缓存管理", meta: "cache", detail: "查看和清理本地缓存" },
|
|
41
|
+
{ title: "快捷键帮助", meta: "help", detail: "查看所有可用快捷键" },
|
|
42
|
+
{ title: "退出登录", meta: "logout", detail: "清除本地登录信息" }
|
|
43
|
+
];
|
|
44
|
+
export async function runTui() {
|
|
45
|
+
const terminal = new Terminal();
|
|
46
|
+
const tokenStore = new TokenStore();
|
|
47
|
+
const client = new CachedCc98Client(new Cc98Client({ tokenStore }));
|
|
48
|
+
let exitRequested = false;
|
|
49
|
+
const state = {
|
|
50
|
+
mode: "list",
|
|
51
|
+
focus: "nav",
|
|
52
|
+
navIndex: 0,
|
|
53
|
+
itemIndex: 0,
|
|
54
|
+
scroll: 0,
|
|
55
|
+
loading: true,
|
|
56
|
+
loadingMore: false,
|
|
57
|
+
status: "",
|
|
58
|
+
viewTitle: "十大",
|
|
59
|
+
items: [],
|
|
60
|
+
stats: [],
|
|
61
|
+
overview: [],
|
|
62
|
+
modal: null,
|
|
63
|
+
menuIndex: 0,
|
|
64
|
+
menuItems: [],
|
|
65
|
+
tabId: "default",
|
|
66
|
+
tabs: [],
|
|
67
|
+
searchMode: "topics",
|
|
68
|
+
searchQuery: "",
|
|
69
|
+
searchResults: [],
|
|
70
|
+
noticeType: "system",
|
|
71
|
+
inputMode: false,
|
|
72
|
+
inputPrompt: "",
|
|
73
|
+
inputValue: "",
|
|
74
|
+
infoLines: []
|
|
75
|
+
};
|
|
76
|
+
terminal.enter();
|
|
77
|
+
try {
|
|
78
|
+
await new Promise((resolve) => {
|
|
79
|
+
let closed = false;
|
|
80
|
+
let loadVersion = 0;
|
|
81
|
+
let currentAbort;
|
|
82
|
+
const nextSignal = () => {
|
|
83
|
+
currentAbort?.abort();
|
|
84
|
+
currentAbort = new AbortController();
|
|
85
|
+
return currentAbort.signal;
|
|
86
|
+
};
|
|
87
|
+
const render = () => terminal.render(draw(state, terminal.size()));
|
|
88
|
+
const load = async (force = false) => {
|
|
89
|
+
const version = ++loadVersion;
|
|
90
|
+
const signal = nextSignal();
|
|
91
|
+
const nav = navItems[state.navIndex];
|
|
92
|
+
state.viewTitle = nav.label;
|
|
93
|
+
state.loading = true;
|
|
94
|
+
state.error = undefined;
|
|
95
|
+
state.itemIndex = 0;
|
|
96
|
+
state.scroll = 0;
|
|
97
|
+
state.mode = nav.id === "settings" && state.mode === "settings" ? "settings" : "list";
|
|
98
|
+
if (state.mode === "settings") {
|
|
99
|
+
state.focus = "content";
|
|
100
|
+
}
|
|
101
|
+
state.items = [];
|
|
102
|
+
state.stats = [];
|
|
103
|
+
state.topic = undefined;
|
|
104
|
+
state.parentList = undefined;
|
|
105
|
+
state.currentBoard = undefined;
|
|
106
|
+
state.currentChat = undefined;
|
|
107
|
+
render();
|
|
108
|
+
try {
|
|
109
|
+
state.account = await tokenStore.getCurrentAccountName();
|
|
110
|
+
const next = await loadView(client, nav.id, force, signal);
|
|
111
|
+
if (closed || version !== loadVersion) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
state.viewTitle = next.title;
|
|
115
|
+
state.items = next.items;
|
|
116
|
+
state.stats = next.stats;
|
|
117
|
+
if (next.overview) {
|
|
118
|
+
state.overview = next.overview;
|
|
119
|
+
}
|
|
120
|
+
state.status = next.status ?? getStatus(state);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (isAbortError(error)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (closed || version !== loadVersion) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
130
|
+
state.items = [];
|
|
131
|
+
state.stats = [];
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (!closed && version === loadVersion) {
|
|
135
|
+
state.loading = false;
|
|
136
|
+
render();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const close = () => {
|
|
141
|
+
if (closed) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
closed = true;
|
|
145
|
+
exitRequested = true;
|
|
146
|
+
currentAbort?.abort();
|
|
147
|
+
offKey();
|
|
148
|
+
offResize();
|
|
149
|
+
resolve();
|
|
150
|
+
};
|
|
151
|
+
const offResize = terminal.onResize(render);
|
|
152
|
+
// Helper to get menu items for current context
|
|
153
|
+
const getMenuItems = () => {
|
|
154
|
+
const items = [];
|
|
155
|
+
if (state.mode === "topic") {
|
|
156
|
+
items.push({ label: "刷新", key: "r", action: "refresh" });
|
|
157
|
+
items.push({ label: "返回列表", key: "h", action: "back" });
|
|
158
|
+
}
|
|
159
|
+
else if (state.mode === "list") {
|
|
160
|
+
items.push({ label: "刷新", key: "r", action: "refresh" });
|
|
161
|
+
if (state.currentBoard) {
|
|
162
|
+
items.push({ label: "返回版面列表", key: "h", action: "back" });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return items;
|
|
166
|
+
};
|
|
167
|
+
const offKey = terminal.onKey((key) => {
|
|
168
|
+
// Input mode
|
|
169
|
+
if (state.inputMode) {
|
|
170
|
+
if (key === "\x1b") {
|
|
171
|
+
state.inputMode = false;
|
|
172
|
+
state.inputValue = "";
|
|
173
|
+
render();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (key === "\r") {
|
|
177
|
+
if (state.inputCallback) {
|
|
178
|
+
state.inputCallback(state.inputValue);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (key === "\x7f") {
|
|
183
|
+
state.inputValue = state.inputValue.slice(0, -1);
|
|
184
|
+
render();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (key.length === 1 && key >= " ") {
|
|
188
|
+
state.inputValue += key;
|
|
189
|
+
render();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Global: Ctrl+C or q to quit
|
|
195
|
+
if (key === "\u0003" || key === "q") {
|
|
196
|
+
close();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Global: ? for help
|
|
200
|
+
if (key === "?") {
|
|
201
|
+
state.modal = state.modal === "help" ? null : "help";
|
|
202
|
+
render();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Handle modal states
|
|
206
|
+
if (state.modal === "help") {
|
|
207
|
+
if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "?" || key === "\r") {
|
|
208
|
+
state.modal = null;
|
|
209
|
+
render();
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (state.modal === "search") {
|
|
214
|
+
if (key === "\x1b") {
|
|
215
|
+
state.modal = null;
|
|
216
|
+
state.searchQuery = "";
|
|
217
|
+
render();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (key === "\t") {
|
|
221
|
+
state.searchMode = state.searchMode === "topics" ? "users" : "topics";
|
|
222
|
+
state.searchResults = [];
|
|
223
|
+
state.itemIndex = 0;
|
|
224
|
+
render();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if ((key === "j" || key === "\x1b[B") && state.searchResults.length > 0) {
|
|
228
|
+
state.itemIndex = Math.min(state.searchResults.length - 1, state.itemIndex + 1);
|
|
229
|
+
render();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if ((key === "k" || key === "\x1b[A") && state.searchResults.length > 0) {
|
|
233
|
+
state.itemIndex = Math.max(0, state.itemIndex - 1);
|
|
234
|
+
render();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (key === "\r") {
|
|
238
|
+
const selected = state.searchResults[state.itemIndex];
|
|
239
|
+
if (selected) {
|
|
240
|
+
state.modal = null;
|
|
241
|
+
void activateContentItem(client, state, selected, render, nextSignal());
|
|
242
|
+
}
|
|
243
|
+
else if (state.searchQuery.trim()) {
|
|
244
|
+
void performSearch(client, state, render, nextSignal());
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (key === "\x7f") {
|
|
249
|
+
state.searchQuery = state.searchQuery.slice(0, -1);
|
|
250
|
+
state.searchResults = [];
|
|
251
|
+
state.itemIndex = 0;
|
|
252
|
+
render();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (key.length === 1 && key >= " ") {
|
|
256
|
+
state.searchQuery += key;
|
|
257
|
+
state.searchResults = [];
|
|
258
|
+
state.itemIndex = 0;
|
|
259
|
+
render();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (state.modal === "info") {
|
|
265
|
+
if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "\r" || key === "q") {
|
|
266
|
+
state.modal = null;
|
|
267
|
+
state.infoTitle = undefined;
|
|
268
|
+
state.infoLines = [];
|
|
269
|
+
render();
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (state.modal === "user") {
|
|
274
|
+
if (key === "\x1b") {
|
|
275
|
+
state.modal = null;
|
|
276
|
+
state.userDetail = undefined;
|
|
277
|
+
render();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (key === "f" && state.userDetail) {
|
|
281
|
+
void toggleFollow(client, state, render);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (key === "m" && state.userDetail) {
|
|
285
|
+
state.inputMode = true;
|
|
286
|
+
state.inputPrompt = `发送私信给 ${state.userDetail.name}: `;
|
|
287
|
+
state.inputValue = "";
|
|
288
|
+
state.inputCallback = (value) => {
|
|
289
|
+
if (value.trim() && state.userDetail) {
|
|
290
|
+
void sendPrivateMessage(client, state, state.userDetail.userId, value.trim(), render);
|
|
291
|
+
}
|
|
292
|
+
state.inputMode = false;
|
|
293
|
+
state.inputValue = "";
|
|
294
|
+
render();
|
|
295
|
+
};
|
|
296
|
+
render();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (state.modal === "menu") {
|
|
302
|
+
if (key === "j" || key === "\x1b[B") {
|
|
303
|
+
state.menuIndex = Math.min(state.menuItems.length - 1, state.menuIndex + 1);
|
|
304
|
+
render();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (key === "k" || key === "\x1b[A") {
|
|
308
|
+
state.menuIndex = Math.max(0, state.menuIndex - 1);
|
|
309
|
+
render();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (key === "\r" || key === "l" || key === "\x1b[C") {
|
|
313
|
+
const selected = state.menuItems[state.menuIndex];
|
|
314
|
+
state.modal = null;
|
|
315
|
+
if (selected?.action === "refresh") {
|
|
316
|
+
void load(true);
|
|
317
|
+
}
|
|
318
|
+
else if (selected?.action === "back") {
|
|
319
|
+
if (state.mode === "topic") {
|
|
320
|
+
currentAbort?.abort();
|
|
321
|
+
state.mode = "list";
|
|
322
|
+
state.focus = "content";
|
|
323
|
+
state.status = getStatus(state);
|
|
324
|
+
render();
|
|
325
|
+
}
|
|
326
|
+
else if (state.parentList) {
|
|
327
|
+
currentAbort?.abort();
|
|
328
|
+
restoreParentList(state);
|
|
329
|
+
render();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (key === "h" || key === "\x1b[D" || key === "\x1b" || key === "o") {
|
|
335
|
+
state.modal = null;
|
|
336
|
+
render();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Topic mode
|
|
342
|
+
if (state.mode === "topic") {
|
|
343
|
+
if (/^\d$/.test(key) && state.topic) {
|
|
344
|
+
state.topic.floorInput = `${state.topic.floorInput}${key}`.slice(0, 6);
|
|
345
|
+
state.status = `跳转到 ${state.topic.floorInput} 楼:Enter 确认 Esc 取消`;
|
|
346
|
+
render();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (key === "\x7f" && state.topic?.floorInput) {
|
|
350
|
+
state.topic.floorInput = state.topic.floorInput.slice(0, -1);
|
|
351
|
+
state.status = state.topic.floorInput
|
|
352
|
+
? `跳转到 ${state.topic.floorInput} 楼:Enter 确认 Esc 取消`
|
|
353
|
+
: getStatus(state);
|
|
354
|
+
render();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (key === "\r" && state.topic?.floorInput) {
|
|
358
|
+
const floor = Number(state.topic.floorInput);
|
|
359
|
+
state.topic.floorInput = "";
|
|
360
|
+
if (Number.isInteger(floor) && floor > 0) {
|
|
361
|
+
void jumpToTopicFloor(client, state, floor, render, nextSignal());
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if ((key === "]" || key === "】") && state.topic) {
|
|
366
|
+
jumpRelativeTopicFloor(state, 1);
|
|
367
|
+
state.status = getStatus(state);
|
|
368
|
+
render();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if ((key === "[" || key === "【") && state.topic) {
|
|
372
|
+
jumpRelativeTopicFloor(state, -1);
|
|
373
|
+
state.status = getStatus(state);
|
|
374
|
+
render();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (key === "h" || key === "\x1b[D") {
|
|
378
|
+
currentAbort?.abort();
|
|
379
|
+
state.mode = "list";
|
|
380
|
+
state.focus = "content";
|
|
381
|
+
state.status = getStatus(state);
|
|
382
|
+
render();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (key === "\x1b" && state.topic?.floorInput) {
|
|
386
|
+
state.topic.floorInput = "";
|
|
387
|
+
state.status = getStatus(state);
|
|
388
|
+
render();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (key === "j" || key === "\x1b[B") {
|
|
392
|
+
const maxScroll = Math.max(0, (state.topic?.lines.length ?? 0) - 1);
|
|
393
|
+
const wasAtEnd = state.scroll >= maxScroll;
|
|
394
|
+
state.scroll = Math.min(maxScroll, state.scroll + 1);
|
|
395
|
+
render();
|
|
396
|
+
if (wasAtEnd && state.topic?.hasMore && !state.loadingMore) {
|
|
397
|
+
void loadNextTopicPage(client, state, render, nextSignal(), true);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (key === "k" || key === "\x1b[A") {
|
|
402
|
+
state.scroll = Math.max(0, state.scroll - 1);
|
|
403
|
+
render();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (key === "n" || key === " ") {
|
|
407
|
+
void loadNextTopicPage(client, state, render, nextSignal());
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (key === "r") {
|
|
411
|
+
if (state.topic) {
|
|
412
|
+
void openTopic(client, state, state.topic.topicId, render, true, nextSignal());
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (key === "s" && state.topic) {
|
|
417
|
+
void toggleFavorite(client, state, render);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (key === "l" && state.topic) {
|
|
421
|
+
void reactToCurrentPost(client, state, true, render);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (key === "d" && state.topic) {
|
|
425
|
+
void reactToCurrentPost(client, state, false, render);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (key === "u" && state.topic) {
|
|
429
|
+
void showUserDetail(client, state, render, nextSignal());
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (key === "v" && state.topic) {
|
|
433
|
+
void showTopicVote(client, state, render, nextSignal());
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (key === "a" && state.topic) {
|
|
437
|
+
void showPostReactionState(client, state, render, nextSignal());
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (key === "o") {
|
|
441
|
+
state.modal = "menu";
|
|
442
|
+
state.menuItems = getMenuItems();
|
|
443
|
+
state.menuIndex = 0;
|
|
444
|
+
render();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// Settings mode
|
|
450
|
+
if (state.mode === "settings") {
|
|
451
|
+
if (key === "j" || key === "\x1b[B") {
|
|
452
|
+
state.itemIndex = Math.min(settingsItems.length - 1, state.itemIndex + 1);
|
|
453
|
+
render();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (key === "k" || key === "\x1b[A") {
|
|
457
|
+
state.itemIndex = Math.max(0, state.itemIndex - 1);
|
|
458
|
+
render();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (key === "h" || key === "\x1b[D") {
|
|
462
|
+
state.mode = "list";
|
|
463
|
+
state.focus = "nav";
|
|
464
|
+
state.status = getStatus(state);
|
|
465
|
+
render();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (key === "l" || key === "\x1b[C" || key === "\r") {
|
|
469
|
+
const selected = settingsItems[state.itemIndex];
|
|
470
|
+
if (selected?.meta === "help") {
|
|
471
|
+
state.modal = "help";
|
|
472
|
+
render();
|
|
473
|
+
}
|
|
474
|
+
else if (selected?.meta === "cache") {
|
|
475
|
+
state.status = "正在清理缓存...";
|
|
476
|
+
render();
|
|
477
|
+
void client.clearCache().then(() => {
|
|
478
|
+
state.status = "缓存已清理";
|
|
479
|
+
void load(true);
|
|
480
|
+
}).catch(() => {
|
|
481
|
+
state.status = "缓存清理失败";
|
|
482
|
+
render();
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
else if (selected?.meta === "logout") {
|
|
486
|
+
state.status = "退出登录功能开发中...";
|
|
487
|
+
render();
|
|
488
|
+
}
|
|
489
|
+
else if (selected?.meta === "account") {
|
|
490
|
+
state.status = "账号切换功能开发中...";
|
|
491
|
+
render();
|
|
492
|
+
}
|
|
493
|
+
else if (selected?.meta === "update") {
|
|
494
|
+
state.status = "正在检查 GitHub Release...";
|
|
495
|
+
render();
|
|
496
|
+
void checkForUpdate().then((result) => {
|
|
497
|
+
state.status = result.message;
|
|
498
|
+
render();
|
|
499
|
+
}).catch((error) => {
|
|
500
|
+
state.status = error instanceof Error ? error.message : "检查更新失败";
|
|
501
|
+
render();
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// Nav focus
|
|
509
|
+
if (state.focus === "nav") {
|
|
510
|
+
if (key === "j" || key === "\x1b[B") {
|
|
511
|
+
state.navIndex = Math.min(navItems.length - 1, state.navIndex + 1);
|
|
512
|
+
void load();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (key === "k" || key === "\x1b[A") {
|
|
516
|
+
state.navIndex = Math.max(0, state.navIndex - 1);
|
|
517
|
+
void load();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (key === "l" || key === "\x1b[C") {
|
|
521
|
+
if (!state.loading && state.items.length > 0) {
|
|
522
|
+
if (navItems[state.navIndex]?.id === "settings") {
|
|
523
|
+
state.mode = "settings";
|
|
524
|
+
}
|
|
525
|
+
state.focus = "content";
|
|
526
|
+
state.status = getStatus(state);
|
|
527
|
+
render();
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (key === "\r") {
|
|
532
|
+
if (!state.loading && state.items.length > 0) {
|
|
533
|
+
if (navItems[state.navIndex]?.id === "settings") {
|
|
534
|
+
state.mode = "settings";
|
|
535
|
+
}
|
|
536
|
+
state.focus = "content";
|
|
537
|
+
state.itemIndex = 0;
|
|
538
|
+
state.status = getStatus(state);
|
|
539
|
+
render();
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (key === "r") {
|
|
544
|
+
void load(true);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// Content focus
|
|
550
|
+
if (key === "j" || key === "\x1b[B") {
|
|
551
|
+
state.itemIndex = Math.min(Math.max(0, state.items.length - 1), state.itemIndex + 1);
|
|
552
|
+
render();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (key === "k" || key === "\x1b[A") {
|
|
556
|
+
state.itemIndex = Math.max(0, state.itemIndex - 1);
|
|
557
|
+
render();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (key === "h" || key === "\x1b[D") {
|
|
561
|
+
if (state.parentList) {
|
|
562
|
+
currentAbort?.abort();
|
|
563
|
+
restoreParentList(state);
|
|
564
|
+
render();
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
currentAbort?.abort();
|
|
568
|
+
state.focus = "nav";
|
|
569
|
+
state.status = getStatus(state);
|
|
570
|
+
render();
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (key === "\x1b") {
|
|
575
|
+
if (state.parentList) {
|
|
576
|
+
currentAbort?.abort();
|
|
577
|
+
restoreParentList(state);
|
|
578
|
+
render();
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
currentAbort?.abort();
|
|
582
|
+
state.focus = "nav";
|
|
583
|
+
state.status = getStatus(state);
|
|
584
|
+
render();
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (key === "l" || key === "\x1b[C") {
|
|
589
|
+
const selected = state.items[state.itemIndex];
|
|
590
|
+
if (selected) {
|
|
591
|
+
void activateContentItem(client, state, selected, render, nextSignal());
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
state.status = "当前条目不可进入";
|
|
595
|
+
render();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (key === "\r") {
|
|
599
|
+
const selected = state.items[state.itemIndex];
|
|
600
|
+
if (selected) {
|
|
601
|
+
void activateContentItem(client, state, selected, render, nextSignal());
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
state.status = "当前条目不可进入";
|
|
605
|
+
render();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if ((key === "n" || key === " ") && state.currentChat) {
|
|
609
|
+
void loadNextChatPage(client, state, render, nextSignal());
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (key === "r") {
|
|
613
|
+
if (state.currentBoard) {
|
|
614
|
+
void openBoard(client, state, state.currentBoard.boardId, state.currentBoard.title, render, true, nextSignal(), false);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (state.currentChat) {
|
|
618
|
+
void openChat(client, state, state.currentChat.userId, state.currentChat.title, render, true, nextSignal(), false);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
void load(true);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (key === "/") {
|
|
625
|
+
state.modal = "search";
|
|
626
|
+
state.searchQuery = "";
|
|
627
|
+
state.searchResults = [];
|
|
628
|
+
state.searchMode = "topics";
|
|
629
|
+
render();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (key === "o") {
|
|
633
|
+
state.modal = "menu";
|
|
634
|
+
state.menuItems = getMenuItems();
|
|
635
|
+
state.menuIndex = 0;
|
|
636
|
+
render();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
render();
|
|
641
|
+
void load();
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
terminal.exit();
|
|
646
|
+
process.stdout.write("\n");
|
|
647
|
+
if (exitRequested) {
|
|
648
|
+
process.exit(0);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function openTopic(client, state, topicId, render, force = false, signal) {
|
|
653
|
+
state.mode = "topic";
|
|
654
|
+
state.loading = true;
|
|
655
|
+
state.loadingMore = false;
|
|
656
|
+
state.error = undefined;
|
|
657
|
+
state.scroll = 0;
|
|
658
|
+
state.topic = {
|
|
659
|
+
topicId,
|
|
660
|
+
title: `#${topicId}`,
|
|
661
|
+
meta: "",
|
|
662
|
+
lines: [],
|
|
663
|
+
posts: [],
|
|
664
|
+
loaded: 0,
|
|
665
|
+
size: 10,
|
|
666
|
+
hasMore: true,
|
|
667
|
+
imageCount: 0,
|
|
668
|
+
linkCount: 0,
|
|
669
|
+
floorInput: ""
|
|
670
|
+
};
|
|
671
|
+
state.status = "正在打开帖子...";
|
|
672
|
+
render();
|
|
673
|
+
try {
|
|
674
|
+
const [topicRaw, postsRaw] = await Promise.all([
|
|
675
|
+
client.getTopic(topicId, force, signal),
|
|
676
|
+
client.getTopicPosts(topicId, 0, 10, force, signal)
|
|
677
|
+
]);
|
|
678
|
+
const topic = asObject(topicRaw);
|
|
679
|
+
const posts = asArray(postsRaw);
|
|
680
|
+
const reader = buildTopicReader(topicId, topic, posts, 10);
|
|
681
|
+
state.topic = reader;
|
|
682
|
+
state.viewTitle = reader.title;
|
|
683
|
+
state.status = reader.hasMore
|
|
684
|
+
? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
|
|
685
|
+
: "j/k 滚动 h/Esc 返回 r 刷新";
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
if (isAbortError(error)) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
692
|
+
state.status = state.parentList
|
|
693
|
+
? "版面读取失败;Esc/Backspace 返回版面列表 h 返回左栏 r 重试"
|
|
694
|
+
: "版面读取失败;h 返回左栏 r 重试";
|
|
695
|
+
}
|
|
696
|
+
finally {
|
|
697
|
+
state.loading = false;
|
|
698
|
+
render();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function openBoard(client, state, boardId, boardTitle, render, force = false, signal, pushParent = true) {
|
|
702
|
+
if (pushParent) {
|
|
703
|
+
state.parentList = {
|
|
704
|
+
title: state.viewTitle,
|
|
705
|
+
items: state.items,
|
|
706
|
+
stats: state.stats,
|
|
707
|
+
itemIndex: state.itemIndex,
|
|
708
|
+
status: state.status
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
state.mode = "list";
|
|
712
|
+
state.focus = "content";
|
|
713
|
+
state.loading = true;
|
|
714
|
+
state.error = undefined;
|
|
715
|
+
state.itemIndex = 0;
|
|
716
|
+
state.scroll = 0;
|
|
717
|
+
state.topic = undefined;
|
|
718
|
+
state.currentChat = undefined;
|
|
719
|
+
state.currentBoard = { boardId, title: boardTitle };
|
|
720
|
+
state.viewTitle = boardTitle;
|
|
721
|
+
state.items = [];
|
|
722
|
+
state.stats = [
|
|
723
|
+
{ title: "版面", detail: `#${boardId}` },
|
|
724
|
+
{ title: "缓存", detail: "topics 30s" }
|
|
725
|
+
];
|
|
726
|
+
state.status = "正在读取版面帖子...";
|
|
727
|
+
render();
|
|
728
|
+
try {
|
|
729
|
+
const topics = asArray(await client.getBoardTopics(boardId, 0, 12, false, force, signal));
|
|
730
|
+
state.items = [
|
|
731
|
+
{ title: "精华帖", meta: `board #${boardId}`, detail: "查看本版精华主题", action: `board-best:${boardId}` },
|
|
732
|
+
...topics.map((topic) => topicItem(topic))
|
|
733
|
+
];
|
|
734
|
+
state.stats = [
|
|
735
|
+
{ title: "版面", detail: `#${boardId}` },
|
|
736
|
+
{ title: "主题", detail: `${topics.length} 条` },
|
|
737
|
+
{ title: "缓存", detail: "topics 30s" }
|
|
738
|
+
];
|
|
739
|
+
state.status = "版面帖子:j/k 选择 l 打开帖子 h 返回 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.loading = false;
|
|
749
|
+
render();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function openChat(client, state, userId, title, render, force = false, signal, pushParent = true) {
|
|
753
|
+
if (pushParent) {
|
|
754
|
+
state.parentList = {
|
|
755
|
+
title: state.viewTitle,
|
|
756
|
+
items: state.items,
|
|
757
|
+
stats: state.stats,
|
|
758
|
+
itemIndex: state.itemIndex,
|
|
759
|
+
status: state.status
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
state.mode = "list";
|
|
763
|
+
state.focus = "content";
|
|
764
|
+
state.loading = true;
|
|
765
|
+
state.error = undefined;
|
|
766
|
+
state.itemIndex = 0;
|
|
767
|
+
state.scroll = 0;
|
|
768
|
+
state.topic = undefined;
|
|
769
|
+
state.currentBoard = undefined;
|
|
770
|
+
state.currentChat = { userId, title, loaded: 0, size: 10, hasMore: true };
|
|
771
|
+
state.viewTitle = title;
|
|
772
|
+
state.items = [];
|
|
773
|
+
state.stats = [
|
|
774
|
+
{ title: "用户", detail: `#${userId}` },
|
|
775
|
+
{ title: "缓存", detail: "history 15s" }
|
|
776
|
+
];
|
|
777
|
+
state.status = "正在读取私信...";
|
|
778
|
+
render();
|
|
779
|
+
try {
|
|
780
|
+
const messages = asArray(await client.getChatHistory(userId, 0, 10, force, signal));
|
|
781
|
+
state.items = chatMessageItems(messages, title, userId);
|
|
782
|
+
state.currentChat.loaded = messages.length;
|
|
783
|
+
state.currentChat.hasMore = messages.length === state.currentChat.size;
|
|
784
|
+
state.itemIndex = Math.max(0, state.items.length - 1);
|
|
785
|
+
state.stats = [
|
|
786
|
+
{ title: "用户", detail: `#${userId}` },
|
|
787
|
+
{ title: "消息", detail: `${messages.length} 条` },
|
|
788
|
+
{ title: "缓存", detail: "history 15s" }
|
|
789
|
+
];
|
|
790
|
+
state.status = state.currentChat.hasMore
|
|
791
|
+
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
792
|
+
: "私信:j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
if (isAbortError(error)) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
799
|
+
state.status = "私信读取失败;Esc/Backspace 返回联系人 h 返回左栏 r 重试";
|
|
800
|
+
}
|
|
801
|
+
finally {
|
|
802
|
+
state.loading = false;
|
|
803
|
+
render();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
async function loadNextChatPage(client, state, render, signal) {
|
|
807
|
+
if (!state.currentChat || state.loadingMore || !state.currentChat.hasMore) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
state.loadingMore = true;
|
|
811
|
+
state.status = "正在读取更早私信...";
|
|
812
|
+
render();
|
|
813
|
+
try {
|
|
814
|
+
const chat = state.currentChat;
|
|
815
|
+
const messages = asArray(await client.getChatHistory(chat.userId, chat.loaded, chat.size, false, signal));
|
|
816
|
+
const olderItems = chatMessageItems(messages, chat.title, chat.userId);
|
|
817
|
+
state.items = [...olderItems, ...state.items];
|
|
818
|
+
state.itemIndex += olderItems.length;
|
|
819
|
+
state.scroll += olderItems.length;
|
|
820
|
+
chat.loaded += messages.length;
|
|
821
|
+
chat.hasMore = messages.length === chat.size;
|
|
822
|
+
state.stats = [
|
|
823
|
+
{ title: "用户", detail: `#${chat.userId}` },
|
|
824
|
+
{ title: "消息", detail: `${chat.loaded} 条` },
|
|
825
|
+
{ title: "缓存", detail: "history 15s" }
|
|
826
|
+
];
|
|
827
|
+
state.status = chat.hasMore
|
|
828
|
+
? "私信:j/k 滚动 n/Space 更早消息 Esc/Backspace 返回联系人 h 返回左栏"
|
|
829
|
+
: "已到最早私信;j/k 滚动 Esc/Backspace 返回联系人 h 返回左栏";
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
if (isAbortError(error)) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
836
|
+
state.status = "更早私信读取失败;n/Space 重试 Esc/Backspace 返回联系人";
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
state.loadingMore = false;
|
|
840
|
+
render();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function restoreParentList(state) {
|
|
844
|
+
if (!state.parentList) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const parent = state.parentList;
|
|
848
|
+
state.mode = "list";
|
|
849
|
+
state.focus = "content";
|
|
850
|
+
state.loading = false;
|
|
851
|
+
state.loadingMore = false;
|
|
852
|
+
state.error = undefined;
|
|
853
|
+
state.topic = undefined;
|
|
854
|
+
state.currentBoard = undefined;
|
|
855
|
+
state.currentChat = undefined;
|
|
856
|
+
state.parentList = undefined;
|
|
857
|
+
state.viewTitle = parent.title;
|
|
858
|
+
state.items = parent.items;
|
|
859
|
+
state.stats = parent.stats;
|
|
860
|
+
state.itemIndex = parent.itemIndex;
|
|
861
|
+
state.status = parent.status;
|
|
862
|
+
}
|
|
863
|
+
async function loadNextTopicPage(client, state, render, signal, advanceAfterLoad = false) {
|
|
864
|
+
if (!state.topic || state.loadingMore || !state.topic.hasMore) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
state.loadingMore = true;
|
|
868
|
+
state.status = "正在加载下一页...";
|
|
869
|
+
render();
|
|
870
|
+
try {
|
|
871
|
+
const posts = asArray(await client.getTopicPosts(state.topic.topicId, state.topic.loaded, state.topic.size, false, signal));
|
|
872
|
+
const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), state.topic.lines.length);
|
|
873
|
+
state.topic.lines.push(...next.lines);
|
|
874
|
+
state.topic.posts.push(...next.posts);
|
|
875
|
+
state.topic.imageCount += next.imageCount;
|
|
876
|
+
state.topic.linkCount += next.linkCount;
|
|
877
|
+
state.topic.loaded += posts.length;
|
|
878
|
+
state.topic.hasMore = posts.length === state.topic.size;
|
|
879
|
+
if (advanceAfterLoad && posts.length > 0) {
|
|
880
|
+
state.scroll = Math.min(Math.max(0, state.topic.lines.length - 1), state.scroll + 1);
|
|
881
|
+
}
|
|
882
|
+
state.status = state.topic.hasMore
|
|
883
|
+
? "j/k 滚动 n/Space 下一页 h/Esc 返回 r 刷新"
|
|
884
|
+
: "已到最后一页 j/k 滚动 h/Esc 返回 r 刷新";
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
if (isAbortError(error)) {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
state.loadingMore = false;
|
|
894
|
+
render();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function currentTopicWidthEstimate() {
|
|
898
|
+
return Number(process.env.COLUMNS) > 90 ? 56 : 44;
|
|
899
|
+
}
|
|
900
|
+
function buildTopicReader(topicId, topic, posts, size) {
|
|
901
|
+
const title = String(topic.title ?? `#${topicId}`);
|
|
902
|
+
const meta = [
|
|
903
|
+
topic.userName,
|
|
904
|
+
topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
|
|
905
|
+
topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
|
|
906
|
+
].filter(Boolean).join(" · ");
|
|
907
|
+
const rendered = renderPosts(posts, currentTopicWidthEstimate());
|
|
908
|
+
return {
|
|
909
|
+
topicId,
|
|
910
|
+
title,
|
|
911
|
+
meta,
|
|
912
|
+
lines: rendered.lines,
|
|
913
|
+
posts: rendered.posts,
|
|
914
|
+
loaded: posts.length,
|
|
915
|
+
size,
|
|
916
|
+
hasMore: posts.length === size,
|
|
917
|
+
imageCount: rendered.imageCount,
|
|
918
|
+
linkCount: rendered.linkCount,
|
|
919
|
+
floorInput: ""
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function renderPosts(posts, width, lineOffset = 0) {
|
|
923
|
+
const lines = [];
|
|
924
|
+
const entries = [];
|
|
925
|
+
let imageCount = 0;
|
|
926
|
+
let linkCount = 0;
|
|
927
|
+
posts.forEach((postRaw) => {
|
|
928
|
+
const post = asObject(postRaw);
|
|
929
|
+
const lineStart = lineOffset + lines.length;
|
|
930
|
+
const postLines = [];
|
|
931
|
+
const floorNumber = asNumber(post.floor);
|
|
932
|
+
const floor = floorNumber !== undefined ? `#${floorNumber}` : "#?";
|
|
933
|
+
const user = asObject(post.user ?? post.User);
|
|
934
|
+
const userId = asNumber(post.userId ?? post.UserId ?? user.id ?? user.Id);
|
|
935
|
+
const author = String(post.userName ?? post.UserName ?? user.name ?? user.Name ?? "匿名");
|
|
936
|
+
const time = typeof post.time === "string" ? post.time.replace("T", " ").slice(0, 16) : "";
|
|
937
|
+
const likeCount = asNumber(post.likeCount) ?? 0;
|
|
938
|
+
const dislikeCount = asNumber(post.dislikeCount) ?? 0;
|
|
939
|
+
const like = likeCount > 0 ? ` · ${likeCount} 赞` : "";
|
|
940
|
+
const push = (text, kind, extra = {}) => {
|
|
941
|
+
const line = lineOffset + lines.length;
|
|
942
|
+
lines.push(text);
|
|
943
|
+
postLines.push({
|
|
944
|
+
line,
|
|
945
|
+
row: postLines.length,
|
|
946
|
+
floor: floorNumber,
|
|
947
|
+
kind,
|
|
948
|
+
text,
|
|
949
|
+
...extra
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
push(`${floor} ${author}${time ? ` · ${time}` : ""}${like}`, "header");
|
|
953
|
+
push("─".repeat(Math.max(8, width)), "divider");
|
|
954
|
+
const content = typeof post.content === "string" ? post.content : "";
|
|
955
|
+
const rendered = renderUbbToLines(content, width);
|
|
956
|
+
rendered.lines.forEach((renderedLine) => {
|
|
957
|
+
const imageIndex = parseBracketIndex(renderedLine, "image");
|
|
958
|
+
const linkIndex = parseBracketIndex(renderedLine, "link");
|
|
959
|
+
const kind = renderedLine.trim() === ""
|
|
960
|
+
? "blank"
|
|
961
|
+
: imageIndex !== undefined
|
|
962
|
+
? "image"
|
|
963
|
+
: linkIndex !== undefined
|
|
964
|
+
? "link"
|
|
965
|
+
: renderedLine.startsWith("│ ")
|
|
966
|
+
? "quote"
|
|
967
|
+
: "text";
|
|
968
|
+
push(renderedLine, kind, {
|
|
969
|
+
imageIndex,
|
|
970
|
+
imageUrl: imageIndex !== undefined ? rendered.images[imageIndex - 1] : undefined,
|
|
971
|
+
linkIndex,
|
|
972
|
+
linkUrl: linkIndex !== undefined ? rendered.links[linkIndex - 1] : undefined
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
push("", "blank");
|
|
976
|
+
const preview = rendered.lines.find((value) => value.trim() &&
|
|
977
|
+
!value.startsWith("[image ") &&
|
|
978
|
+
!value.startsWith("[link ")) ?? "";
|
|
979
|
+
entries.push({
|
|
980
|
+
id: asNumber(post.id),
|
|
981
|
+
userId,
|
|
982
|
+
floor: floorNumber,
|
|
983
|
+
author,
|
|
984
|
+
time,
|
|
985
|
+
likeCount,
|
|
986
|
+
dislikeCount,
|
|
987
|
+
rating: formatRating(post),
|
|
988
|
+
preview,
|
|
989
|
+
lineStart,
|
|
990
|
+
lineEnd: lineOffset + lines.length - 1,
|
|
991
|
+
imageCount: rendered.images.length,
|
|
992
|
+
linkCount: rendered.links.length,
|
|
993
|
+
images: rendered.images,
|
|
994
|
+
links: rendered.links,
|
|
995
|
+
lines: postLines
|
|
996
|
+
});
|
|
997
|
+
imageCount += rendered.images.length;
|
|
998
|
+
linkCount += rendered.links.length;
|
|
999
|
+
});
|
|
1000
|
+
return { lines, posts: entries, imageCount, linkCount };
|
|
1001
|
+
}
|
|
1002
|
+
async function jumpToTopicFloor(client, state, floor, render, signal) {
|
|
1003
|
+
const topic = state.topic;
|
|
1004
|
+
if (!topic) {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const loaded = findTopicPostByFloor(topic, floor);
|
|
1008
|
+
if (loaded) {
|
|
1009
|
+
state.scroll = loaded.lineStart;
|
|
1010
|
+
state.status = getStatus(state);
|
|
1011
|
+
render();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const from = Math.floor((floor - 1) / topic.size) * topic.size;
|
|
1015
|
+
state.loadingMore = true;
|
|
1016
|
+
state.status = `正在读取 ${floor} 楼...`;
|
|
1017
|
+
render();
|
|
1018
|
+
try {
|
|
1019
|
+
const posts = asArray(await client.getTopicPosts(topic.topicId, from, topic.size, false, signal));
|
|
1020
|
+
const next = renderPosts(posts, Math.max(36, currentTopicWidthEstimate()), topic.lines.length);
|
|
1021
|
+
topic.lines.push(...next.lines);
|
|
1022
|
+
topic.posts.push(...next.posts);
|
|
1023
|
+
topic.posts.sort((left, right) => (left.floor ?? 0) - (right.floor ?? 0));
|
|
1024
|
+
topic.imageCount += next.imageCount;
|
|
1025
|
+
topic.linkCount += next.linkCount;
|
|
1026
|
+
topic.loaded = Math.max(topic.loaded, from + posts.length);
|
|
1027
|
+
topic.hasMore = posts.length === topic.size;
|
|
1028
|
+
const target = findTopicPostByFloor(topic, floor);
|
|
1029
|
+
if (target) {
|
|
1030
|
+
state.scroll = target.lineStart;
|
|
1031
|
+
state.status = getStatus(state);
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
state.status = `未找到 ${floor} 楼`;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
if (!isAbortError(error)) {
|
|
1039
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
finally {
|
|
1043
|
+
state.loadingMore = false;
|
|
1044
|
+
render();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
function jumpRelativeTopicFloor(state, delta) {
|
|
1048
|
+
const topic = state.topic;
|
|
1049
|
+
if (!topic || topic.posts.length === 0) {
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const current = currentTopicPost(topic, state.scroll);
|
|
1053
|
+
const currentIndex = current ? topic.posts.indexOf(current) : 0;
|
|
1054
|
+
const next = topic.posts[Math.min(topic.posts.length - 1, Math.max(0, currentIndex + delta))];
|
|
1055
|
+
if (next) {
|
|
1056
|
+
state.scroll = next.lineStart;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function findTopicPostByFloor(topic, floor) {
|
|
1060
|
+
return topic.posts.find((entry) => entry.floor === floor);
|
|
1061
|
+
}
|
|
1062
|
+
function currentTopicPost(topic, scroll) {
|
|
1063
|
+
return topic.posts.find((entry) => scroll >= entry.lineStart && scroll <= entry.lineEnd) ??
|
|
1064
|
+
[...topic.posts].reverse().find((entry) => entry.lineStart <= scroll) ??
|
|
1065
|
+
topic.posts[0];
|
|
1066
|
+
}
|
|
1067
|
+
function currentTopicLine(topic, scroll) {
|
|
1068
|
+
const post = currentTopicPost(topic, scroll);
|
|
1069
|
+
if (!post) {
|
|
1070
|
+
return undefined;
|
|
1071
|
+
}
|
|
1072
|
+
return post.lines.find((entry) => entry.line === scroll) ??
|
|
1073
|
+
post.lines.find((entry) => entry.line > scroll && entry.kind !== "blank") ??
|
|
1074
|
+
post.lines.at(-1);
|
|
1075
|
+
}
|
|
1076
|
+
function lineKindLabel(kind) {
|
|
1077
|
+
switch (kind) {
|
|
1078
|
+
case "header":
|
|
1079
|
+
return "楼层标题";
|
|
1080
|
+
case "divider":
|
|
1081
|
+
return "分隔线";
|
|
1082
|
+
case "quote":
|
|
1083
|
+
return "引用";
|
|
1084
|
+
case "image":
|
|
1085
|
+
return "图片";
|
|
1086
|
+
case "link":
|
|
1087
|
+
return "链接";
|
|
1088
|
+
case "blank":
|
|
1089
|
+
return "空行";
|
|
1090
|
+
case "text":
|
|
1091
|
+
return "正文";
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function parseBracketIndex(value, label) {
|
|
1095
|
+
const match = new RegExp(`\\[${label} (\\d+)`).exec(value);
|
|
1096
|
+
return match ? Number(match[1]) : undefined;
|
|
1097
|
+
}
|
|
1098
|
+
function formatRating(post) {
|
|
1099
|
+
const value = post.rating ?? post.ratingCount ?? post.wealth ?? post.score;
|
|
1100
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1101
|
+
return String(value);
|
|
1102
|
+
}
|
|
1103
|
+
if (typeof value === "string" && value.trim()) {
|
|
1104
|
+
return value.trim();
|
|
1105
|
+
}
|
|
1106
|
+
return undefined;
|
|
1107
|
+
}
|
|
1108
|
+
async function loadView(client, view, force, signal) {
|
|
1109
|
+
switch (view) {
|
|
1110
|
+
case "hot": {
|
|
1111
|
+
const [index, unread] = await Promise.all([
|
|
1112
|
+
client.getForumIndex(force, signal),
|
|
1113
|
+
client.getUnreadCount(force, signal)
|
|
1114
|
+
]);
|
|
1115
|
+
const indexObject = asObject(index);
|
|
1116
|
+
const unreadObject = asObject(unread);
|
|
1117
|
+
const hotTopics = asArray(indexObject.hotTopic ?? indexObject.manualHotTopic);
|
|
1118
|
+
return {
|
|
1119
|
+
title: "十大",
|
|
1120
|
+
items: hotTopics.map((topic) => topicItem(topic)),
|
|
1121
|
+
stats: unreadStats(unreadObject),
|
|
1122
|
+
overview: overviewStats(indexObject, unreadObject)
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
case "new": {
|
|
1126
|
+
const topics = asArray(await client.getNewTopics(0, 12, force, signal));
|
|
1127
|
+
return {
|
|
1128
|
+
title: "最新",
|
|
1129
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
1130
|
+
stats: [{ title: "新帖流", detail: `${topics.length} 条` }]
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
case "boards": {
|
|
1134
|
+
const sections = asArray(await client.getAllBoards(force, signal));
|
|
1135
|
+
const boards = flattenBoards(sections).slice(0, 14);
|
|
1136
|
+
return {
|
|
1137
|
+
title: "版面",
|
|
1138
|
+
items: boards,
|
|
1139
|
+
stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${flattenBoards(sections).length}` }],
|
|
1140
|
+
status: "版面:j/k 选择 l 进入版面 h 返回 r 刷新"
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
case "following": {
|
|
1144
|
+
const topics = asArray(await client.getFolloweeTopics(0, 12, force, signal));
|
|
1145
|
+
return {
|
|
1146
|
+
title: "关注",
|
|
1147
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
1148
|
+
stats: [
|
|
1149
|
+
{ title: "关注动态", detail: `${topics.length} 条` },
|
|
1150
|
+
{ title: "缓存", detail: "30s" }
|
|
1151
|
+
],
|
|
1152
|
+
status: "关注:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
case "favorite": {
|
|
1156
|
+
const [meRaw, sectionsRaw, topicFavorites] = await Promise.all([
|
|
1157
|
+
client.getMe(force, signal),
|
|
1158
|
+
client.getAllBoards(false, signal),
|
|
1159
|
+
client.getFavoriteTopics(0, 6, 1, 0, force, signal)
|
|
1160
|
+
]);
|
|
1161
|
+
const customBoards = asArray(asObject(meRaw).customBoards).filter((id) => typeof id === "number");
|
|
1162
|
+
const allBoards = flattenBoards(asArray(sectionsRaw));
|
|
1163
|
+
const boardById = new Map(allBoards.filter((board) => board.boardId !== undefined).map((board) => [board.boardId, board]));
|
|
1164
|
+
const topicGroups = await mapLimit(customBoards, 3, async (boardId) => {
|
|
1165
|
+
const board = boardById.get(boardId);
|
|
1166
|
+
const topics = asArray(await client.getBoardTopics(boardId, 0, 3, false, force, signal));
|
|
1167
|
+
return topics.map((topic) => topicItem(topic, board));
|
|
1168
|
+
});
|
|
1169
|
+
const boardTopics = topicGroups.flat().sort((left, right) => (right.sortTime ?? 0) - (left.sortTime ?? 0)).slice(0, 12);
|
|
1170
|
+
const items = [
|
|
1171
|
+
{ title: "收藏主题", meta: "topic/me/favorite", detail: "打开收藏夹主题列表", action: "favorite-topics" },
|
|
1172
|
+
{ title: "收藏分组", meta: "me/favorite-topic-group", detail: "查看收藏夹分组", action: "favorite-groups" },
|
|
1173
|
+
...asArray(topicFavorites).slice(0, 6).map((topic) => topicItem(topic)),
|
|
1174
|
+
...boardTopics
|
|
1175
|
+
];
|
|
1176
|
+
return {
|
|
1177
|
+
title: "收藏",
|
|
1178
|
+
items,
|
|
1179
|
+
stats: [
|
|
1180
|
+
{ title: "收藏版面", detail: `${customBoards.length} 个` },
|
|
1181
|
+
{ title: "收藏主题", detail: `${asArray(topicFavorites).length} 条` },
|
|
1182
|
+
{ title: "版面主题", detail: `${boardTopics.length} 条` },
|
|
1183
|
+
{ title: "缓存", detail: "boards 24h / topics 30s" }
|
|
1184
|
+
],
|
|
1185
|
+
status: "收藏:j/k 选择 l 打开 h 返回 r 刷新"
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
case "messages": {
|
|
1189
|
+
const [unread, recent] = await Promise.all([
|
|
1190
|
+
client.getUnreadCount(force, signal),
|
|
1191
|
+
client.getRecentChats(0, 10, force, signal)
|
|
1192
|
+
]);
|
|
1193
|
+
const unreadObject = asObject(unread);
|
|
1194
|
+
const chats = asArray(recent);
|
|
1195
|
+
const userNames = await loadChatUserNames(client, chats, force, signal);
|
|
1196
|
+
return {
|
|
1197
|
+
title: "消息",
|
|
1198
|
+
items: chats.length > 0 ? chats.map((chat) => chatItem(chat, userNames)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
|
|
1199
|
+
stats: unreadStats(unreadObject),
|
|
1200
|
+
status: "消息:j/k 选择 l 打开会话 h 返回 r 刷新"
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
case "notices": {
|
|
1204
|
+
const unread = asObject(await client.getUnreadCount(force, signal));
|
|
1205
|
+
return {
|
|
1206
|
+
title: "通知",
|
|
1207
|
+
items: [
|
|
1208
|
+
{ title: "系统通知", meta: `${unread.systemCount ?? 0} 未读`, detail: "查看系统通知列表", action: "notices:system" },
|
|
1209
|
+
{ title: "@ 通知", meta: `${unread.atCount ?? 0} 未读`, detail: "查看提到我的通知", action: "notices:at" },
|
|
1210
|
+
{ title: "回复通知", meta: `${unread.replyCount ?? 0} 未读`, detail: "查看回复我的通知", action: "notices:reply" }
|
|
1211
|
+
],
|
|
1212
|
+
stats: unreadStats(unread),
|
|
1213
|
+
status: "通知:j/k 选择 l 打开列表 h 返回 r 刷新"
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
case "me": {
|
|
1217
|
+
const [me, cacheStats] = await Promise.all([
|
|
1218
|
+
client.getMe(force, signal),
|
|
1219
|
+
client.getCacheStats()
|
|
1220
|
+
]);
|
|
1221
|
+
const meObject = asObject(me);
|
|
1222
|
+
return {
|
|
1223
|
+
title: "我的",
|
|
1224
|
+
items: [
|
|
1225
|
+
item("昵称", meObject.name),
|
|
1226
|
+
item("用户 ID", meObject.id),
|
|
1227
|
+
item("等级", meObject.levelTitle ?? meObject.groupName),
|
|
1228
|
+
item("发帖数", meObject.postCount),
|
|
1229
|
+
item("财富", meObject.wealth),
|
|
1230
|
+
item("签到", "Enter 执行", "signin"),
|
|
1231
|
+
{ title: "我的最近主题", meta: "me/recent-topic", detail: "查看自己最近发布或回复的主题", action: "recent-topics" },
|
|
1232
|
+
{ title: "浏览历史", meta: "me/browsing-record", detail: "查看最近浏览过的主题", action: "browse-history" },
|
|
1233
|
+
{ title: "我的粉丝", meta: `${meObject.fanCount ?? "-"} 人`, detail: "查看粉丝列表", action: "followers" },
|
|
1234
|
+
{ title: "我的关注", meta: `${meObject.followCount ?? "-"} 人`, detail: "查看关注列表", action: "followees" },
|
|
1235
|
+
item("关注", meObject.followCount),
|
|
1236
|
+
item("粉丝", meObject.fanCount)
|
|
1237
|
+
],
|
|
1238
|
+
stats: [
|
|
1239
|
+
{ title: "登录状态", detail: "已登录" }
|
|
1240
|
+
]
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
case "more": {
|
|
1244
|
+
return {
|
|
1245
|
+
title: "更多",
|
|
1246
|
+
items: [
|
|
1247
|
+
{ title: "随机主题", meta: "topic/random-recent", detail: "随机读取一组最近主题", action: "random-topics" },
|
|
1248
|
+
{ title: "我的最近主题", meta: "me/recent-topic", detail: "查看自己最近发布或回复的主题", action: "recent-topics" },
|
|
1249
|
+
{ title: "浏览历史", meta: "me/browsing-record", detail: "查看最近浏览过的主题", action: "browse-history" },
|
|
1250
|
+
{ title: "收藏主题", meta: "topic/me/favorite", detail: "查看收藏夹主题", action: "favorite-topics" },
|
|
1251
|
+
{ title: "收藏更新", meta: "topic/me/favorite?order=1", detail: "查看收藏主题更新", action: "favorite-updates" },
|
|
1252
|
+
{ title: "收藏分组", meta: "me/favorite-topic-group", detail: "查看收藏夹分组", action: "favorite-groups" },
|
|
1253
|
+
{ title: "粉丝列表", meta: "me/follower", detail: "查看关注我的用户", action: "followers" },
|
|
1254
|
+
{ title: "关注列表", meta: "me/followee", detail: "查看我关注的用户", action: "followees" },
|
|
1255
|
+
{ title: "全站统计", meta: "card.cc98.org/api/collection/stat", detail: "查看论坛全站统计", action: "card-stat" },
|
|
1256
|
+
{ title: "评分原因: 普通", meta: "post/rating-reason?type=0", detail: "查看普通评分原因", action: "rate-reasons:0" },
|
|
1257
|
+
{ title: "评分原因: 管理", meta: "post/rating-reason?type=1", detail: "查看管理评分原因", action: "rate-reasons:1" }
|
|
1258
|
+
],
|
|
1259
|
+
stats: [
|
|
1260
|
+
{ title: "只读入口", detail: "11 个" },
|
|
1261
|
+
{ title: "写入", detail: "不含发帖/回帖" }
|
|
1262
|
+
],
|
|
1263
|
+
status: "更多:j/k 选择 l 打开只读内容 h 返回 r 刷新"
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
case "settings": {
|
|
1267
|
+
const cacheStats = await client.getCacheStats();
|
|
1268
|
+
return {
|
|
1269
|
+
title: "设置",
|
|
1270
|
+
items: settingsItems,
|
|
1271
|
+
stats: [
|
|
1272
|
+
{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` },
|
|
1273
|
+
{ title: "版本", detail: `v${appVersion}` }
|
|
1274
|
+
],
|
|
1275
|
+
status: "设置:j/k 选择 l 执行 h 返回"
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function draw(state, size) {
|
|
1281
|
+
const width = Math.max(60, size.columns);
|
|
1282
|
+
const height = Math.max(20, size.rows);
|
|
1283
|
+
const sidebarWidth = width < 90 ? 14 : 18;
|
|
1284
|
+
const rightWidth = width < 78 ? 0 : Math.min(42, Math.max(34, Math.floor(width * 0.30)));
|
|
1285
|
+
const mainWidth = width - sidebarWidth - rightWidth - (rightWidth > 0 ? 2 : 1);
|
|
1286
|
+
const overviewHeight = height < 24 ? 1 : 2;
|
|
1287
|
+
const bodyHeight = height - 4 - overviewHeight;
|
|
1288
|
+
const lines = [];
|
|
1289
|
+
lines.push(header(width, state));
|
|
1290
|
+
lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1291
|
+
lines.push(...drawOverview(state, width, overviewHeight));
|
|
1292
|
+
const sidebar = drawSidebar(state, sidebarWidth, bodyHeight);
|
|
1293
|
+
const main = drawMain(state, mainWidth, bodyHeight);
|
|
1294
|
+
const right = rightWidth > 0 ? drawRight(state, rightWidth, bodyHeight) : [];
|
|
1295
|
+
for (let row = 0; row < bodyHeight; row += 1) {
|
|
1296
|
+
const parts = [
|
|
1297
|
+
fit(sidebar[row] ?? "", sidebarWidth),
|
|
1298
|
+
`${line}│${ansi.reset}`,
|
|
1299
|
+
fit(main[row] ?? "", mainWidth)
|
|
1300
|
+
];
|
|
1301
|
+
if (rightWidth > 0) {
|
|
1302
|
+
parts.push(`${line}│${ansi.reset}`, fit(right[row] ?? "", rightWidth));
|
|
1303
|
+
}
|
|
1304
|
+
lines.push(parts.join(""));
|
|
1305
|
+
}
|
|
1306
|
+
lines.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1307
|
+
lines.push(drawStatusBar(state, width));
|
|
1308
|
+
// Draw modal overlays
|
|
1309
|
+
if (state.modal === "help") {
|
|
1310
|
+
return drawHelpModal(lines, width, height);
|
|
1311
|
+
}
|
|
1312
|
+
if (state.modal === "menu") {
|
|
1313
|
+
return drawMenuModal(lines, state, width, height);
|
|
1314
|
+
}
|
|
1315
|
+
if (state.modal === "search") {
|
|
1316
|
+
return drawSearchModal(lines, state, width, height);
|
|
1317
|
+
}
|
|
1318
|
+
if (state.modal === "user") {
|
|
1319
|
+
return drawUserDetailModal(lines, state, width, height);
|
|
1320
|
+
}
|
|
1321
|
+
if (state.modal === "info") {
|
|
1322
|
+
return drawInfoModal(lines, state, width, height);
|
|
1323
|
+
}
|
|
1324
|
+
return lines.slice(0, height).join("\n");
|
|
1325
|
+
}
|
|
1326
|
+
function header(width, state) {
|
|
1327
|
+
const account = state.account ? `@${state.account}` : "未登录";
|
|
1328
|
+
const title = ` CC98 ${state.viewTitle} `;
|
|
1329
|
+
const padding = Math.max(1, width - cellWidth(title) - cellWidth(account));
|
|
1330
|
+
return `${cc98BlueBg}${white}${ansi.bold}${fit(`${title}${" ".repeat(padding)}${account}`, width)}${ansi.reset}`;
|
|
1331
|
+
}
|
|
1332
|
+
function drawOverview(state, width, height) {
|
|
1333
|
+
const rows = [];
|
|
1334
|
+
const summary = state.overview.length > 0
|
|
1335
|
+
? state.overview.map((entry) => `${entry.title} ${entry.detail ?? "-"}`).join(" ")
|
|
1336
|
+
: "全站概览会在读取十大时更新";
|
|
1337
|
+
rows.push(fit(`${cc98BlueSoft} ${summary}${ansi.reset}`, width));
|
|
1338
|
+
if (height > 1) {
|
|
1339
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1340
|
+
}
|
|
1341
|
+
return rows.slice(0, height);
|
|
1342
|
+
}
|
|
1343
|
+
function drawSidebar(state, width, height) {
|
|
1344
|
+
const rows = [];
|
|
1345
|
+
for (let index = 0; index < height; index += 1) {
|
|
1346
|
+
const nav = navItems[index];
|
|
1347
|
+
if (!nav) {
|
|
1348
|
+
rows.push(" ".repeat(width));
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
const active = index === state.navIndex;
|
|
1352
|
+
const focused = state.focus === "nav";
|
|
1353
|
+
const label = ` ${nav.label}`;
|
|
1354
|
+
const hint = width > 16 ? ` ${nav.hint}` : "";
|
|
1355
|
+
const text = fit(`${label}${hint}`, width);
|
|
1356
|
+
if (active && focused) {
|
|
1357
|
+
rows.push(`${bg(0, 130, 202)}${white}${text}${ansi.reset}`);
|
|
1358
|
+
}
|
|
1359
|
+
else if (active) {
|
|
1360
|
+
rows.push(`${bg(5, 46, 74)}${cc98BlueSoft}${text}${ansi.reset}`);
|
|
1361
|
+
}
|
|
1362
|
+
else {
|
|
1363
|
+
rows.push(`${cc98Blue}${label}${ansi.reset}${muted}${fit(hint, Math.max(0, width - cellWidth(label)))}${ansi.reset}`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return rows;
|
|
1367
|
+
}
|
|
1368
|
+
function drawMain(state, width, height) {
|
|
1369
|
+
if (state.mode === "topic") {
|
|
1370
|
+
return drawTopic(state, width, height);
|
|
1371
|
+
}
|
|
1372
|
+
if (state.loading) {
|
|
1373
|
+
return [
|
|
1374
|
+
`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
|
|
1375
|
+
fit(`${muted} 正在加载...${ansi.reset}`, width),
|
|
1376
|
+
`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
|
|
1377
|
+
`${muted} ${"· ".repeat(Math.max(1, Math.floor((width - 2) / 2))).slice(0, width - 1)}${ansi.reset}`
|
|
1378
|
+
].concat(blank(height - 4, width)).slice(0, height);
|
|
1379
|
+
}
|
|
1380
|
+
if (state.error) {
|
|
1381
|
+
return [
|
|
1382
|
+
`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`,
|
|
1383
|
+
`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`,
|
|
1384
|
+
`${danger} 请求失败${ansi.reset}`,
|
|
1385
|
+
fit(` ${state.error}`, width)
|
|
1386
|
+
].concat(blank(height - 4, width)).slice(0, height);
|
|
1387
|
+
}
|
|
1388
|
+
const rows = [];
|
|
1389
|
+
rows.push(`${cc98Blue}${ansi.bold} ${state.viewTitle}${ansi.reset}`);
|
|
1390
|
+
rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
|
|
1391
|
+
const visibleCapacity = Math.max(1, Math.floor(Math.max(1, height - 3) / 3));
|
|
1392
|
+
if (state.itemIndex < state.scroll) {
|
|
1393
|
+
state.scroll = state.itemIndex;
|
|
1394
|
+
}
|
|
1395
|
+
else if (state.itemIndex >= state.scroll + visibleCapacity) {
|
|
1396
|
+
state.scroll = state.itemIndex - visibleCapacity + 1;
|
|
1397
|
+
}
|
|
1398
|
+
const visible = state.items.slice(state.scroll);
|
|
1399
|
+
visible.forEach((itemValue, offset) => {
|
|
1400
|
+
if (rows.length >= height) {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const index = state.scroll + offset;
|
|
1404
|
+
const active = index === state.itemIndex && (state.focus === "content" || state.mode === "settings");
|
|
1405
|
+
const prefix = active ? `${ok}●${ansi.reset}` : `${muted}•${ansi.reset}`;
|
|
1406
|
+
const title = fit(` ${itemValue.title}`, Math.max(10, width - 2));
|
|
1407
|
+
rows.push(active ? `${bg(5, 46, 74)}${prefix}${title}${ansi.reset}` : fit(`${prefix}${title}`, width));
|
|
1408
|
+
if (itemValue.meta && rows.length < height) {
|
|
1409
|
+
rows.push(fit(` ${muted}${itemValue.meta}${ansi.reset}`, width));
|
|
1410
|
+
}
|
|
1411
|
+
// Note: detail is shown in right panel, not here
|
|
1412
|
+
});
|
|
1413
|
+
if (visible.length === 0) {
|
|
1414
|
+
rows.push(`${muted} 暂无数据${ansi.reset}`);
|
|
1415
|
+
}
|
|
1416
|
+
if (state.scroll + visibleCapacity < state.items.length && rows.length < height) {
|
|
1417
|
+
rows.push(fit(`${muted} ↓ 还有 ${state.items.length - state.scroll - visibleCapacity} 项${ansi.reset}`, width));
|
|
1418
|
+
}
|
|
1419
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1420
|
+
}
|
|
1421
|
+
function drawTopic(state, width, height) {
|
|
1422
|
+
if (state.loading && (!state.topic || state.topic.lines.length === 0)) {
|
|
1423
|
+
return [
|
|
1424
|
+
`${cc98Blue} 正在打开帖子...${ansi.reset}`,
|
|
1425
|
+
"",
|
|
1426
|
+
`${muted} 只加载第一页,不预取未读楼层。${ansi.reset}`
|
|
1427
|
+
].concat(blank(height - 3, width)).slice(0, height);
|
|
1428
|
+
}
|
|
1429
|
+
if (state.error) {
|
|
1430
|
+
return [
|
|
1431
|
+
`${danger} 读取帖子失败${ansi.reset}`,
|
|
1432
|
+
fit(` ${state.error}`, width),
|
|
1433
|
+
"",
|
|
1434
|
+
`${muted} h/Esc 返回列表${ansi.reset}`
|
|
1435
|
+
].concat(blank(height - 4, width)).slice(0, height);
|
|
1436
|
+
}
|
|
1437
|
+
const topic = state.topic;
|
|
1438
|
+
if (!topic) {
|
|
1439
|
+
return blank(height, width);
|
|
1440
|
+
}
|
|
1441
|
+
const rows = [];
|
|
1442
|
+
rows.push(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`);
|
|
1443
|
+
rows.push(fit(`${muted} ${topic.meta}${ansi.reset}`, width));
|
|
1444
|
+
rows.push(`${line}${"─".repeat(Math.max(0, width - 1))}${ansi.reset}`);
|
|
1445
|
+
const viewport = Math.max(0, height - rows.length - 1);
|
|
1446
|
+
const maxScroll = Math.max(0, topic.lines.length - viewport);
|
|
1447
|
+
state.scroll = Math.min(state.scroll, maxScroll);
|
|
1448
|
+
const body = topic.lines.slice(state.scroll, state.scroll + viewport);
|
|
1449
|
+
for (const bodyLine of body) {
|
|
1450
|
+
if (bodyLine.startsWith("[image ")) {
|
|
1451
|
+
rows.push(fit(`${cc98BlueSoft}${bodyLine}${ansi.reset}`, width));
|
|
1452
|
+
}
|
|
1453
|
+
else if (bodyLine.startsWith("│ ")) {
|
|
1454
|
+
rows.push(fit(`${muted}${bodyLine}${ansi.reset}`, width));
|
|
1455
|
+
}
|
|
1456
|
+
else if (/^#\d+ /.test(bodyLine)) {
|
|
1457
|
+
rows.push(fit(`${ok}${bodyLine}${ansi.reset}`, width));
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
rows.push(fit(` ${bodyLine}`, width));
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const pageInfo = topic.hasMore
|
|
1464
|
+
? `已载入 ${topic.loaded} 楼,n 下一页`
|
|
1465
|
+
: `已载入 ${topic.loaded} 楼,已到底`;
|
|
1466
|
+
rows.push(fit(`${muted}${pageInfo}${state.loadingMore ? " · 加载中" : ""}${ansi.reset}`, width));
|
|
1467
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1468
|
+
}
|
|
1469
|
+
function drawStatusBar(state, width) {
|
|
1470
|
+
if (state.inputMode) {
|
|
1471
|
+
return fit(`${cc98Blue} ${state.inputPrompt}${state.inputValue}${ansi.reset}`, width);
|
|
1472
|
+
}
|
|
1473
|
+
const left = getStatus(state);
|
|
1474
|
+
const right = getKeyHints(state);
|
|
1475
|
+
const padding = Math.max(1, width - cellWidth(left) - cellWidth(right) - 2);
|
|
1476
|
+
return fit(`${muted} ${left}${" ".repeat(padding)}${right} `, width);
|
|
1477
|
+
}
|
|
1478
|
+
function getKeyHints(state) {
|
|
1479
|
+
const hints = [];
|
|
1480
|
+
hints.push("j/k ↑↓ 移动");
|
|
1481
|
+
hints.push("h← 返回");
|
|
1482
|
+
hints.push("l→ 进入");
|
|
1483
|
+
hints.push("Enter 确认");
|
|
1484
|
+
if (state.mode === "topic") {
|
|
1485
|
+
hints.push("s 收藏");
|
|
1486
|
+
hints.push("l 赞");
|
|
1487
|
+
hints.push("d 踩");
|
|
1488
|
+
hints.push("u 用户");
|
|
1489
|
+
hints.push("v 投票");
|
|
1490
|
+
hints.push("a 状态");
|
|
1491
|
+
hints.push("n 下页");
|
|
1492
|
+
hints.push("【/】楼层");
|
|
1493
|
+
hints.push("数字跳楼");
|
|
1494
|
+
}
|
|
1495
|
+
else if (state.currentChat) {
|
|
1496
|
+
hints.push("n 更多");
|
|
1497
|
+
}
|
|
1498
|
+
hints.push("/ 搜索");
|
|
1499
|
+
hints.push("r 刷新");
|
|
1500
|
+
hints.push("o 操作");
|
|
1501
|
+
hints.push("? 帮助");
|
|
1502
|
+
hints.push("q 退出");
|
|
1503
|
+
return hints.join(" ");
|
|
1504
|
+
}
|
|
1505
|
+
function drawHelpModal(baseLines, width, height) {
|
|
1506
|
+
const modalWidth = Math.min(50, width - 4);
|
|
1507
|
+
const modalHeight = Math.min(22, height - 4);
|
|
1508
|
+
const startRow = Math.floor((height - modalHeight) / 2);
|
|
1509
|
+
const startCol = Math.floor((width - modalWidth) / 2);
|
|
1510
|
+
const helpContent = [
|
|
1511
|
+
"",
|
|
1512
|
+
`${cc98Blue}${ansi.bold} 快捷键帮助${ansi.reset}`,
|
|
1513
|
+
"",
|
|
1514
|
+
" 导航",
|
|
1515
|
+
" j/k, ↑/↓ 上下移动",
|
|
1516
|
+
" l, → 进入下一层",
|
|
1517
|
+
" h, ← 返回上一层",
|
|
1518
|
+
" Enter 确认/执行",
|
|
1519
|
+
"",
|
|
1520
|
+
" 全局",
|
|
1521
|
+
" / 搜索",
|
|
1522
|
+
" ? 显示/关闭帮助",
|
|
1523
|
+
" r 刷新当前视图",
|
|
1524
|
+
" n, Space 加载更多",
|
|
1525
|
+
" o 打开操作菜单",
|
|
1526
|
+
" q 退出程序",
|
|
1527
|
+
"",
|
|
1528
|
+
" 帖子详情",
|
|
1529
|
+
" s 收藏/取消收藏",
|
|
1530
|
+
" l 点赞",
|
|
1531
|
+
" d 踩",
|
|
1532
|
+
" u 查看用户",
|
|
1533
|
+
" v 查看投票",
|
|
1534
|
+
" a 查看点赞状态",
|
|
1535
|
+
" 【/】 上/下楼层",
|
|
1536
|
+
" 数字+Enter 跳转楼层",
|
|
1537
|
+
"",
|
|
1538
|
+
" 按任意键关闭"
|
|
1539
|
+
];
|
|
1540
|
+
const result = [...baseLines];
|
|
1541
|
+
for (let i = 0; i < modalHeight && i < helpContent.length; i++) {
|
|
1542
|
+
const row = startRow + i;
|
|
1543
|
+
if (row >= 0 && row < result.length) {
|
|
1544
|
+
const line = helpContent[i] ?? "";
|
|
1545
|
+
const padded = fit(line, modalWidth);
|
|
1546
|
+
const bgStr = i === 0 || i === modalHeight - 1 ? `${line}${"─".repeat(modalWidth)}${ansi.reset}` : `${bg(5, 46, 74)}${padded}${ansi.reset}`;
|
|
1547
|
+
const before = result[row].slice(0, startCol);
|
|
1548
|
+
const after = " ".repeat(Math.max(0, width - startCol - modalWidth));
|
|
1549
|
+
result[row] = `${before}${bgStr}${after}`;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return result.slice(0, height).join("\n");
|
|
1553
|
+
}
|
|
1554
|
+
function drawMenuModal(baseLines, state, width, height) {
|
|
1555
|
+
const modalWidth = Math.min(30, width - 4);
|
|
1556
|
+
const modalHeight = state.menuItems.length + 4;
|
|
1557
|
+
const startRow = Math.floor((height - modalHeight) / 2);
|
|
1558
|
+
const startCol = Math.floor((width - modalWidth) / 2);
|
|
1559
|
+
const result = [...baseLines];
|
|
1560
|
+
// Title
|
|
1561
|
+
const titleRow = startRow;
|
|
1562
|
+
if (titleRow >= 0 && titleRow < result.length) {
|
|
1563
|
+
const title = fit(`${cc98Blue}${ansi.bold} 操作菜单${ansi.reset}`, modalWidth);
|
|
1564
|
+
result[titleRow] = replaceAt(result[titleRow], startCol, `${bg(5, 46, 74)}${title}${ansi.reset}`);
|
|
1565
|
+
}
|
|
1566
|
+
// Separator
|
|
1567
|
+
const sepRow = startRow + 1;
|
|
1568
|
+
if (sepRow >= 0 && sepRow < result.length) {
|
|
1569
|
+
result[sepRow] = replaceAt(result[sepRow], startCol, `${line}${"─".repeat(modalWidth)}${ansi.reset}`);
|
|
1570
|
+
}
|
|
1571
|
+
// Menu items
|
|
1572
|
+
state.menuItems.forEach((item, i) => {
|
|
1573
|
+
const row = startRow + 2 + i;
|
|
1574
|
+
if (row >= 0 && row < result.length) {
|
|
1575
|
+
const active = i === state.menuIndex;
|
|
1576
|
+
const label = ` ${item.label}`;
|
|
1577
|
+
const key = `[${item.key}]`;
|
|
1578
|
+
const padding = Math.max(0, modalWidth - label.length - key.length - 1);
|
|
1579
|
+
const content = `${label}${" ".repeat(padding)}${key}`;
|
|
1580
|
+
const styled = active
|
|
1581
|
+
? `${bg(0, 130, 202)}${white}${fit(content, modalWidth)}${ansi.reset}`
|
|
1582
|
+
: `${bg(5, 46, 74)}${fit(content, modalWidth)}${ansi.reset}`;
|
|
1583
|
+
result[row] = replaceAt(result[row], startCol, styled);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
return result.slice(0, height).join("\n");
|
|
1587
|
+
}
|
|
1588
|
+
function replaceAt(str, index, replacement) {
|
|
1589
|
+
const before = str.slice(0, index);
|
|
1590
|
+
const afterWidth = Math.max(0, cellWidth(str) - index - cellWidth(replacement));
|
|
1591
|
+
const after = " ".repeat(afterWidth);
|
|
1592
|
+
return `${before}${replacement}${after}`;
|
|
1593
|
+
}
|
|
1594
|
+
function drawInfoModal(baseLines, state, width, height) {
|
|
1595
|
+
const modalWidth = Math.min(72, width - 4);
|
|
1596
|
+
const modalHeight = Math.min(Math.max(10, state.infoLines.length + 5), height - 4);
|
|
1597
|
+
const startRow = Math.floor((height - modalHeight) / 2);
|
|
1598
|
+
const startCol = Math.floor((width - modalWidth) / 2);
|
|
1599
|
+
const content = [
|
|
1600
|
+
"",
|
|
1601
|
+
`${cc98Blue}${ansi.bold} ${state.infoTitle ?? "详情"}${ansi.reset}`,
|
|
1602
|
+
"",
|
|
1603
|
+
...state.infoLines.flatMap((value) => wrapText(value, modalWidth - 2).map((row) => ` ${row}`)),
|
|
1604
|
+
"",
|
|
1605
|
+
` ${muted}Esc/Enter 关闭${ansi.reset}`
|
|
1606
|
+
];
|
|
1607
|
+
const result = [...baseLines];
|
|
1608
|
+
for (let i = 0; i < modalHeight && i < content.length; i++) {
|
|
1609
|
+
const row = startRow + i;
|
|
1610
|
+
if (row >= 0 && row < result.length) {
|
|
1611
|
+
const padded = fit(content[i] ?? "", modalWidth);
|
|
1612
|
+
result[row] = replaceAt(result[row], startCol, `${bg(5, 46, 74)}${padded}${ansi.reset}`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return result.slice(0, height).join("\n");
|
|
1616
|
+
}
|
|
1617
|
+
function drawRight(state, width, height) {
|
|
1618
|
+
if (state.mode === "topic" && state.topic) {
|
|
1619
|
+
return drawTopicRight(state.topic, state.scroll, width, height);
|
|
1620
|
+
}
|
|
1621
|
+
if (state.focus === "nav") {
|
|
1622
|
+
return drawNavRight(state, width, height);
|
|
1623
|
+
}
|
|
1624
|
+
return drawItemRight(state, width, height);
|
|
1625
|
+
}
|
|
1626
|
+
function drawNavRight(state, width, height) {
|
|
1627
|
+
const rows = [];
|
|
1628
|
+
const nav = navItems[state.navIndex];
|
|
1629
|
+
rows.push(...mascotMini.map((row) => fit(`${white}${row}${ansi.reset}`, width)));
|
|
1630
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1631
|
+
rows.push(fit(`${cc98Blue}${ansi.bold} ${nav.label}${ansi.reset}`, width));
|
|
1632
|
+
rows.push(fit(`${muted} ${nav.hint}${ansi.reset}`, width));
|
|
1633
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1634
|
+
if (state.loading) {
|
|
1635
|
+
rows.push(fit(`${muted} 正在读取栏目...${ansi.reset}`, width));
|
|
1636
|
+
}
|
|
1637
|
+
else if (state.error) {
|
|
1638
|
+
rows.push(fit(`${danger} 栏目读取失败${ansi.reset}`, width));
|
|
1639
|
+
rows.push(fit(` ${state.error}`, width));
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
rows.push(fit(`${muted} 当前内容${ansi.reset}`, width));
|
|
1643
|
+
rows.push(fit(`${cc98BlueSoft} ${state.items.length} 项${ansi.reset}`, width));
|
|
1644
|
+
if (state.stats.length > 0) {
|
|
1645
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1646
|
+
state.stats.slice(0, 5).forEach((stat) => {
|
|
1647
|
+
rows.push(fit(`${muted} ${stat.title}${ansi.reset}`, width));
|
|
1648
|
+
rows.push(fit(`${cc98BlueSoft} ${stat.detail ?? "-"}${ansi.reset}`, width));
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1653
|
+
rows.push(fit(`${muted} j/k 切换栏目${ansi.reset}`, width));
|
|
1654
|
+
rows.push(fit(`${muted} l/Enter 进入内容${ansi.reset}`, width));
|
|
1655
|
+
rows.push(fit(`${muted} r 刷新当前栏目${ansi.reset}`, width));
|
|
1656
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1657
|
+
}
|
|
1658
|
+
function drawItemRight(state, width, height) {
|
|
1659
|
+
const rows = [];
|
|
1660
|
+
const selected = state.items[state.itemIndex];
|
|
1661
|
+
if (!selected) {
|
|
1662
|
+
rows.push(fit(`${muted} 暂无选中项${ansi.reset}`, width));
|
|
1663
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1664
|
+
}
|
|
1665
|
+
rows.push(fit(`${cc98Blue}${ansi.bold} ${selected.title}${ansi.reset}`, width));
|
|
1666
|
+
if (selected.meta) {
|
|
1667
|
+
wrapText(selected.meta, width - 2).slice(0, 3).forEach((row) => {
|
|
1668
|
+
rows.push(fit(`${muted} ${row}${ansi.reset}`, width));
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1672
|
+
if (selected.detail) {
|
|
1673
|
+
wrapText(selected.detail, width - 2).slice(0, Math.max(0, height - rows.length - 8)).forEach((row) => {
|
|
1674
|
+
rows.push(fit(` ${row}`, width));
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
rows.push(fit(`${muted} 没有摘要内容${ansi.reset}`, width));
|
|
1679
|
+
}
|
|
1680
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1681
|
+
if (selected.topicId !== undefined) {
|
|
1682
|
+
rows.push(fit(`${muted} 主题 #${selected.topicId}${ansi.reset}`, width));
|
|
1683
|
+
if (selected.boardId !== undefined) {
|
|
1684
|
+
rows.push(fit(`${muted} 版面 #${selected.boardId}${ansi.reset}`, width));
|
|
1685
|
+
}
|
|
1686
|
+
rows.push(fit(`${cc98BlueSoft} l 打开阅读${ansi.reset}`, width));
|
|
1687
|
+
}
|
|
1688
|
+
else if (selected.boardId !== undefined) {
|
|
1689
|
+
rows.push(fit(`${muted} 版面 #${selected.boardId}${ansi.reset}`, width));
|
|
1690
|
+
rows.push(fit(`${cc98BlueSoft} l 读取主题${ansi.reset}`, width));
|
|
1691
|
+
}
|
|
1692
|
+
else if (selected.chatUserId !== undefined) {
|
|
1693
|
+
rows.push(fit(`${muted} 用户 #${selected.chatUserId}${ansi.reset}`, width));
|
|
1694
|
+
rows.push(fit(`${cc98BlueSoft} l 打开会话${ansi.reset}`, width));
|
|
1695
|
+
}
|
|
1696
|
+
else if (selected.userId !== undefined) {
|
|
1697
|
+
rows.push(fit(`${muted} 用户 #${selected.userId}${ansi.reset}`, width));
|
|
1698
|
+
rows.push(fit(`${cc98BlueSoft} l 查看用户${ansi.reset}`, width));
|
|
1699
|
+
}
|
|
1700
|
+
else if (selected.action !== undefined || selected.meta === "signin") {
|
|
1701
|
+
rows.push(fit(`${cc98BlueSoft} l/Enter 执行${ansi.reset}`, width));
|
|
1702
|
+
}
|
|
1703
|
+
else if (state.mode === "settings") {
|
|
1704
|
+
rows.push(fit(`${cc98BlueSoft} l/Enter 执行${ansi.reset}`, width));
|
|
1705
|
+
}
|
|
1706
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1707
|
+
}
|
|
1708
|
+
function drawTopicRight(topic, scroll, width, height) {
|
|
1709
|
+
const rows = [];
|
|
1710
|
+
const post = currentTopicPost(topic, scroll);
|
|
1711
|
+
const lineEntry = currentTopicLine(topic, scroll);
|
|
1712
|
+
rows.push(fit(`${cc98Blue}${ansi.bold} ${topic.title}${ansi.reset}`, width));
|
|
1713
|
+
if (topic.meta) {
|
|
1714
|
+
wrapText(topic.meta, width - 2).slice(0, 2).forEach((row) => {
|
|
1715
|
+
rows.push(fit(`${muted} ${row}${ansi.reset}`, width));
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1719
|
+
if (post) {
|
|
1720
|
+
const floor = post.floor !== undefined ? `${post.floor} 楼` : "未知楼层";
|
|
1721
|
+
rows.push(fit(`${cc98BlueSoft} ${floor}${ansi.reset}`, width));
|
|
1722
|
+
rows.push(fit(`${muted} ${post.author}${post.time ? ` · ${post.time}` : ""}${ansi.reset}`, width));
|
|
1723
|
+
rows.push(fit(`${muted} 赞 ${post.likeCount} 踩 ${post.dislikeCount}${post.rating ? ` 评分 ${post.rating}` : ""}${ansi.reset}`, width));
|
|
1724
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1725
|
+
if (lineEntry) {
|
|
1726
|
+
rows.push(fit(`${muted} 当前行 ${lineEntry.row + 1}/${post.lines.length}${ansi.reset}`, width));
|
|
1727
|
+
rows.push(fit(`${cc98BlueSoft} ${lineKindLabel(lineEntry.kind)}${ansi.reset}`, width));
|
|
1728
|
+
if (lineEntry.imageUrl) {
|
|
1729
|
+
rows.push(fit(`${muted} 图片 ${lineEntry.imageIndex}${ansi.reset}`, width));
|
|
1730
|
+
wrapText(lineEntry.imageUrl, width - 2).slice(0, 2).forEach((row) => rows.push(fit(` ${row}`, width)));
|
|
1731
|
+
}
|
|
1732
|
+
else if (lineEntry.linkUrl) {
|
|
1733
|
+
rows.push(fit(`${muted} 链接 ${lineEntry.linkIndex}${ansi.reset}`, width));
|
|
1734
|
+
wrapText(lineEntry.linkUrl, width - 2).slice(0, 2).forEach((row) => rows.push(fit(` ${row}`, width)));
|
|
1735
|
+
}
|
|
1736
|
+
else if (lineEntry.text.trim()) {
|
|
1737
|
+
wrapText(lineEntry.text, width - 2).slice(0, 3).forEach((row) => rows.push(fit(` ${row}`, width)));
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1741
|
+
rows.push(fit(`${muted} 本楼 图片 ${post.imageCount} 链接 ${post.linkCount}${ansi.reset}`, width));
|
|
1742
|
+
}
|
|
1743
|
+
const hot = topic.posts
|
|
1744
|
+
.filter((entry) => entry.likeCount > 0)
|
|
1745
|
+
.sort((left, right) => right.likeCount - left.likeCount)
|
|
1746
|
+
.slice(0, 3);
|
|
1747
|
+
if (hot.length > 0 && rows.length < height - 5) {
|
|
1748
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1749
|
+
rows.push(fit(`${cc98Blue}${ansi.bold} 热门回复${ansi.reset}`, width));
|
|
1750
|
+
hot.forEach((entry) => {
|
|
1751
|
+
rows.push(fit(`${muted} #${entry.floor ?? "?"} ${entry.author} · ${entry.likeCount} 赞${ansi.reset}`, width));
|
|
1752
|
+
if (entry.preview) {
|
|
1753
|
+
rows.push(fit(` ${truncate(entry.preview, width - 2)}`, width));
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
rows.push(`${line}${"─".repeat(width)}${ansi.reset}`);
|
|
1758
|
+
rows.push(fit(`${muted} j/k 行滚动 【/】楼层切换${ansi.reset}`, width));
|
|
1759
|
+
rows.push(fit(`${muted} 数字+Enter 跳楼 n 下一页${ansi.reset}`, width));
|
|
1760
|
+
if (topic.floorInput) {
|
|
1761
|
+
rows.push(fit(`${ok} 跳转:${topic.floorInput} 楼${ansi.reset}`, width));
|
|
1762
|
+
}
|
|
1763
|
+
return rows.concat(blank(height - rows.length, width)).slice(0, height);
|
|
1764
|
+
}
|
|
1765
|
+
function wrapText(text, maxWidth) {
|
|
1766
|
+
const lines = [];
|
|
1767
|
+
let current = "";
|
|
1768
|
+
let currentWidth = 0;
|
|
1769
|
+
for (const char of text) {
|
|
1770
|
+
const charW = charCellWidth(char);
|
|
1771
|
+
if (currentWidth + charW > maxWidth) {
|
|
1772
|
+
lines.push(current);
|
|
1773
|
+
current = char;
|
|
1774
|
+
currentWidth = charW;
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
current += char;
|
|
1778
|
+
currentWidth += charW;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (current) {
|
|
1782
|
+
lines.push(current);
|
|
1783
|
+
}
|
|
1784
|
+
return lines;
|
|
1785
|
+
}
|
|
1786
|
+
function item(title, value, meta) {
|
|
1787
|
+
return {
|
|
1788
|
+
title,
|
|
1789
|
+
meta,
|
|
1790
|
+
detail: value === undefined || value === null ? "-" : String(value)
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
function topicItem(value, fallbackBoard) {
|
|
1794
|
+
const topic = asObject(value);
|
|
1795
|
+
const nestedTopic = asObject(topic.topic ?? topic.Topic);
|
|
1796
|
+
const source = Object.keys(nestedTopic).length > 0 ? nestedTopic : topic;
|
|
1797
|
+
const topicId = asNumber(source.id ?? source.Id ?? topic.topicId ?? topic.TopicId);
|
|
1798
|
+
const boardId = asNumber(source.boardId ?? source.BoardId) ?? fallbackBoard?.boardId;
|
|
1799
|
+
const boardName = topic.boardName ?? topic.BoardName ?? fallbackBoard?.title;
|
|
1800
|
+
return {
|
|
1801
|
+
title: String(source.title ?? source.Title ?? topic.title ?? topic.Title ?? `#${topicId ?? ""}`),
|
|
1802
|
+
meta: [
|
|
1803
|
+
boardName,
|
|
1804
|
+
source.userName ?? source.authorName ?? topic.userName ?? topic.authorName,
|
|
1805
|
+
source.replyCount !== undefined ? `${source.replyCount} 回复` : topic.replyCount !== undefined ? `${topic.replyCount} 回复` : undefined,
|
|
1806
|
+
source.hitCount !== undefined ? `${source.hitCount} 浏览` : topic.hitCount !== undefined ? `${topic.hitCount} 浏览` : undefined
|
|
1807
|
+
]
|
|
1808
|
+
.filter(Boolean)
|
|
1809
|
+
.join(" · "),
|
|
1810
|
+
detail: normalizeInline(String(source.lastPostContent ?? source.content ?? topic.lastPostContent ?? topic.content ?? "")) || undefined,
|
|
1811
|
+
topicId,
|
|
1812
|
+
boardId,
|
|
1813
|
+
sortTime: timestampOf(source.lastPostTime ?? source.updateTime ?? source.time ?? source.createTime ?? topic.lastPostTime ?? topic.updateTime ?? topic.time)
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
function userItem(value) {
|
|
1817
|
+
const user = asObject(value);
|
|
1818
|
+
const userId = asNumber(user.id ?? user.Id ?? user.userId ?? user.UserId);
|
|
1819
|
+
return {
|
|
1820
|
+
title: String(user.name ?? user.Name ?? user.userName ?? user.UserName ?? (userId !== undefined ? `#${userId}` : "用户")),
|
|
1821
|
+
meta: [
|
|
1822
|
+
userId !== undefined ? `#${userId}` : undefined,
|
|
1823
|
+
user.postCount !== undefined ? `${user.postCount} 帖` : undefined,
|
|
1824
|
+
user.levelTitle ?? user.groupName
|
|
1825
|
+
].filter(Boolean).join(" · "),
|
|
1826
|
+
detail: normalizeInline(String(user.introduction ?? user.signature ?? user.Signature ?? "")) || undefined,
|
|
1827
|
+
userId
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
function genericItem(value, fallbackTitle) {
|
|
1831
|
+
const object = asObject(value);
|
|
1832
|
+
const id = asNumber(object.id ?? object.Id ?? object.groupId ?? object.GroupId);
|
|
1833
|
+
const title = String(object.name ?? object.Name ?? object.title ?? object.Title ?? object.reason ?? object.Reason ?? (id !== undefined ? `#${id}` : fallbackTitle));
|
|
1834
|
+
const detail = normalizeInline(String(object.description ?? object.content ?? object.Content ?? object.message ?? object.Message ?? JSON.stringify(value)));
|
|
1835
|
+
return {
|
|
1836
|
+
title,
|
|
1837
|
+
meta: id !== undefined ? `#${id}` : undefined,
|
|
1838
|
+
detail
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
function noticeItem(value) {
|
|
1842
|
+
const notice = asObject(value);
|
|
1843
|
+
const topic = asObject(notice.topic ?? notice.Topic);
|
|
1844
|
+
const topicId = asNumber(notice.topicId ?? notice.TopicId ?? topic.id ?? topic.Id);
|
|
1845
|
+
const time = typeof notice.time === "string"
|
|
1846
|
+
? notice.time.replace("T", " ").slice(0, 16)
|
|
1847
|
+
: typeof notice.createTime === "string"
|
|
1848
|
+
? notice.createTime.replace("T", " ").slice(0, 16)
|
|
1849
|
+
: undefined;
|
|
1850
|
+
return {
|
|
1851
|
+
title: String(notice.title ?? notice.Title ?? notice.type ?? notice.Type ?? topic.title ?? "通知"),
|
|
1852
|
+
meta: [time, topicId !== undefined ? `主题 #${topicId}` : undefined].filter(Boolean).join(" · "),
|
|
1853
|
+
detail: normalizeInline(String(notice.content ?? notice.Content ?? notice.message ?? notice.Message ?? "")) || undefined,
|
|
1854
|
+
topicId
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
function historyItem(value) {
|
|
1858
|
+
const itemValue = topicItem(value);
|
|
1859
|
+
const history = asObject(value);
|
|
1860
|
+
const time = history.time ?? history.Time ?? history.lastViewTime ?? history.LastViewTime ?? history.createTime;
|
|
1861
|
+
return {
|
|
1862
|
+
...itemValue,
|
|
1863
|
+
meta: [itemValue.meta, time !== undefined ? `浏览 ${String(time).replace("T", " ").slice(0, 16)}` : undefined].filter(Boolean).join(" · ")
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
async function loadFriendUsers(client, type, signal) {
|
|
1867
|
+
const raw = asArray(await client.getFriendIds(type, 0, 20, false, signal));
|
|
1868
|
+
if (raw.length === 0) {
|
|
1869
|
+
return [];
|
|
1870
|
+
}
|
|
1871
|
+
if (raw.every((value) => asNumber(value) !== undefined)) {
|
|
1872
|
+
const ids = raw.map((value) => asNumber(value)).filter((id) => id !== undefined);
|
|
1873
|
+
return asArray(await client.getBasicUsers(ids, false, signal)).map((user) => userItem(user));
|
|
1874
|
+
}
|
|
1875
|
+
return raw.map((user) => userItem(user));
|
|
1876
|
+
}
|
|
1877
|
+
async function loadChatUserNames(client, chats, force, signal) {
|
|
1878
|
+
const ids = chats
|
|
1879
|
+
.map((chat) => asNumber(asObject(chat).userId ?? asObject(chat).UserId))
|
|
1880
|
+
.filter((id) => id !== undefined);
|
|
1881
|
+
const users = asArray(await client.getBasicUsers(ids, force, signal));
|
|
1882
|
+
return new Map(users.map((userRaw) => {
|
|
1883
|
+
const user = asObject(userRaw);
|
|
1884
|
+
const id = asNumber(user.id ?? user.Id);
|
|
1885
|
+
const name = String(user.name ?? user.Name ?? (id !== undefined ? `#${id}` : "用户"));
|
|
1886
|
+
return [id, name];
|
|
1887
|
+
}).filter((entry) => entry[0] !== undefined));
|
|
1888
|
+
}
|
|
1889
|
+
function chatItem(value, userNames) {
|
|
1890
|
+
const chat = asObject(value);
|
|
1891
|
+
const userId = asNumber(chat.userId ?? chat.UserId);
|
|
1892
|
+
const name = userId !== undefined ? userNames.get(userId) : undefined;
|
|
1893
|
+
return {
|
|
1894
|
+
title: String(name ?? chat.name ?? chat.userName ?? userId ?? "私信"),
|
|
1895
|
+
meta: userId !== undefined ? `user #${userId}` : undefined,
|
|
1896
|
+
detail: normalizeInline(String(chat.lastContent ?? chat.lastMessage ?? chat.content ?? "")),
|
|
1897
|
+
chatUserId: userId
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
function chatMessageItems(messages, otherName, otherUserId) {
|
|
1901
|
+
return [...messages].reverse().map((messageRaw) => {
|
|
1902
|
+
const message = asObject(messageRaw);
|
|
1903
|
+
const receiverId = asNumber(message.receiverId ?? message.ReceiverId);
|
|
1904
|
+
const isMine = receiverId === otherUserId;
|
|
1905
|
+
const time = typeof message.time === "string"
|
|
1906
|
+
? message.time.replace("T", " ").slice(0, 16)
|
|
1907
|
+
: "";
|
|
1908
|
+
const content = normalizeInline(String(message.content ?? message.Content ?? ""));
|
|
1909
|
+
return {
|
|
1910
|
+
title: isMine ? `我 -> ${otherName}` : `${otherName} -> 我`,
|
|
1911
|
+
meta: [time, receiverId !== undefined ? `receiver #${receiverId}` : undefined].filter(Boolean).join(" · "),
|
|
1912
|
+
detail: content || "(空消息)"
|
|
1913
|
+
};
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
function unreadStats(value) {
|
|
1917
|
+
return [
|
|
1918
|
+
item("系统", value.systemCount),
|
|
1919
|
+
item("@", value.atCount),
|
|
1920
|
+
item("回复", value.replyCount),
|
|
1921
|
+
item("私信", value.messageCount)
|
|
1922
|
+
];
|
|
1923
|
+
}
|
|
1924
|
+
function overviewStats(index, unread) {
|
|
1925
|
+
const unreadTotal = ["systemCount", "atCount", "replyCount", "messageCount"].reduce((total, key) => {
|
|
1926
|
+
const value = unread[key];
|
|
1927
|
+
return total + (typeof value === "number" ? value : 0);
|
|
1928
|
+
}, 0);
|
|
1929
|
+
return [
|
|
1930
|
+
item("今日主题", index.todayTopicCount),
|
|
1931
|
+
item("今日回复", index.todayCount),
|
|
1932
|
+
item("在线", index.onlineUserCount),
|
|
1933
|
+
item("用户", index.userCount),
|
|
1934
|
+
item("未读", unreadTotal)
|
|
1935
|
+
];
|
|
1936
|
+
}
|
|
1937
|
+
async function mapLimit(values, limit, mapper) {
|
|
1938
|
+
const results = [];
|
|
1939
|
+
let nextIndex = 0;
|
|
1940
|
+
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
1941
|
+
while (nextIndex < values.length) {
|
|
1942
|
+
const index = nextIndex;
|
|
1943
|
+
nextIndex += 1;
|
|
1944
|
+
results[index] = await mapper(values[index]);
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
await Promise.all(workers);
|
|
1948
|
+
return results;
|
|
1949
|
+
}
|
|
1950
|
+
function getStatus(state) {
|
|
1951
|
+
// Left part: context status
|
|
1952
|
+
let left = "";
|
|
1953
|
+
if (state.loading) {
|
|
1954
|
+
left = "加载中...";
|
|
1955
|
+
}
|
|
1956
|
+
else if (state.loadingMore) {
|
|
1957
|
+
left = "加载更多...";
|
|
1958
|
+
}
|
|
1959
|
+
else if (state.error) {
|
|
1960
|
+
left = "出错了";
|
|
1961
|
+
}
|
|
1962
|
+
else if (state.mode === "topic") {
|
|
1963
|
+
if (state.topic) {
|
|
1964
|
+
const post = currentTopicPost(state.topic, state.scroll);
|
|
1965
|
+
const line = currentTopicLine(state.topic, state.scroll);
|
|
1966
|
+
left = post
|
|
1967
|
+
? `${post.floor ?? "?"} 楼 · 第 ${line ? line.row + 1 : 1} 行`
|
|
1968
|
+
: `${state.topic.loaded} 楼已加载`;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
else if (state.mode === "settings") {
|
|
1972
|
+
left = "设置";
|
|
1973
|
+
}
|
|
1974
|
+
else {
|
|
1975
|
+
left = `${state.items.length} 项`;
|
|
1976
|
+
}
|
|
1977
|
+
return left;
|
|
1978
|
+
}
|
|
1979
|
+
function flattenBoards(sections) {
|
|
1980
|
+
const boards = [];
|
|
1981
|
+
for (const section of sections) {
|
|
1982
|
+
const sectionObject = asObject(section);
|
|
1983
|
+
const sectionName = String(sectionObject.name ?? sectionObject.title ?? "分区");
|
|
1984
|
+
const candidates = [sectionObject.boards, sectionObject.children, sectionObject.boardList];
|
|
1985
|
+
for (const candidate of candidates) {
|
|
1986
|
+
if (!Array.isArray(candidate)) {
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
for (const board of candidate) {
|
|
1990
|
+
const boardObject = asObject(board);
|
|
1991
|
+
boards.push({
|
|
1992
|
+
title: String(boardObject.name ?? boardObject.title ?? `#${boardObject.id ?? ""}`),
|
|
1993
|
+
meta: `${sectionName}${boardObject.id !== undefined ? ` · #${boardObject.id}` : ""}`,
|
|
1994
|
+
detail: typeof boardObject.description === "string" ? boardObject.description : undefined,
|
|
1995
|
+
boardId: typeof boardObject.id === "number" ? boardObject.id : undefined
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return boards;
|
|
2001
|
+
}
|
|
2002
|
+
function asObject(value) {
|
|
2003
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
2004
|
+
}
|
|
2005
|
+
function asArray(value) {
|
|
2006
|
+
return Array.isArray(value) ? value : [];
|
|
2007
|
+
}
|
|
2008
|
+
function asNumber(value) {
|
|
2009
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
2010
|
+
return value;
|
|
2011
|
+
}
|
|
2012
|
+
if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
2013
|
+
return Number(value);
|
|
2014
|
+
}
|
|
2015
|
+
return undefined;
|
|
2016
|
+
}
|
|
2017
|
+
function normalizeInline(value) {
|
|
2018
|
+
return value.replace(/\s+/g, " ").trim();
|
|
2019
|
+
}
|
|
2020
|
+
function timestampOf(value) {
|
|
2021
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
2022
|
+
return undefined;
|
|
2023
|
+
}
|
|
2024
|
+
const timestamp = new Date(value).getTime();
|
|
2025
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
2026
|
+
}
|
|
2027
|
+
function isAbortError(error) {
|
|
2028
|
+
return error instanceof Error && error.name === "AbortError";
|
|
2029
|
+
}
|
|
2030
|
+
function blank(count, width) {
|
|
2031
|
+
return Array.from({ length: Math.max(0, count) }, () => " ".repeat(width));
|
|
2032
|
+
}
|
|
2033
|
+
function fit(value, width) {
|
|
2034
|
+
const truncated = truncate(value, width);
|
|
2035
|
+
return `${truncated}${" ".repeat(Math.max(0, width - cellWidth(truncated)))}`;
|
|
2036
|
+
}
|
|
2037
|
+
function truncate(value, width) {
|
|
2038
|
+
let out = "";
|
|
2039
|
+
let used = 0;
|
|
2040
|
+
let inEscape = false;
|
|
2041
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2042
|
+
const char = value[index];
|
|
2043
|
+
if (char === "\x1b") {
|
|
2044
|
+
inEscape = true;
|
|
2045
|
+
out += char;
|
|
2046
|
+
continue;
|
|
2047
|
+
}
|
|
2048
|
+
if (inEscape) {
|
|
2049
|
+
out += char;
|
|
2050
|
+
if (/[A-Za-z]/.test(char)) {
|
|
2051
|
+
inEscape = false;
|
|
2052
|
+
}
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
const charWidth = charCellWidth(char);
|
|
2056
|
+
if (used + charWidth > width) {
|
|
2057
|
+
break;
|
|
2058
|
+
}
|
|
2059
|
+
out += char;
|
|
2060
|
+
used += charWidth;
|
|
2061
|
+
}
|
|
2062
|
+
return out;
|
|
2063
|
+
}
|
|
2064
|
+
function cellWidth(value) {
|
|
2065
|
+
let width = 0;
|
|
2066
|
+
for (const char of stripAnsi(value)) {
|
|
2067
|
+
width += charCellWidth(char);
|
|
2068
|
+
}
|
|
2069
|
+
return width;
|
|
2070
|
+
}
|
|
2071
|
+
function charCellWidth(char) {
|
|
2072
|
+
const code = char.codePointAt(0) ?? 0;
|
|
2073
|
+
if (code === 0) {
|
|
2074
|
+
return 0;
|
|
2075
|
+
}
|
|
2076
|
+
if (code >= 0x1100 &&
|
|
2077
|
+
(code <= 0x115f ||
|
|
2078
|
+
code === 0x2329 ||
|
|
2079
|
+
code === 0x232a ||
|
|
2080
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
2081
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
2082
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
2083
|
+
(code >= 0xfe10 && code <= 0xfe19) ||
|
|
2084
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
2085
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
2086
|
+
(code >= 0xffe0 && code <= 0xffe6))) {
|
|
2087
|
+
return 2;
|
|
2088
|
+
}
|
|
2089
|
+
return 1;
|
|
2090
|
+
}
|
|
2091
|
+
// 新增功能函数
|
|
2092
|
+
async function activateContentItem(client, state, selected, render, signal) {
|
|
2093
|
+
if (selected.topicId !== undefined) {
|
|
2094
|
+
await openTopic(client, state, selected.topicId, render, false, signal);
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
if (selected.boardId !== undefined) {
|
|
2098
|
+
await openBoard(client, state, selected.boardId, selected.title, render, false, signal);
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
if (selected.chatUserId !== undefined) {
|
|
2102
|
+
await openChat(client, state, selected.chatUserId, selected.title, render, false, signal);
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (selected.userId !== undefined) {
|
|
2106
|
+
await showUserDetailById(client, state, selected.userId, render, signal);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (selected.meta === "signin") {
|
|
2110
|
+
await signin(client, state, render);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (selected.action) {
|
|
2114
|
+
await runReadOnlyAction(client, state, selected.action, render, signal);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
state.status = "当前条目不可进入";
|
|
2118
|
+
render();
|
|
2119
|
+
}
|
|
2120
|
+
async function runReadOnlyAction(client, state, action, render, signal) {
|
|
2121
|
+
if (action.startsWith("notices:")) {
|
|
2122
|
+
await openNoticeList(client, state, action.slice("notices:".length), render, signal);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
if (action === "favorite-topics") {
|
|
2126
|
+
await openReadOnlyList(client, state, "收藏主题", "正在读取收藏主题...", render, signal, async () => {
|
|
2127
|
+
const topics = asArray(await client.getFavoriteTopics(0, 20, 1, 0, false, signal));
|
|
2128
|
+
return {
|
|
2129
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
2130
|
+
stats: [{ title: "收藏主题", detail: `${topics.length} 条` }],
|
|
2131
|
+
status: "收藏主题:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
2132
|
+
};
|
|
2133
|
+
});
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (action === "favorite-updates") {
|
|
2137
|
+
await openReadOnlyList(client, state, "收藏更新", "正在读取收藏更新...", render, signal, async () => {
|
|
2138
|
+
const topics = asArray(await client.getFavoriteUpdates(0, 20, false, signal));
|
|
2139
|
+
return {
|
|
2140
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
2141
|
+
stats: [{ title: "收藏更新", detail: `${topics.length} 条` }],
|
|
2142
|
+
status: "收藏更新:j/k 选择 l 打开帖子 h 返回"
|
|
2143
|
+
};
|
|
2144
|
+
});
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
if (action === "favorite-groups") {
|
|
2148
|
+
await openReadOnlyList(client, state, "收藏分组", "正在读取收藏分组...", render, signal, async () => {
|
|
2149
|
+
const groups = asArray(await client.getFavoriteGroups(false, signal));
|
|
2150
|
+
return {
|
|
2151
|
+
items: groups.map((group) => genericItem(group, "收藏分组")),
|
|
2152
|
+
stats: [{ title: "分组", detail: `${groups.length} 个` }],
|
|
2153
|
+
status: "收藏分组:j/k 查看 h 返回 r 刷新"
|
|
2154
|
+
};
|
|
2155
|
+
});
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (action === "followers" || action === "followees") {
|
|
2159
|
+
const type = action === "followers" ? "follower" : "followee";
|
|
2160
|
+
const title = action === "followers" ? "粉丝列表" : "关注列表";
|
|
2161
|
+
await openReadOnlyList(client, state, title, `正在读取${title}...`, render, signal, async () => {
|
|
2162
|
+
const users = await loadFriendUsers(client, type, signal);
|
|
2163
|
+
return {
|
|
2164
|
+
items: users,
|
|
2165
|
+
stats: [{ title, detail: `${users.length} 人` }],
|
|
2166
|
+
status: `${title}:j/k 选择 l 查看用户 h 返回 r 刷新`
|
|
2167
|
+
};
|
|
2168
|
+
});
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (action === "browse-history") {
|
|
2172
|
+
await openReadOnlyList(client, state, "浏览历史", "正在读取浏览历史...", render, signal, async () => {
|
|
2173
|
+
const records = asArray(await client.getBrowseHistory(0, 20, false, signal));
|
|
2174
|
+
return {
|
|
2175
|
+
items: records.map((record) => historyItem(record)),
|
|
2176
|
+
stats: [{ title: "浏览历史", detail: `${records.length} 条` }],
|
|
2177
|
+
status: "浏览历史:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
2178
|
+
};
|
|
2179
|
+
});
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
if (action === "recent-topics") {
|
|
2183
|
+
await openReadOnlyList(client, state, "我的最近主题", "正在读取最近主题...", render, signal, async () => {
|
|
2184
|
+
const topics = asArray(await client.getRecentTopics(undefined, 0, 20, false, signal));
|
|
2185
|
+
return {
|
|
2186
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
2187
|
+
stats: [{ title: "最近主题", detail: `${topics.length} 条` }],
|
|
2188
|
+
status: "最近主题:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
2189
|
+
};
|
|
2190
|
+
});
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (action === "random-topics") {
|
|
2194
|
+
await openReadOnlyList(client, state, "随机主题", "正在读取随机主题...", render, signal, async () => {
|
|
2195
|
+
const topics = asArray(await client.getRandomTopics(20, false, signal));
|
|
2196
|
+
return {
|
|
2197
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
2198
|
+
stats: [{ title: "随机主题", detail: `${topics.length} 条` }],
|
|
2199
|
+
status: "随机主题:j/k 选择 l 打开帖子 h 返回 r 刷新"
|
|
2200
|
+
};
|
|
2201
|
+
});
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
if (action === "card-stat") {
|
|
2205
|
+
state.status = "正在读取全站统计...";
|
|
2206
|
+
render();
|
|
2207
|
+
try {
|
|
2208
|
+
const stat = await client.getCardStat(false, signal);
|
|
2209
|
+
state.infoTitle = "全站统计";
|
|
2210
|
+
state.infoLines = jsonPreviewLines(stat);
|
|
2211
|
+
state.modal = "info";
|
|
2212
|
+
}
|
|
2213
|
+
catch (error) {
|
|
2214
|
+
state.status = `全站统计读取失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2215
|
+
}
|
|
2216
|
+
render();
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (action.startsWith("board-best:")) {
|
|
2220
|
+
const boardId = asNumber(action.slice("board-best:".length));
|
|
2221
|
+
if (boardId === undefined)
|
|
2222
|
+
return;
|
|
2223
|
+
await openReadOnlyList(client, state, "精华帖", "正在读取精华帖...", render, signal, async () => {
|
|
2224
|
+
const topics = asArray(await client.getBoardTopics(boardId, 0, 20, true, false, signal));
|
|
2225
|
+
return {
|
|
2226
|
+
items: topics.map((topic) => topicItem(topic)),
|
|
2227
|
+
stats: [{ title: "精华帖", detail: `${topics.length} 条` }],
|
|
2228
|
+
status: "精华帖:j/k 选择 l 打开帖子 h 返回"
|
|
2229
|
+
};
|
|
2230
|
+
});
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (action.startsWith("rate-reasons:")) {
|
|
2234
|
+
const type = Number(action.slice("rate-reasons:".length));
|
|
2235
|
+
await openReadOnlyList(client, state, `评分原因 ${type}`, "正在读取评分原因...", render, signal, async () => {
|
|
2236
|
+
const reasons = asArray(await client.getPostRateReasons(type, false, signal));
|
|
2237
|
+
return {
|
|
2238
|
+
items: reasons.map((reason) => genericItem(reason, "评分原因")),
|
|
2239
|
+
stats: [{ title: "评分原因", detail: `${reasons.length} 条` }],
|
|
2240
|
+
status: "评分原因:j/k 查看 h 返回"
|
|
2241
|
+
};
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
async function openReadOnlyList(client, state, title, loadingStatus, render, signal, loadItems) {
|
|
2246
|
+
state.parentList = {
|
|
2247
|
+
title: state.viewTitle,
|
|
2248
|
+
items: state.items,
|
|
2249
|
+
stats: state.stats,
|
|
2250
|
+
itemIndex: state.itemIndex,
|
|
2251
|
+
status: state.status
|
|
2252
|
+
};
|
|
2253
|
+
state.mode = "list";
|
|
2254
|
+
state.focus = "content";
|
|
2255
|
+
state.loading = true;
|
|
2256
|
+
state.loadingMore = false;
|
|
2257
|
+
state.error = undefined;
|
|
2258
|
+
state.topic = undefined;
|
|
2259
|
+
state.currentBoard = undefined;
|
|
2260
|
+
state.currentChat = undefined;
|
|
2261
|
+
state.viewTitle = title;
|
|
2262
|
+
state.items = [];
|
|
2263
|
+
state.stats = [];
|
|
2264
|
+
state.itemIndex = 0;
|
|
2265
|
+
state.scroll = 0;
|
|
2266
|
+
state.status = loadingStatus;
|
|
2267
|
+
render();
|
|
2268
|
+
try {
|
|
2269
|
+
const loaded = await loadItems();
|
|
2270
|
+
state.items = loaded.items;
|
|
2271
|
+
state.stats = loaded.stats;
|
|
2272
|
+
state.status = loaded.status;
|
|
2273
|
+
}
|
|
2274
|
+
catch (error) {
|
|
2275
|
+
if (!isAbortError(error)) {
|
|
2276
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
2277
|
+
state.status = "读取失败;h 返回 r 重试";
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
finally {
|
|
2281
|
+
state.loading = false;
|
|
2282
|
+
render();
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
async function openNoticeList(client, state, type, render, signal) {
|
|
2286
|
+
const title = type === "system" ? "系统通知" : type === "at" ? "@ 通知" : "回复通知";
|
|
2287
|
+
await openReadOnlyList(client, state, title, `正在读取${title}...`, render, signal, async () => {
|
|
2288
|
+
const notices = asArray(await client.getNotices(type, 0, 20, false, signal));
|
|
2289
|
+
return {
|
|
2290
|
+
items: notices.map((notice) => noticeItem(notice)),
|
|
2291
|
+
stats: [{ title, detail: `${notices.length} 条` }],
|
|
2292
|
+
status: `${title}:j/k 选择 l 打开关联帖子 h 返回 r 刷新`
|
|
2293
|
+
};
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
async function performSearch(client, state, render, signal) {
|
|
2297
|
+
const query = state.searchQuery.trim();
|
|
2298
|
+
if (!query)
|
|
2299
|
+
return;
|
|
2300
|
+
state.loading = true;
|
|
2301
|
+
state.searchResults = [];
|
|
2302
|
+
render();
|
|
2303
|
+
try {
|
|
2304
|
+
if (state.searchMode === "topics") {
|
|
2305
|
+
const results = asArray(await client.searchTopics(query, 0, 20, false, signal));
|
|
2306
|
+
state.searchResults = results.map((topic) => topicItem(topic));
|
|
2307
|
+
}
|
|
2308
|
+
else {
|
|
2309
|
+
const results = asArray(await client.searchUsers(query, false, signal));
|
|
2310
|
+
state.searchResults = results.map((user) => userItem(user));
|
|
2311
|
+
}
|
|
2312
|
+
state.itemIndex = 0;
|
|
2313
|
+
state.status = `找到 ${state.searchResults.length} 个结果`;
|
|
2314
|
+
}
|
|
2315
|
+
catch (error) {
|
|
2316
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
2317
|
+
}
|
|
2318
|
+
finally {
|
|
2319
|
+
state.loading = false;
|
|
2320
|
+
render();
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
async function toggleFavorite(client, state, render) {
|
|
2324
|
+
if (!state.topic)
|
|
2325
|
+
return;
|
|
2326
|
+
try {
|
|
2327
|
+
const isFav = await client.isTopicFavorite(state.topic.topicId, false);
|
|
2328
|
+
if (isFav) {
|
|
2329
|
+
await client.removeFavorite(state.topic.topicId);
|
|
2330
|
+
state.status = "已取消收藏";
|
|
2331
|
+
}
|
|
2332
|
+
else {
|
|
2333
|
+
await client.addFavorite(state.topic.topicId);
|
|
2334
|
+
state.status = "已收藏";
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
catch (error) {
|
|
2338
|
+
state.status = `收藏失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2339
|
+
}
|
|
2340
|
+
render();
|
|
2341
|
+
}
|
|
2342
|
+
async function reactToCurrentPost(client, state, isLike, render) {
|
|
2343
|
+
if (!state.topic || state.topic.posts.length === 0)
|
|
2344
|
+
return;
|
|
2345
|
+
const currentPost = currentTopicPost(state.topic, state.scroll);
|
|
2346
|
+
if (!currentPost?.id) {
|
|
2347
|
+
state.status = "无法找到当前帖子";
|
|
2348
|
+
render();
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
try {
|
|
2352
|
+
await client.reactToPost(currentPost.id, isLike);
|
|
2353
|
+
state.status = isLike ? "已点赞" : "已踩";
|
|
2354
|
+
}
|
|
2355
|
+
catch (error) {
|
|
2356
|
+
state.status = `操作失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2357
|
+
}
|
|
2358
|
+
render();
|
|
2359
|
+
}
|
|
2360
|
+
async function showUserDetail(client, state, render, signal) {
|
|
2361
|
+
if (!state.topic || state.topic.posts.length === 0)
|
|
2362
|
+
return;
|
|
2363
|
+
const currentPost = currentTopicPost(state.topic, state.scroll);
|
|
2364
|
+
if (!currentPost)
|
|
2365
|
+
return;
|
|
2366
|
+
const userId = currentPost.userId;
|
|
2367
|
+
if (!userId) {
|
|
2368
|
+
state.status = "无法获取用户信息";
|
|
2369
|
+
render();
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
await showUserDetailById(client, state, userId, render, signal);
|
|
2373
|
+
}
|
|
2374
|
+
async function showUserDetailById(client, state, userId, render, signal) {
|
|
2375
|
+
if (!userId) {
|
|
2376
|
+
state.status = "无法获取用户信息";
|
|
2377
|
+
render();
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
state.loading = true;
|
|
2381
|
+
render();
|
|
2382
|
+
try {
|
|
2383
|
+
const profile = asObject(await client.getUserProfile(userId, false, signal));
|
|
2384
|
+
state.userDetail = {
|
|
2385
|
+
userId,
|
|
2386
|
+
name: String(profile.name ?? "未知用户"),
|
|
2387
|
+
level: String(profile.levelTitle ?? profile.groupName ?? ""),
|
|
2388
|
+
postCount: asNumber(profile.postCount),
|
|
2389
|
+
fanCount: asNumber(profile.fanCount),
|
|
2390
|
+
followCount: asNumber(profile.followCount),
|
|
2391
|
+
isFollowing: Boolean(profile.isFollowing)
|
|
2392
|
+
};
|
|
2393
|
+
// 获取最近帖子
|
|
2394
|
+
const topics = asArray(await client.getRecentTopics(userId, 0, 5, false, signal));
|
|
2395
|
+
state.userDetail.recentTopics = topics.map((t) => topicItem(t));
|
|
2396
|
+
state.modal = "user";
|
|
2397
|
+
state.status = "用户详情";
|
|
2398
|
+
}
|
|
2399
|
+
catch (error) {
|
|
2400
|
+
state.status = `获取用户信息失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2401
|
+
}
|
|
2402
|
+
finally {
|
|
2403
|
+
state.loading = false;
|
|
2404
|
+
render();
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
async function showTopicVote(client, state, render, signal) {
|
|
2408
|
+
if (!state.topic)
|
|
2409
|
+
return;
|
|
2410
|
+
state.status = "正在读取投票信息...";
|
|
2411
|
+
render();
|
|
2412
|
+
try {
|
|
2413
|
+
const vote = await client.getTopicVote(state.topic.topicId, false, signal);
|
|
2414
|
+
state.infoTitle = "投票信息";
|
|
2415
|
+
state.infoLines = jsonPreviewLines(vote);
|
|
2416
|
+
state.modal = "info";
|
|
2417
|
+
}
|
|
2418
|
+
catch (error) {
|
|
2419
|
+
state.status = `投票读取失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2420
|
+
}
|
|
2421
|
+
render();
|
|
2422
|
+
}
|
|
2423
|
+
async function showPostReactionState(client, state, render, signal) {
|
|
2424
|
+
if (!state.topic)
|
|
2425
|
+
return;
|
|
2426
|
+
const currentPost = currentTopicPost(state.topic, state.scroll);
|
|
2427
|
+
if (!currentPost?.id) {
|
|
2428
|
+
state.status = "无法找到当前帖子";
|
|
2429
|
+
render();
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
state.status = "正在读取点赞状态...";
|
|
2433
|
+
render();
|
|
2434
|
+
try {
|
|
2435
|
+
const reaction = await client.getPostReactionState(currentPost.id, false, signal);
|
|
2436
|
+
state.infoTitle = `#${currentPost.floor ?? "?"} 点赞状态`;
|
|
2437
|
+
state.infoLines = jsonPreviewLines(reaction);
|
|
2438
|
+
state.modal = "info";
|
|
2439
|
+
}
|
|
2440
|
+
catch (error) {
|
|
2441
|
+
state.status = `点赞状态读取失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2442
|
+
}
|
|
2443
|
+
render();
|
|
2444
|
+
}
|
|
2445
|
+
function jsonPreviewLines(value) {
|
|
2446
|
+
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
2447
|
+
return text.split("\n").slice(0, 18);
|
|
2448
|
+
}
|
|
2449
|
+
async function toggleFollow(client, state, render) {
|
|
2450
|
+
if (!state.userDetail)
|
|
2451
|
+
return;
|
|
2452
|
+
try {
|
|
2453
|
+
if (state.userDetail.isFollowing) {
|
|
2454
|
+
await client.unfollowUser(state.userDetail.userId);
|
|
2455
|
+
state.userDetail.isFollowing = false;
|
|
2456
|
+
state.status = "已取消关注";
|
|
2457
|
+
}
|
|
2458
|
+
else {
|
|
2459
|
+
await client.followUser(state.userDetail.userId);
|
|
2460
|
+
state.userDetail.isFollowing = true;
|
|
2461
|
+
state.status = "已关注";
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
catch (error) {
|
|
2465
|
+
state.status = `关注失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2466
|
+
}
|
|
2467
|
+
render();
|
|
2468
|
+
}
|
|
2469
|
+
async function sendPrivateMessage(client, state, userId, content, render) {
|
|
2470
|
+
try {
|
|
2471
|
+
await client.sendMessage(userId, content);
|
|
2472
|
+
state.status = "消息已发送";
|
|
2473
|
+
}
|
|
2474
|
+
catch (error) {
|
|
2475
|
+
state.status = `发送失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2476
|
+
}
|
|
2477
|
+
render();
|
|
2478
|
+
}
|
|
2479
|
+
async function signin(client, state, render) {
|
|
2480
|
+
try {
|
|
2481
|
+
const result = await client.signin();
|
|
2482
|
+
const wealth = typeof result === "number" ? result : 0;
|
|
2483
|
+
state.status = wealth > 0 ? `签到成功,获得 ${wealth} 米` : "签到成功";
|
|
2484
|
+
}
|
|
2485
|
+
catch (error) {
|
|
2486
|
+
state.status = `签到失败: ${error instanceof Error ? error.message : String(error)}`;
|
|
2487
|
+
}
|
|
2488
|
+
render();
|
|
2489
|
+
}
|
|
2490
|
+
// 搜索模态框
|
|
2491
|
+
function drawSearchModal(baseLines, state, width, height) {
|
|
2492
|
+
const modalWidth = Math.min(60, width - 4);
|
|
2493
|
+
const modalHeight = Math.min(20, height - 4);
|
|
2494
|
+
const startRow = Math.floor((height - modalHeight) / 2);
|
|
2495
|
+
const startCol = Math.floor((width - modalWidth) / 2);
|
|
2496
|
+
const resultCount = state.searchResults.length;
|
|
2497
|
+
const resultLabel = resultCount > 0 ? ` (${resultCount} 结果)` : "";
|
|
2498
|
+
const content = [
|
|
2499
|
+
"",
|
|
2500
|
+
`${cc98Blue}${ansi.bold} 搜索${ansi.reset}`,
|
|
2501
|
+
"",
|
|
2502
|
+
` 模式: ${state.searchMode === "topics" ? "● 帖子 ○ 用户" : "○ 帖子 ● 用户"} Tab 切换`,
|
|
2503
|
+
"",
|
|
2504
|
+
` ${cc98Blue}▸${ansi.reset} ${state.searchQuery}${state.loading ? " ..." : "_"}${resultLabel}`,
|
|
2505
|
+
"",
|
|
2506
|
+
];
|
|
2507
|
+
const maxResults = modalHeight - 10;
|
|
2508
|
+
for (let i = 0; i < Math.min(state.searchResults.length, maxResults); i++) {
|
|
2509
|
+
const item = state.searchResults[i];
|
|
2510
|
+
const marker = i === state.itemIndex ? ">" : " ";
|
|
2511
|
+
content.push(` ${marker} ${i + 1}. ${item.title}`);
|
|
2512
|
+
if (item.meta) {
|
|
2513
|
+
content.push(` ${muted}${item.meta}${ansi.reset}`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
if (state.searchResults.length === 0 && state.searchQuery && !state.loading) {
|
|
2517
|
+
content.push(` ${muted}无结果${ansi.reset}`);
|
|
2518
|
+
}
|
|
2519
|
+
content.push("");
|
|
2520
|
+
content.push(` ${muted}Enter 搜索/打开 j/k 选择 Esc 关闭 Tab 切换${ansi.reset}`);
|
|
2521
|
+
const result = [...baseLines];
|
|
2522
|
+
for (let i = 0; i < modalHeight && i < content.length; i++) {
|
|
2523
|
+
const row = startRow + i;
|
|
2524
|
+
if (row >= 0 && row < result.length) {
|
|
2525
|
+
const line = content[i] ?? "";
|
|
2526
|
+
const padded = fit(line, modalWidth);
|
|
2527
|
+
const bgStr = i === 0 || i === modalHeight - 1
|
|
2528
|
+
? `${line}${"─".repeat(modalWidth)}${ansi.reset}`
|
|
2529
|
+
: `${bg(5, 46, 74)}${padded}${ansi.reset}`;
|
|
2530
|
+
const before = result[row].slice(0, startCol);
|
|
2531
|
+
const after = " ".repeat(Math.max(0, width - startCol - modalWidth));
|
|
2532
|
+
result[row] = `${before}${bgStr}${after}`;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
return result.slice(0, height).join("\n");
|
|
2536
|
+
}
|
|
2537
|
+
// 用户详情模态框
|
|
2538
|
+
function drawUserDetailModal(baseLines, state, width, height) {
|
|
2539
|
+
const modalWidth = Math.min(50, width - 4);
|
|
2540
|
+
const modalHeight = Math.min(18, height - 4);
|
|
2541
|
+
const startRow = Math.floor((height - modalHeight) / 2);
|
|
2542
|
+
const startCol = Math.floor((width - modalWidth) / 2);
|
|
2543
|
+
const user = state.userDetail;
|
|
2544
|
+
if (!user) {
|
|
2545
|
+
return baseLines.slice(0, height).join("\n");
|
|
2546
|
+
}
|
|
2547
|
+
const followLabel = user.isFollowing ? "已关注" : "未关注";
|
|
2548
|
+
const content = [
|
|
2549
|
+
"",
|
|
2550
|
+
`${cc98Blue}${ansi.bold} 用户详情${ansi.reset}`,
|
|
2551
|
+
"",
|
|
2552
|
+
` 昵称: ${user.name}`,
|
|
2553
|
+
` ID: #${user.userId}`,
|
|
2554
|
+
];
|
|
2555
|
+
if (user.level)
|
|
2556
|
+
content.push(` 等级: ${user.level}`);
|
|
2557
|
+
if (user.postCount !== undefined)
|
|
2558
|
+
content.push(` 帖子: ${user.postCount}`);
|
|
2559
|
+
if (user.fanCount !== undefined)
|
|
2560
|
+
content.push(` 粉丝: ${user.fanCount} 关注: ${user.followCount ?? 0}`);
|
|
2561
|
+
content.push(` 状态: ${followLabel}`);
|
|
2562
|
+
content.push("");
|
|
2563
|
+
content.push(` ${cc98Blue}▸${ansi.reset} f 关注/取消关注 m 发私信`);
|
|
2564
|
+
content.push("");
|
|
2565
|
+
if (user.recentTopics && user.recentTopics.length > 0) {
|
|
2566
|
+
content.push(` ${cc98Blue}最近帖子${ansi.reset}`);
|
|
2567
|
+
for (const topic of user.recentTopics.slice(0, 3)) {
|
|
2568
|
+
content.push(` • ${topic.title}`);
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
content.push("");
|
|
2572
|
+
content.push(` ${muted}Esc 关闭${ansi.reset}`);
|
|
2573
|
+
const result = [...baseLines];
|
|
2574
|
+
for (let i = 0; i < modalHeight && i < content.length; i++) {
|
|
2575
|
+
const row = startRow + i;
|
|
2576
|
+
if (row >= 0 && row < result.length) {
|
|
2577
|
+
const line = content[i] ?? "";
|
|
2578
|
+
const padded = fit(line, modalWidth);
|
|
2579
|
+
const bgStr = i === 0 || i === modalHeight - 1
|
|
2580
|
+
? `${line}${"─".repeat(modalWidth)}${ansi.reset}`
|
|
2581
|
+
: `${bg(5, 46, 74)}${padded}${ansi.reset}`;
|
|
2582
|
+
const before = result[row].slice(0, startCol);
|
|
2583
|
+
const after = " ".repeat(Math.max(0, width - startCol - modalWidth));
|
|
2584
|
+
result[row] = `${before}${bgStr}${after}`;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return result.slice(0, height).join("\n");
|
|
2588
|
+
}
|
|
2589
|
+
//# sourceMappingURL=app-new.js.map
|