cc98-cli 0.3.0 → 0.4.2
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 +28 -1
- package/dist/api/client.d.ts +14 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +83 -8
- package/dist/api/client.js.map +1 -1
- package/dist/api/types.d.ts +13 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/webvpn.d.ts +68 -0
- package/dist/api/webvpn.d.ts.map +1 -0
- package/dist/api/webvpn.js +311 -0
- package/dist/api/webvpn.js.map +1 -0
- package/dist/cli/commands/board.js +1 -1
- package/dist/cli/commands/board.js.map +1 -1
- package/dist/cli/commands/forum.js +1 -1
- package/dist/cli/commands/forum.js.map +1 -1
- package/dist/cli/commands/login.js +1 -1
- package/dist/cli/commands/login.js.map +1 -1
- package/dist/cli/commands/logout.js +2 -2
- package/dist/cli/commands/logout.js.map +1 -1
- package/dist/cli/commands/me.js +1 -1
- package/dist/cli/commands/me.js.map +1 -1
- package/dist/cli/commands/message.js +1 -1
- package/dist/cli/commands/message.js.map +1 -1
- package/dist/cli/commands/notice.js +1 -1
- package/dist/cli/commands/notice.js.map +1 -1
- package/dist/cli/commands/post.js +1 -1
- package/dist/cli/commands/post.js.map +1 -1
- package/dist/cli/commands/search.js +1 -1
- package/dist/cli/commands/search.js.map +1 -1
- package/dist/cli/commands/topic.js +1 -1
- package/dist/cli/commands/topic.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +49 -3
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/commands/user.js +1 -1
- package/dist/cli/commands/user.js.map +1 -1
- package/dist/cli/commands/vpn.d.ts +2 -0
- package/dist/cli/commands/vpn.d.ts.map +1 -0
- package/dist/cli/commands/vpn.js +200 -0
- package/dist/cli/commands/vpn.js.map +1 -0
- package/dist/cli/context.d.ts +2 -2
- package/dist/cli/context.d.ts.map +1 -1
- package/dist/cli/context.js +20 -2
- package/dist/cli/context.js.map +1 -1
- package/dist/cli/router.d.ts.map +1 -1
- package/dist/cli/router.js +5 -1
- package/dist/cli/router.js.map +1 -1
- package/dist/main.js +0 -0
- package/dist/storage/image-cache.d.ts +32 -0
- package/dist/storage/image-cache.d.ts.map +1 -0
- package/dist/storage/image-cache.js +90 -0
- package/dist/storage/image-cache.js.map +1 -0
- package/dist/storage/settings-store.d.ts +10 -0
- package/dist/storage/settings-store.d.ts.map +1 -0
- package/dist/storage/settings-store.js +37 -0
- package/dist/storage/settings-store.js.map +1 -0
- package/dist/storage/vpn-store.d.ts +39 -0
- package/dist/storage/vpn-store.d.ts.map +1 -0
- package/dist/storage/vpn-store.js +94 -0
- package/dist/storage/vpn-store.js.map +1 -0
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +13 -1
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/components/content.d.ts +1 -0
- package/dist/tui/components/content.d.ts.map +1 -1
- package/dist/tui/components/content.js +26 -8
- package/dist/tui/components/content.js.map +1 -1
- package/dist/tui/components/overview.d.ts.map +1 -1
- package/dist/tui/components/overview.js +18 -8
- package/dist/tui/components/overview.js.map +1 -1
- package/dist/tui/components/sidebar.d.ts.map +1 -1
- package/dist/tui/components/sidebar.js +0 -1
- package/dist/tui/components/sidebar.js.map +1 -1
- package/dist/tui/components/status.d.ts +3 -0
- package/dist/tui/components/status.d.ts.map +1 -1
- package/dist/tui/components/status.js +28 -9
- package/dist/tui/components/status.js.map +1 -1
- package/dist/tui/controller.d.ts +22 -0
- package/dist/tui/controller.d.ts.map +1 -1
- package/dist/tui/controller.js +573 -134
- package/dist/tui/controller.js.map +1 -1
- package/dist/tui/helpers.d.ts.map +1 -1
- package/dist/tui/helpers.js +19 -10
- package/dist/tui/helpers.js.map +1 -1
- package/dist/tui/keybindings.d.ts +24 -0
- package/dist/tui/keybindings.d.ts.map +1 -0
- package/dist/tui/keybindings.js +207 -0
- package/dist/tui/keybindings.js.map +1 -0
- package/dist/tui/navigation.d.ts.map +1 -1
- package/dist/tui/navigation.js +1 -1
- package/dist/tui/navigation.js.map +1 -1
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +50 -20
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/state/store.d.ts.map +1 -1
- package/dist/tui/state/store.js +14 -2
- package/dist/tui/state/store.js.map +1 -1
- package/dist/tui/state/types.d.ts +21 -1
- package/dist/tui/state/types.d.ts.map +1 -1
- package/dist/tui/topic-reader.d.ts +9 -1
- package/dist/tui/topic-reader.d.ts.map +1 -1
- package/dist/tui/topic-reader.js +39 -16
- package/dist/tui/topic-reader.js.map +1 -1
- package/dist/tui/ubb-renderer.d.ts.map +1 -1
- package/dist/tui/ubb-renderer.js +176 -12
- package/dist/tui/ubb-renderer.js.map +1 -1
- package/dist/update.d.ts +4 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +71 -11
- package/dist/update.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +10 -2
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/dist/tui/app-new.d.ts +0 -2
- package/dist/tui/app-new.d.ts.map +0 -1
- package/dist/tui/app-new.js +0 -2589
- package/dist/tui/app-new.js.map +0 -1
- package/dist/tui/app-old.d.ts +0 -2
- package/dist/tui/app-old.d.ts.map +0 -1
- package/dist/tui/app-old.js +0 -2589
- package/dist/tui/app-old.js.map +0 -1
- package/dist/tui/components/layout.d.ts +0 -3
- package/dist/tui/components/layout.d.ts.map +0 -1
- package/dist/tui/components/layout.js +0 -464
- package/dist/tui/components/layout.js.map +0 -1
- package/dist/tui/keymap/actions.d.ts +0 -9
- package/dist/tui/keymap/actions.d.ts.map +0 -1
- package/dist/tui/keymap/actions.js +0 -208
- package/dist/tui/keymap/actions.js.map +0 -1
- package/dist/tui/keymap/bindings.d.ts +0 -5
- package/dist/tui/keymap/bindings.d.ts.map +0 -1
- package/dist/tui/keymap/bindings.js +0 -138
- package/dist/tui/keymap/bindings.js.map +0 -1
- package/dist/tui/keymap/index.d.ts +0 -4
- package/dist/tui/keymap/index.d.ts.map +0 -1
- package/dist/tui/keymap/index.js +0 -5
- package/dist/tui/keymap/index.js.map +0 -1
- package/dist/tui/keymap/types.d.ts +0 -17
- package/dist/tui/keymap/types.d.ts.map +0 -1
- package/dist/tui/keymap/types.js +0 -3
- package/dist/tui/keymap/types.js.map +0 -1
package/dist/tui/controller.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { checkForUpdate } from "../update.js";
|
|
2
2
|
import { appVersion } from "../version.js";
|
|
3
|
+
import { getImageCache } from "../storage/image-cache.js";
|
|
4
|
+
import { SettingsStore } from "../storage/settings-store.js";
|
|
5
|
+
import { getKeybindingManager } from "./keybindings.js";
|
|
3
6
|
import { navItems, settingsItems } from "./navigation.js";
|
|
4
7
|
import { getStatus } from "./state/store.js";
|
|
5
8
|
import { asArray, asNumber, asObject, chatItem, chatMessageItems, flattenBoards, genericItem, historyItem, isAbortError, jsonPreviewLines, loadChatUserNames, mapLimit, noticeItem, overviewStats, topicItem, unreadStats, userItem } from "./helpers.js";
|
|
6
|
-
import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor,
|
|
9
|
+
import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, FLOORS_PER_PAGE } from "./topic-reader.js";
|
|
7
10
|
export class TuiController {
|
|
8
11
|
state;
|
|
9
12
|
client;
|
|
@@ -13,6 +16,9 @@ export class TuiController {
|
|
|
13
16
|
nextSignal;
|
|
14
17
|
abortCurrent;
|
|
15
18
|
loadVersion = 0;
|
|
19
|
+
keybindings;
|
|
20
|
+
settingsStore = new SettingsStore();
|
|
21
|
+
updateChecked = false;
|
|
16
22
|
constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent) {
|
|
17
23
|
this.state = state;
|
|
18
24
|
this.client = client;
|
|
@@ -21,6 +27,7 @@ export class TuiController {
|
|
|
21
27
|
this.close = close;
|
|
22
28
|
this.nextSignal = nextSignal;
|
|
23
29
|
this.abortCurrent = abortCurrent;
|
|
30
|
+
this.keybindings = getKeybindingManager();
|
|
24
31
|
}
|
|
25
32
|
async load(force = false) {
|
|
26
33
|
const version = ++this.loadVersion;
|
|
@@ -43,7 +50,14 @@ export class TuiController {
|
|
|
43
50
|
this.state.currentChat = undefined;
|
|
44
51
|
this.render();
|
|
45
52
|
try {
|
|
53
|
+
// 加载快捷键配置
|
|
54
|
+
await this.keybindings.load();
|
|
46
55
|
this.state.account = await this.tokenStore.getCurrentAccountName();
|
|
56
|
+
// 异步检查更新(不阻塞主加载)
|
|
57
|
+
if (!this.updateChecked) {
|
|
58
|
+
this.updateChecked = true;
|
|
59
|
+
void this.checkUpdate();
|
|
60
|
+
}
|
|
47
61
|
const next = await this.loadView(nav.id, force, signal);
|
|
48
62
|
if (version !== this.loadVersion)
|
|
49
63
|
return;
|
|
@@ -74,11 +88,20 @@ export class TuiController {
|
|
|
74
88
|
this.handleInputKey(key);
|
|
75
89
|
return;
|
|
76
90
|
}
|
|
77
|
-
|
|
91
|
+
// 关闭更新通知(Esc 或任意键)
|
|
92
|
+
if (this.state.updateAvailable?.isNew) {
|
|
93
|
+
if (key === "\x1b" || key === "\r") {
|
|
94
|
+
this.dismissUpdate();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// 其他按键也关闭更新通知,继续处理原按键动作
|
|
98
|
+
this.dismissUpdate();
|
|
99
|
+
}
|
|
100
|
+
if (this.keybindings.matches(key, "quit")) {
|
|
78
101
|
this.close();
|
|
79
102
|
return;
|
|
80
103
|
}
|
|
81
|
-
if (key
|
|
104
|
+
if (this.keybindings.matches(key, "help")) {
|
|
82
105
|
this.state.modal = this.state.modal === "help" ? null : "help";
|
|
83
106
|
this.render();
|
|
84
107
|
return;
|
|
@@ -102,17 +125,17 @@ export class TuiController {
|
|
|
102
125
|
this.handleContentKey(key);
|
|
103
126
|
}
|
|
104
127
|
handleInputKey(key) {
|
|
105
|
-
if (key
|
|
128
|
+
if (this.keybindings.matches(key, "inputCancel")) {
|
|
106
129
|
this.state.inputMode = false;
|
|
107
130
|
this.state.inputValue = "";
|
|
108
131
|
this.render();
|
|
109
132
|
return;
|
|
110
133
|
}
|
|
111
|
-
if (key
|
|
134
|
+
if (this.keybindings.matches(key, "inputConfirm")) {
|
|
112
135
|
this.state.inputCallback?.(this.state.inputValue);
|
|
113
136
|
return;
|
|
114
137
|
}
|
|
115
|
-
if (key
|
|
138
|
+
if (this.keybindings.matches(key, "inputBackspace")) {
|
|
116
139
|
this.state.inputValue = this.state.inputValue.slice(0, -1);
|
|
117
140
|
this.render();
|
|
118
141
|
return;
|
|
@@ -123,10 +146,20 @@ export class TuiController {
|
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
148
|
handleModalKey(key) {
|
|
126
|
-
if (this.state.modal === "help"
|
|
127
|
-
|
|
149
|
+
if (this.state.modal === "help") {
|
|
150
|
+
this.closeModal();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (this.state.modal === "info") {
|
|
154
|
+
// 如果有确认回调,确认键执行回调,其它键关闭。
|
|
155
|
+
if (this.state.confirmCallback && this.keybindings.matches(key, "confirm")) {
|
|
156
|
+
const callback = this.state.confirmCallback;
|
|
157
|
+
this.state.confirmCallback = undefined;
|
|
128
158
|
this.closeModal();
|
|
159
|
+
callback();
|
|
160
|
+
return;
|
|
129
161
|
}
|
|
162
|
+
this.closeModal();
|
|
130
163
|
return;
|
|
131
164
|
}
|
|
132
165
|
if (this.state.modal === "search") {
|
|
@@ -142,36 +175,38 @@ export class TuiController {
|
|
|
142
175
|
}
|
|
143
176
|
}
|
|
144
177
|
handleSearchKey(key) {
|
|
145
|
-
if (key
|
|
178
|
+
if (this.keybindings.matches(key, "searchClose")) {
|
|
146
179
|
this.state.modal = null;
|
|
147
180
|
this.state.searchQuery = "";
|
|
148
181
|
this.render();
|
|
149
182
|
return;
|
|
150
183
|
}
|
|
151
|
-
if (key
|
|
184
|
+
if (this.keybindings.matches(key, "searchToggleMode")) {
|
|
152
185
|
this.state.searchMode = this.state.searchMode === "topics" ? "users" : "topics";
|
|
153
186
|
this.state.searchResults = [];
|
|
154
187
|
this.state.itemIndex = 0;
|
|
155
188
|
this.render();
|
|
156
189
|
return;
|
|
157
190
|
}
|
|
158
|
-
if ((key
|
|
191
|
+
if (this.keybindings.matches(key, "searchNext") && this.state.searchResults.length > 0) {
|
|
159
192
|
this.state.itemIndex = Math.min(this.state.searchResults.length - 1, this.state.itemIndex + 1);
|
|
160
193
|
this.render();
|
|
161
194
|
return;
|
|
162
195
|
}
|
|
163
|
-
if ((key
|
|
196
|
+
if (this.keybindings.matches(key, "searchPrev") && this.state.searchResults.length > 0) {
|
|
164
197
|
this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
|
|
165
198
|
this.render();
|
|
166
199
|
return;
|
|
167
200
|
}
|
|
168
|
-
if (key
|
|
201
|
+
if (this.keybindings.matches(key, "searchExecute")) {
|
|
169
202
|
const selected = this.state.searchResults[this.state.itemIndex];
|
|
170
203
|
if (selected) {
|
|
204
|
+
// 有选中项:打开
|
|
171
205
|
this.state.modal = null;
|
|
172
206
|
void this.activateContentItem(selected, this.nextSignal());
|
|
173
207
|
}
|
|
174
208
|
else if (this.state.searchQuery.trim()) {
|
|
209
|
+
// 无选中项:执行搜索
|
|
175
210
|
void this.performSearch(this.nextSignal());
|
|
176
211
|
}
|
|
177
212
|
return;
|
|
@@ -191,7 +226,8 @@ export class TuiController {
|
|
|
191
226
|
}
|
|
192
227
|
}
|
|
193
228
|
handleUserModalKey(key) {
|
|
194
|
-
if (key === "\x1b") {
|
|
229
|
+
if (key === "\x1b" || key === "u") {
|
|
230
|
+
// Esc 或 u 关闭用户详情
|
|
195
231
|
this.closeModal();
|
|
196
232
|
return;
|
|
197
233
|
}
|
|
@@ -218,22 +254,23 @@ export class TuiController {
|
|
|
218
254
|
}
|
|
219
255
|
}
|
|
220
256
|
handleMenuKey(key) {
|
|
221
|
-
if (key
|
|
257
|
+
if (this.keybindings.matches(key, "menuNext")) {
|
|
222
258
|
this.state.menuIndex = Math.min(Math.max(0, this.state.menuItems.length - 1), this.state.menuIndex + 1);
|
|
223
259
|
this.render();
|
|
224
260
|
return;
|
|
225
261
|
}
|
|
226
|
-
if (key
|
|
262
|
+
if (this.keybindings.matches(key, "menuPrev")) {
|
|
227
263
|
this.state.menuIndex = Math.max(0, this.state.menuIndex - 1);
|
|
228
264
|
this.render();
|
|
229
265
|
return;
|
|
230
266
|
}
|
|
231
|
-
if (key
|
|
267
|
+
if (this.keybindings.matches(key, "menuClose")) {
|
|
232
268
|
this.state.modal = null;
|
|
233
269
|
this.render();
|
|
234
270
|
return;
|
|
235
271
|
}
|
|
236
|
-
if (key
|
|
272
|
+
if (this.keybindings.matches(key, "menuExecute")) {
|
|
273
|
+
// Enter 或 l 执行选中项
|
|
237
274
|
const selected = this.state.menuItems[this.state.menuIndex];
|
|
238
275
|
this.state.modal = null;
|
|
239
276
|
if (selected?.action === "refresh")
|
|
@@ -244,119 +281,190 @@ export class TuiController {
|
|
|
244
281
|
}
|
|
245
282
|
}
|
|
246
283
|
handleTopicKey(key) {
|
|
284
|
+
// 数字输入:收集跳转目标
|
|
247
285
|
if (/^\d$/.test(key) && this.state.topic) {
|
|
248
286
|
this.state.topic.floorInput = `${this.state.topic.floorInput}${key}`.slice(0, 6);
|
|
249
|
-
this.state.status =
|
|
287
|
+
this.state.status = `输入: ${this.state.topic.floorInput}`;
|
|
250
288
|
this.render();
|
|
251
289
|
return;
|
|
252
290
|
}
|
|
253
|
-
|
|
291
|
+
// 退格:删除输入
|
|
292
|
+
if (this.keybindings.matches(key, "inputBackspace") && this.state.topic?.floorInput) {
|
|
254
293
|
this.state.topic.floorInput = this.state.topic.floorInput.slice(0, -1);
|
|
255
|
-
this.state.status = this.state.topic.floorInput ?
|
|
294
|
+
this.state.status = this.state.topic.floorInput ? `输入: ${this.state.topic.floorInput}` : "";
|
|
256
295
|
this.render();
|
|
257
296
|
return;
|
|
258
297
|
}
|
|
259
|
-
|
|
260
|
-
|
|
298
|
+
// 数字 + 跳页键:跳页
|
|
299
|
+
if (this.keybindings.matches(key, "topicJumpPage") && this.state.topic?.floorInput) {
|
|
300
|
+
const page = Number(this.state.topic.floorInput);
|
|
301
|
+
this.state.topic.jumpTarget = { type: "page", value: page };
|
|
261
302
|
this.state.topic.floorInput = "";
|
|
262
|
-
|
|
263
|
-
|
|
303
|
+
this.state.status = `跳转到第 ${page} 页?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
|
|
304
|
+
this.render();
|
|
264
305
|
return;
|
|
265
306
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
307
|
+
// 数字 + 跳楼键:跳楼
|
|
308
|
+
if (this.keybindings.matches(key, "topicJumpFloor") && this.state.topic?.floorInput) {
|
|
309
|
+
const floor = Number(this.state.topic.floorInput);
|
|
310
|
+
this.state.topic.jumpTarget = { type: "floor", value: floor };
|
|
311
|
+
this.state.topic.floorInput = "";
|
|
312
|
+
this.state.status = `跳转到第 ${floor} 楼?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
|
|
269
313
|
this.render();
|
|
270
314
|
return;
|
|
271
315
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
this.state.
|
|
316
|
+
// 跳到最后一页
|
|
317
|
+
if (this.keybindings.matches(key, "topicJumpLast") && !this.state.topic?.floorInput && this.state.topic) {
|
|
318
|
+
const pageInfo = getTopicPageInfo(this.state.topic, this.state.topic.cursorLine);
|
|
319
|
+
this.state.topic.jumpTarget = { type: "page", value: pageInfo.totalPages };
|
|
320
|
+
this.state.status = `跳转到最后一页(第 ${pageInfo.totalPages} 页)?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
|
|
275
321
|
this.render();
|
|
276
322
|
return;
|
|
277
323
|
}
|
|
278
|
-
|
|
324
|
+
// 确认跳转
|
|
325
|
+
if (this.keybindings.matches(key, "confirm") && this.state.topic?.jumpTarget) {
|
|
326
|
+
const target = this.state.topic.jumpTarget;
|
|
327
|
+
this.state.topic.jumpTarget = undefined;
|
|
328
|
+
this.state.status = "";
|
|
329
|
+
if (target.type === "page") {
|
|
330
|
+
void this.jumpToTopicPage(target.value, this.nextSignal());
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
void this.jumpToTopicFloor(target.value, this.nextSignal());
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// 取消跳转
|
|
338
|
+
if (this.keybindings.matches(key, "back") && (this.state.topic?.floorInput || this.state.topic?.jumpTarget)) {
|
|
279
339
|
this.state.topic.floorInput = "";
|
|
280
|
-
this.state.
|
|
340
|
+
this.state.topic.jumpTarget = undefined;
|
|
341
|
+
this.state.status = "";
|
|
281
342
|
this.render();
|
|
282
343
|
return;
|
|
283
344
|
}
|
|
284
|
-
|
|
345
|
+
// ]:下一层
|
|
346
|
+
if (this.keybindings.matches(key, "topicNextFloor") && this.state.topic) {
|
|
347
|
+
void this.jumpRelativeFloor(1);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// [:上一层
|
|
351
|
+
if (this.keybindings.matches(key, "topicPrevFloor") && this.state.topic) {
|
|
352
|
+
void this.jumpRelativeFloor(-1);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// }:下一页
|
|
356
|
+
if (this.keybindings.matches(key, "topicNextPage") && this.state.topic) {
|
|
357
|
+
void this.jumpToNextPage();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// {:上一页
|
|
361
|
+
if (this.keybindings.matches(key, "topicPrevPage") && this.state.topic) {
|
|
362
|
+
void this.jumpToPrevPage();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// h/Esc:返回
|
|
366
|
+
if (this.keybindings.matches(key, "back")) {
|
|
285
367
|
this.leave();
|
|
286
368
|
return;
|
|
287
369
|
}
|
|
288
|
-
|
|
370
|
+
// j:下移
|
|
371
|
+
if (this.keybindings.matches(key, "topicScrollDown")) {
|
|
289
372
|
const maxScroll = Math.max(0, (this.state.topic?.lines.length ?? 0) - 1);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
this.render();
|
|
293
|
-
if (wasAtEnd && this.state.topic?.hasMore && !this.state.loadingMore) {
|
|
294
|
-
void this.loadNextTopicPage(this.nextSignal(), true);
|
|
373
|
+
if (this.state.topic) {
|
|
374
|
+
this.state.topic.cursorLine = Math.min(maxScroll, this.state.topic.cursorLine + 1);
|
|
295
375
|
}
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
if (key === "k" || key === "\x1b[A") {
|
|
299
|
-
this.state.scroll = Math.max(0, this.state.scroll - 1);
|
|
376
|
+
this.state.status = "";
|
|
300
377
|
this.render();
|
|
378
|
+
void this.checkAutoLoad();
|
|
301
379
|
return;
|
|
302
380
|
}
|
|
303
|
-
|
|
304
|
-
|
|
381
|
+
// k:上移
|
|
382
|
+
if (this.keybindings.matches(key, "topicScrollUp")) {
|
|
383
|
+
if (this.state.topic) {
|
|
384
|
+
this.state.topic.cursorLine = Math.max(0, this.state.topic.cursorLine - 1);
|
|
385
|
+
}
|
|
386
|
+
this.state.status = "";
|
|
387
|
+
this.render();
|
|
305
388
|
return;
|
|
306
389
|
}
|
|
307
|
-
|
|
390
|
+
// r:刷新
|
|
391
|
+
if (this.keybindings.matches(key, "topicRefresh") && this.state.topic) {
|
|
308
392
|
void this.openTopic(this.state.topic.topicId, true, this.nextSignal());
|
|
309
393
|
return;
|
|
310
394
|
}
|
|
311
|
-
|
|
395
|
+
// s:收藏
|
|
396
|
+
if (this.keybindings.matches(key, "topicFavorite"))
|
|
312
397
|
void this.toggleFavorite();
|
|
313
|
-
|
|
398
|
+
// l:点赞
|
|
399
|
+
if (this.keybindings.matches(key, "topicLike"))
|
|
314
400
|
void this.reactToCurrentPost(true);
|
|
315
|
-
|
|
401
|
+
// d:踩
|
|
402
|
+
if (this.keybindings.matches(key, "topicDislike"))
|
|
316
403
|
void this.reactToCurrentPost(false);
|
|
317
|
-
|
|
404
|
+
// u:查看用户
|
|
405
|
+
if (this.keybindings.matches(key, "topicUser"))
|
|
318
406
|
void this.showCurrentUser(this.nextSignal());
|
|
319
|
-
|
|
407
|
+
// v:查看投票
|
|
408
|
+
if (this.keybindings.matches(key, "topicVote"))
|
|
320
409
|
void this.showTopicVote(this.nextSignal());
|
|
321
|
-
|
|
410
|
+
// a:查看评价
|
|
411
|
+
if (this.keybindings.matches(key, "topicReaction"))
|
|
322
412
|
void this.showPostReactionState(this.nextSignal());
|
|
323
|
-
|
|
324
|
-
|
|
413
|
+
// o:打开图片/菜单
|
|
414
|
+
if (this.keybindings.matches(key, "topicOpenImage")) {
|
|
415
|
+
const currentLine = this.getCurrentTopicLine();
|
|
416
|
+
if (currentLine?.kind === "image" && currentLine.imageUrl) {
|
|
417
|
+
void this.openImage(currentLine.imageUrl);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
this.openMenu();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// c:复制链接
|
|
424
|
+
if (this.keybindings.matches(key, "topicCopyLink")) {
|
|
425
|
+
const currentLine = this.getCurrentTopicLine();
|
|
426
|
+
if (currentLine?.kind === "image" && currentLine.imageUrl) {
|
|
427
|
+
void this.copyToClipboard(currentLine.imageUrl);
|
|
428
|
+
}
|
|
429
|
+
else if (currentLine?.kind === "link" && currentLine.linkUrl) {
|
|
430
|
+
void this.copyToClipboard(currentLine.linkUrl);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
325
433
|
}
|
|
326
434
|
handleSettingsKey(key) {
|
|
327
|
-
if (key
|
|
435
|
+
if (this.keybindings.matches(key, "moveDown")) {
|
|
328
436
|
this.state.itemIndex = Math.min(settingsItems.length - 1, this.state.itemIndex + 1);
|
|
329
437
|
this.render();
|
|
330
438
|
return;
|
|
331
439
|
}
|
|
332
|
-
if (key
|
|
440
|
+
if (this.keybindings.matches(key, "moveUp")) {
|
|
333
441
|
this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
|
|
334
442
|
this.render();
|
|
335
443
|
return;
|
|
336
444
|
}
|
|
337
|
-
if (key
|
|
445
|
+
if (this.keybindings.matches(key, "back")) {
|
|
338
446
|
this.state.mode = "list";
|
|
339
447
|
this.state.focus = "nav";
|
|
340
448
|
this.state.status = getStatus(this.state);
|
|
341
449
|
this.render();
|
|
342
450
|
return;
|
|
343
451
|
}
|
|
344
|
-
if (key
|
|
452
|
+
if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
|
|
345
453
|
void this.activateSetting(settingsItems[this.state.itemIndex]);
|
|
346
454
|
}
|
|
347
455
|
}
|
|
348
456
|
handleNavKey(key) {
|
|
349
|
-
if (key
|
|
457
|
+
if (this.keybindings.matches(key, "moveDown")) {
|
|
350
458
|
this.state.navIndex = Math.min(navItems.length - 1, this.state.navIndex + 1);
|
|
351
459
|
void this.load();
|
|
352
460
|
return;
|
|
353
461
|
}
|
|
354
|
-
if (key
|
|
462
|
+
if (this.keybindings.matches(key, "moveUp")) {
|
|
355
463
|
this.state.navIndex = Math.max(0, this.state.navIndex - 1);
|
|
356
464
|
void this.load();
|
|
357
465
|
return;
|
|
358
466
|
}
|
|
359
|
-
if (key
|
|
467
|
+
if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
|
|
360
468
|
if (!this.state.loading && this.state.items.length > 0) {
|
|
361
469
|
if (navItems[this.state.navIndex]?.id === "settings")
|
|
362
470
|
this.state.mode = "settings";
|
|
@@ -367,25 +475,25 @@ export class TuiController {
|
|
|
367
475
|
}
|
|
368
476
|
return;
|
|
369
477
|
}
|
|
370
|
-
if (key
|
|
478
|
+
if (this.keybindings.matches(key, "refresh"))
|
|
371
479
|
void this.load(true);
|
|
372
480
|
}
|
|
373
481
|
handleContentKey(key) {
|
|
374
|
-
if (key
|
|
482
|
+
if (this.keybindings.matches(key, "listNext")) {
|
|
375
483
|
this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), this.state.itemIndex + 1);
|
|
376
484
|
this.render();
|
|
377
485
|
return;
|
|
378
486
|
}
|
|
379
|
-
if (key
|
|
487
|
+
if (this.keybindings.matches(key, "listPrev")) {
|
|
380
488
|
this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
|
|
381
489
|
this.render();
|
|
382
490
|
return;
|
|
383
491
|
}
|
|
384
|
-
if (key
|
|
492
|
+
if (this.keybindings.matches(key, "listBack")) {
|
|
385
493
|
this.leave();
|
|
386
494
|
return;
|
|
387
495
|
}
|
|
388
|
-
if (key
|
|
496
|
+
if (this.keybindings.matches(key, "listOpen")) {
|
|
389
497
|
const selected = this.state.items[this.state.itemIndex];
|
|
390
498
|
if (selected) {
|
|
391
499
|
void this.activateContentItem(selected, this.nextSignal());
|
|
@@ -396,15 +504,11 @@ export class TuiController {
|
|
|
396
504
|
}
|
|
397
505
|
return;
|
|
398
506
|
}
|
|
399
|
-
if ((key
|
|
400
|
-
void this.loadNextChatPage(this.nextSignal());
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
if (key === "r")
|
|
507
|
+
if (this.keybindings.matches(key, "listRefresh"))
|
|
404
508
|
void this.refresh();
|
|
405
|
-
if (key
|
|
509
|
+
if (this.keybindings.matches(key, "search"))
|
|
406
510
|
this.openSearch();
|
|
407
|
-
if (key
|
|
511
|
+
if (this.keybindings.matches(key, "menu"))
|
|
408
512
|
this.openMenu();
|
|
409
513
|
}
|
|
410
514
|
leave() {
|
|
@@ -441,9 +545,17 @@ export class TuiController {
|
|
|
441
545
|
this.state.searchQuery = "";
|
|
442
546
|
this.state.searchResults = [];
|
|
443
547
|
this.state.searchMode = "topics";
|
|
548
|
+
this.state.searchScope = this.getSearchScope();
|
|
444
549
|
this.state.itemIndex = 0;
|
|
445
550
|
this.render();
|
|
446
551
|
}
|
|
552
|
+
getSearchScope() {
|
|
553
|
+
// 根据当前位置确定搜索范围
|
|
554
|
+
if (this.state.currentBoard) {
|
|
555
|
+
return { label: this.state.currentBoard.title, boardId: this.state.currentBoard.boardId };
|
|
556
|
+
}
|
|
557
|
+
return { label: "全站" };
|
|
558
|
+
}
|
|
447
559
|
openMenu() {
|
|
448
560
|
this.state.modal = "menu";
|
|
449
561
|
this.state.menuItems = this.getMenuItems();
|
|
@@ -500,7 +612,7 @@ export class TuiController {
|
|
|
500
612
|
title: "版面",
|
|
501
613
|
items: boards.slice(0, 24),
|
|
502
614
|
stats: [{ title: "分区", detail: `${sections.length}` }, { title: "版面", detail: `${boards.length}` }],
|
|
503
|
-
status: "版面:j/k 选择
|
|
615
|
+
status: "版面:j/k 选择 Enter 进入版面 h 返回 r 刷新"
|
|
504
616
|
};
|
|
505
617
|
}
|
|
506
618
|
case "following": {
|
|
@@ -509,7 +621,7 @@ export class TuiController {
|
|
|
509
621
|
title: "关注",
|
|
510
622
|
items: topics.map((topic) => topicItem(topic)),
|
|
511
623
|
stats: [{ title: "关注动态", detail: `${topics.length} 条` }, { title: "缓存", detail: "30s" }],
|
|
512
|
-
status: "关注:j/k 选择
|
|
624
|
+
status: "关注:j/k 选择 Enter 打开帖子 h 返回 r 刷新"
|
|
513
625
|
};
|
|
514
626
|
}
|
|
515
627
|
case "favorite": {
|
|
@@ -530,7 +642,8 @@ export class TuiController {
|
|
|
530
642
|
return {
|
|
531
643
|
title: "收藏",
|
|
532
644
|
items: [
|
|
533
|
-
{ title: "收藏主题", meta: "topic/me/favorite", detail: "
|
|
645
|
+
{ title: "收藏主题", meta: "topic/me/favorite", detail: "查看收藏夹主题列表", action: "favorite-topics" },
|
|
646
|
+
{ title: "收藏更新", meta: "topic/me/favorite?order=1", detail: "查看收藏主题更新", action: "favorite-updates" },
|
|
534
647
|
{ title: "收藏分组", meta: "me/favorite-topic-group", detail: "查看收藏夹分组", action: "favorite-groups" },
|
|
535
648
|
...asArray(topicFavorites).slice(0, 6).map((topic) => topicItem(topic)),
|
|
536
649
|
...boardTopics
|
|
@@ -540,7 +653,7 @@ export class TuiController {
|
|
|
540
653
|
{ title: "收藏主题", detail: `${asArray(topicFavorites).length} 条` },
|
|
541
654
|
{ title: "版面主题", detail: `${boardTopics.length} 条` }
|
|
542
655
|
],
|
|
543
|
-
status: "收藏:j/k 选择
|
|
656
|
+
status: "收藏:j/k 选择 Enter 打开 h 返回 r 刷新"
|
|
544
657
|
};
|
|
545
658
|
}
|
|
546
659
|
case "messages": {
|
|
@@ -554,7 +667,7 @@ export class TuiController {
|
|
|
554
667
|
title: "消息",
|
|
555
668
|
items: chats.length > 0 ? chats.map((chat) => chatItem(chat, names)) : [{ title: "暂无最近私信", meta: "recent-contact-users" }],
|
|
556
669
|
stats: unreadStats(asObject(unread)),
|
|
557
|
-
status: "消息:j/k 选择
|
|
670
|
+
status: "消息:j/k 选择 Enter 打开会话 h 返回 r 刷新"
|
|
558
671
|
};
|
|
559
672
|
}
|
|
560
673
|
case "notices": {
|
|
@@ -567,7 +680,7 @@ export class TuiController {
|
|
|
567
680
|
{ title: "回复通知", meta: `${unread.replyCount ?? 0} 未读`, detail: "查看回复我的通知", action: "notices:reply" }
|
|
568
681
|
],
|
|
569
682
|
stats: unreadStats(unread),
|
|
570
|
-
status: "通知:j/k 选择
|
|
683
|
+
status: "通知:j/k 选择 Enter 打开列表 h 返回 r 刷新"
|
|
571
684
|
};
|
|
572
685
|
}
|
|
573
686
|
case "me": {
|
|
@@ -579,41 +692,25 @@ export class TuiController {
|
|
|
579
692
|
{ title: String(meObject.name ?? "当前账号"), meta: `#${meObject.id ?? "?"}`, detail: String(meObject.levelTitle ?? meObject.groupName ?? ""), userId: asNumber(meObject.id) },
|
|
580
693
|
{ title: "我的最近主题", meta: "me/recent-topic", detail: "查看自己最近发布或回复的主题", action: "recent-topics" },
|
|
581
694
|
{ title: "浏览历史", meta: "me/browsing-record", detail: "查看最近浏览过的主题", action: "browse-history" },
|
|
695
|
+
{ title: "粉丝列表", meta: "me/follower", detail: "查看关注我的用户", action: "followers" },
|
|
696
|
+
{ title: "关注列表", meta: "me/followee", detail: "查看我关注的用户", action: "followees" },
|
|
697
|
+
{ title: "随机主题", meta: "topic/random-recent", detail: "随机读取一组最近主题", action: "random-topics" },
|
|
582
698
|
{ title: "每日签到", meta: "me/signin", detail: "执行签到", action: "signin" }
|
|
583
699
|
],
|
|
584
700
|
stats: [
|
|
585
701
|
{ title: "登录状态", detail: "已登录" },
|
|
586
702
|
{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` }
|
|
587
703
|
],
|
|
588
|
-
status: "我的:j/k 选择
|
|
704
|
+
status: "我的:j/k 选择 Enter 打开 h 返回 r 刷新"
|
|
589
705
|
};
|
|
590
706
|
}
|
|
591
|
-
case "more":
|
|
592
|
-
return {
|
|
593
|
-
title: "更多",
|
|
594
|
-
items: [
|
|
595
|
-
{ title: "随机主题", meta: "topic/random-recent", detail: "随机读取一组最近主题", action: "random-topics" },
|
|
596
|
-
{ title: "我的最近主题", meta: "me/recent-topic", detail: "查看自己最近发布或回复的主题", action: "recent-topics" },
|
|
597
|
-
{ title: "浏览历史", meta: "me/browsing-record", detail: "查看最近浏览过的主题", action: "browse-history" },
|
|
598
|
-
{ title: "收藏主题", meta: "topic/me/favorite", detail: "查看收藏夹主题", action: "favorite-topics" },
|
|
599
|
-
{ title: "收藏更新", meta: "topic/me/favorite?order=1", detail: "查看收藏主题更新", action: "favorite-updates" },
|
|
600
|
-
{ title: "收藏分组", meta: "me/favorite-topic-group", detail: "查看收藏夹分组", action: "favorite-groups" },
|
|
601
|
-
{ title: "粉丝列表", meta: "me/follower", detail: "查看关注我的用户", action: "followers" },
|
|
602
|
-
{ title: "关注列表", meta: "me/followee", detail: "查看我关注的用户", action: "followees" },
|
|
603
|
-
{ title: "全站统计", meta: "card.cc98.org/api/collection/stat", detail: "查看论坛全站统计", action: "card-stat" },
|
|
604
|
-
{ title: "评分原因: 普通", meta: "post/rating-reason?type=0", detail: "查看普通评分原因", action: "rate-reasons:0" },
|
|
605
|
-
{ title: "评分原因: 管理", meta: "post/rating-reason?type=1", detail: "查看管理评分原因", action: "rate-reasons:1" }
|
|
606
|
-
],
|
|
607
|
-
stats: [{ title: "只读入口", detail: "11 个" }, { title: "写入", detail: "不含发帖/回帖" }],
|
|
608
|
-
status: "更多:j/k 选择 l 打开只读内容 h 返回 r 刷新"
|
|
609
|
-
};
|
|
610
707
|
case "settings": {
|
|
611
708
|
const cacheStats = await this.client.getCacheStats();
|
|
612
709
|
return {
|
|
613
710
|
title: "设置",
|
|
614
711
|
items: [...settingsItems],
|
|
615
712
|
stats: [{ title: "缓存", detail: `${cacheStats.fileCacheEntries} 文件` }, { title: "版本", detail: `v${appVersion}` }],
|
|
616
|
-
status: "设置:j/k 选择
|
|
713
|
+
status: "设置:j/k 选择 Enter 执行 h 返回"
|
|
617
714
|
};
|
|
618
715
|
}
|
|
619
716
|
}
|
|
@@ -626,34 +723,27 @@ export class TuiController {
|
|
|
626
723
|
this.render();
|
|
627
724
|
return;
|
|
628
725
|
}
|
|
726
|
+
if (selected.meta === "keybindings") {
|
|
727
|
+
void this.openKeybindingEditor();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
629
730
|
if (selected.meta === "cache") {
|
|
630
|
-
this.
|
|
631
|
-
this.render();
|
|
632
|
-
try {
|
|
633
|
-
await this.client.clearCache();
|
|
634
|
-
this.state.status = "缓存已清理";
|
|
635
|
-
await this.load(true);
|
|
636
|
-
}
|
|
637
|
-
catch {
|
|
638
|
-
this.state.status = "缓存清理失败";
|
|
639
|
-
this.render();
|
|
640
|
-
}
|
|
731
|
+
void this.openCacheManager();
|
|
641
732
|
return;
|
|
642
733
|
}
|
|
643
734
|
if (selected.meta === "update") {
|
|
644
|
-
this.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
catch (error) {
|
|
651
|
-
this.state.status = error instanceof Error ? error.message : "检查更新失败";
|
|
652
|
-
}
|
|
653
|
-
this.render();
|
|
735
|
+
void this.checkUpdate(true);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (selected.meta === "account") {
|
|
739
|
+
void this.openAccountSwitcher();
|
|
654
740
|
return;
|
|
655
741
|
}
|
|
656
|
-
|
|
742
|
+
if (selected.meta === "logout") {
|
|
743
|
+
void this.confirmLogout();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
this.state.status = "功能开发中...";
|
|
657
747
|
this.render();
|
|
658
748
|
}
|
|
659
749
|
async activateContentItem(selected, signal) {
|
|
@@ -673,6 +763,12 @@ export class TuiController {
|
|
|
673
763
|
await this.showUserDetailById(selected.userId, signal);
|
|
674
764
|
return;
|
|
675
765
|
}
|
|
766
|
+
// 账号切换
|
|
767
|
+
if (selected.meta?.startsWith("account:")) {
|
|
768
|
+
const accountName = selected.meta.slice(8);
|
|
769
|
+
await this.switchAccount(accountName);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
676
772
|
if (selected.action?.startsWith("notices:")) {
|
|
677
773
|
await this.openNoticeList(selected.action.split(":")[1], signal);
|
|
678
774
|
return;
|
|
@@ -724,7 +820,7 @@ export class TuiController {
|
|
|
724
820
|
const topics = asArray(await this.client.getBoardTopics(boardId, 0, 20, false, force, signal));
|
|
725
821
|
this.state.items = topics.map((topic) => topicItem(topic, { title, boardId }));
|
|
726
822
|
this.state.stats = [{ title: "主题", detail: `${topics.length} 条` }];
|
|
727
|
-
this.state.status = `版面 ${title}: j/k 选择
|
|
823
|
+
this.state.status = `版面 ${title}: j/k 选择 Enter 打开帖子 h 返回`;
|
|
728
824
|
}
|
|
729
825
|
catch (error) {
|
|
730
826
|
if (!isAbortError(error))
|
|
@@ -783,6 +879,94 @@ export class TuiController {
|
|
|
783
879
|
this.render();
|
|
784
880
|
}
|
|
785
881
|
}
|
|
882
|
+
async checkAutoLoad() {
|
|
883
|
+
const topic = this.state.topic;
|
|
884
|
+
if (!topic?.hasMore || this.state.loadingMore)
|
|
885
|
+
return;
|
|
886
|
+
const viewportRows = Math.max(1, topic.viewportRows);
|
|
887
|
+
const viewportBottom = this.state.scroll + viewportRows;
|
|
888
|
+
if (viewportBottom >= topic.lines.length) {
|
|
889
|
+
void this.loadNextTopicPage(this.nextSignal(), true);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async jumpRelativeFloor(delta) {
|
|
893
|
+
const topic = this.state.topic;
|
|
894
|
+
if (!topic)
|
|
895
|
+
return;
|
|
896
|
+
const current = currentTopicPost(topic, topic.cursorLine);
|
|
897
|
+
const currentFloor = current?.floor ?? 1;
|
|
898
|
+
const targetFloor = currentFloor + delta;
|
|
899
|
+
if (targetFloor < 1)
|
|
900
|
+
return;
|
|
901
|
+
const loaded = findTopicPostByFloor(topic, targetFloor);
|
|
902
|
+
if (loaded) {
|
|
903
|
+
topic.cursorLine = loaded.lineStart;
|
|
904
|
+
this.state.status = "";
|
|
905
|
+
this.render();
|
|
906
|
+
if (delta > 0)
|
|
907
|
+
void this.checkAutoLoad();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (delta > 0 && topic.hasMore && !this.state.loadingMore) {
|
|
911
|
+
await this.jumpToTopicFloor(targetFloor, this.nextSignal());
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
async jumpToNextPage() {
|
|
915
|
+
const topic = this.state.topic;
|
|
916
|
+
if (!topic)
|
|
917
|
+
return;
|
|
918
|
+
const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
|
|
919
|
+
if (pageInfo.currentPage < pageInfo.totalPages) {
|
|
920
|
+
await this.jumpToTopicPage(pageInfo.currentPage + 1, this.nextSignal());
|
|
921
|
+
}
|
|
922
|
+
else if (topic.hasMore && !this.state.loadingMore) {
|
|
923
|
+
// 当前是最后一页,但还有更多内容,加载下一页
|
|
924
|
+
await this.loadNextTopicPage(this.nextSignal());
|
|
925
|
+
const newPageInfo = getTopicPageInfo(topic, topic.cursorLine);
|
|
926
|
+
topic.cursorLine = jumpToPage(topic, newPageInfo.currentPage + 1);
|
|
927
|
+
this.state.status = "";
|
|
928
|
+
this.render();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
async jumpToPrevPage() {
|
|
932
|
+
const topic = this.state.topic;
|
|
933
|
+
if (!topic)
|
|
934
|
+
return;
|
|
935
|
+
const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
|
|
936
|
+
if (pageInfo.currentPage > 1) {
|
|
937
|
+
topic.cursorLine = jumpToPage(topic, pageInfo.currentPage - 1);
|
|
938
|
+
this.state.status = "";
|
|
939
|
+
this.render();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async jumpToTopicPage(page, signal) {
|
|
943
|
+
const topic = this.state.topic;
|
|
944
|
+
if (!topic)
|
|
945
|
+
return;
|
|
946
|
+
const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
|
|
947
|
+
if (page < 1 || page > pageInfo.totalPages) {
|
|
948
|
+
this.state.status = `未找到第 ${page} 页`;
|
|
949
|
+
this.render();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// 如果目标页未加载,需要加载到该页
|
|
953
|
+
const targetFloor = (page - 1) * FLOORS_PER_PAGE + 1;
|
|
954
|
+
while (topic.hasMore && !findTopicPostByFloor(topic, targetFloor)) {
|
|
955
|
+
const previousLoaded = topic.loaded;
|
|
956
|
+
await this.loadNextTopicPage(signal, true);
|
|
957
|
+
if (topic.loaded === previousLoaded)
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
const post = findTopicPostByFloor(topic, targetFloor);
|
|
961
|
+
if (post) {
|
|
962
|
+
topic.cursorLine = post.lineStart;
|
|
963
|
+
this.state.status = "";
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
this.state.status = `未找到第 ${page} 页`;
|
|
967
|
+
}
|
|
968
|
+
this.render();
|
|
969
|
+
}
|
|
786
970
|
async loadNextTopicPage(signal, quiet = false) {
|
|
787
971
|
const topic = this.state.topic;
|
|
788
972
|
if (!topic?.hasMore || this.state.loadingMore)
|
|
@@ -793,7 +977,7 @@ export class TuiController {
|
|
|
793
977
|
try {
|
|
794
978
|
const posts = asArray(await this.client.getTopicPosts(topic.topicId, topic.loaded, topic.size, false, signal));
|
|
795
979
|
appendTopicPosts(topic, posts);
|
|
796
|
-
this.state.status =
|
|
980
|
+
this.state.status = "";
|
|
797
981
|
}
|
|
798
982
|
catch (error) {
|
|
799
983
|
if (!isAbortError(error))
|
|
@@ -810,17 +994,20 @@ export class TuiController {
|
|
|
810
994
|
return;
|
|
811
995
|
const loaded = findTopicPostByFloor(topic, floor);
|
|
812
996
|
if (loaded) {
|
|
813
|
-
|
|
814
|
-
this.state.status =
|
|
997
|
+
topic.cursorLine = loaded.lineStart;
|
|
998
|
+
this.state.status = "";
|
|
815
999
|
this.render();
|
|
816
1000
|
return;
|
|
817
1001
|
}
|
|
818
1002
|
while (topic.hasMore && !findTopicPostByFloor(topic, floor)) {
|
|
1003
|
+
const previousLoaded = topic.loaded;
|
|
819
1004
|
await this.loadNextTopicPage(signal, true);
|
|
1005
|
+
if (topic.loaded === previousLoaded)
|
|
1006
|
+
break;
|
|
820
1007
|
}
|
|
821
1008
|
const post = findTopicPostByFloor(topic, floor);
|
|
822
|
-
|
|
823
|
-
this.state.status = post ?
|
|
1009
|
+
topic.cursorLine = post?.lineStart ?? topic.cursorLine;
|
|
1010
|
+
this.state.status = post ? "" : `未找到 ${floor} 楼`;
|
|
824
1011
|
this.render();
|
|
825
1012
|
}
|
|
826
1013
|
async runReadOnlyAction(action, signal) {
|
|
@@ -893,7 +1080,7 @@ export class TuiController {
|
|
|
893
1080
|
this.render();
|
|
894
1081
|
try {
|
|
895
1082
|
const results = this.state.searchMode === "topics"
|
|
896
|
-
? asArray(await this.client.searchTopics(query, 0, 20, true, signal)).map((topic) => topicItem(topic))
|
|
1083
|
+
? this.filterSearchTopicScope(asArray(await this.client.searchTopics(query, 0, 20, true, signal)).map((topic) => topicItem(topic)))
|
|
897
1084
|
: asArray(await this.client.searchUsers(query, true, signal)).map((user) => userItem(user));
|
|
898
1085
|
this.state.searchResults = results;
|
|
899
1086
|
this.state.itemIndex = 0;
|
|
@@ -907,6 +1094,13 @@ export class TuiController {
|
|
|
907
1094
|
this.render();
|
|
908
1095
|
}
|
|
909
1096
|
}
|
|
1097
|
+
filterSearchTopicScope(items) {
|
|
1098
|
+
const boardId = this.state.searchScope.boardId;
|
|
1099
|
+
if (boardId === undefined) {
|
|
1100
|
+
return items;
|
|
1101
|
+
}
|
|
1102
|
+
return items.filter((item) => item.boardId === boardId);
|
|
1103
|
+
}
|
|
910
1104
|
async toggleFavorite() {
|
|
911
1105
|
const topic = this.state.topic;
|
|
912
1106
|
if (!topic)
|
|
@@ -931,7 +1125,7 @@ export class TuiController {
|
|
|
931
1125
|
const topic = this.state.topic;
|
|
932
1126
|
if (!topic)
|
|
933
1127
|
return;
|
|
934
|
-
const post = currentTopicPost(topic,
|
|
1128
|
+
const post = currentTopicPost(topic, topic.cursorLine);
|
|
935
1129
|
if (!post?.id) {
|
|
936
1130
|
this.state.status = "当前楼层没有可操作的帖子 ID";
|
|
937
1131
|
this.render();
|
|
@@ -950,7 +1144,7 @@ export class TuiController {
|
|
|
950
1144
|
const topic = this.state.topic;
|
|
951
1145
|
if (!topic)
|
|
952
1146
|
return;
|
|
953
|
-
const post = currentTopicPost(topic,
|
|
1147
|
+
const post = currentTopicPost(topic, topic.cursorLine);
|
|
954
1148
|
if (!post?.userId) {
|
|
955
1149
|
this.state.status = "当前楼层没有用户 ID";
|
|
956
1150
|
this.render();
|
|
@@ -1006,7 +1200,7 @@ export class TuiController {
|
|
|
1006
1200
|
const topic = this.state.topic;
|
|
1007
1201
|
if (!topic)
|
|
1008
1202
|
return;
|
|
1009
|
-
const post = currentTopicPost(topic,
|
|
1203
|
+
const post = currentTopicPost(topic, topic.cursorLine);
|
|
1010
1204
|
if (!post?.id)
|
|
1011
1205
|
return;
|
|
1012
1206
|
try {
|
|
@@ -1052,6 +1246,64 @@ export class TuiController {
|
|
|
1052
1246
|
}
|
|
1053
1247
|
this.render();
|
|
1054
1248
|
}
|
|
1249
|
+
getCurrentTopicLine() {
|
|
1250
|
+
const topic = this.state.topic;
|
|
1251
|
+
if (!topic)
|
|
1252
|
+
return undefined;
|
|
1253
|
+
for (const post of topic.posts) {
|
|
1254
|
+
const line = post.lines.find((entry) => entry.line === topic.cursorLine);
|
|
1255
|
+
if (line)
|
|
1256
|
+
return line;
|
|
1257
|
+
}
|
|
1258
|
+
return undefined;
|
|
1259
|
+
}
|
|
1260
|
+
async openImage(url) {
|
|
1261
|
+
this.state.status = "正在下载图片...";
|
|
1262
|
+
this.render();
|
|
1263
|
+
try {
|
|
1264
|
+
const cache = getImageCache();
|
|
1265
|
+
const localPath = await cache.getOrDownload(url);
|
|
1266
|
+
this.state.status = `已缓存: ${localPath}`;
|
|
1267
|
+
this.render();
|
|
1268
|
+
// 用系统默认程序打开图片
|
|
1269
|
+
const { execFile } = await import("node:child_process");
|
|
1270
|
+
const platform = process.platform;
|
|
1271
|
+
const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
|
|
1272
|
+
const args = platform === "win32" ? ["/c", "start", "", localPath] : [localPath];
|
|
1273
|
+
execFile(command, args, (error) => {
|
|
1274
|
+
if (error) {
|
|
1275
|
+
this.state.status = `打开失败: ${error.message}`;
|
|
1276
|
+
this.render();
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
this.state.status = error instanceof Error ? error.message : "图片下载失败";
|
|
1282
|
+
this.render();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async copyToClipboard(text) {
|
|
1286
|
+
try {
|
|
1287
|
+
const { spawn } = await import("node:child_process");
|
|
1288
|
+
const platform = process.platform;
|
|
1289
|
+
const command = platform === "win32" ? "clip" : platform === "darwin" ? "pbcopy" : "xclip";
|
|
1290
|
+
const args = platform === "linux" ? ["-selection", "clipboard"] : [];
|
|
1291
|
+
const child = spawn(command, args);
|
|
1292
|
+
child.stdin.end(text);
|
|
1293
|
+
child.on("error", () => {
|
|
1294
|
+
this.state.status = "复制失败";
|
|
1295
|
+
this.render();
|
|
1296
|
+
});
|
|
1297
|
+
child.on("close", (code) => {
|
|
1298
|
+
this.state.status = code === 0 ? "已复制到剪贴板" : "复制失败";
|
|
1299
|
+
this.render();
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
this.state.status = "复制失败";
|
|
1304
|
+
this.render();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1055
1307
|
async signin() {
|
|
1056
1308
|
this.state.status = "正在签到...";
|
|
1057
1309
|
this.render();
|
|
@@ -1066,6 +1318,147 @@ export class TuiController {
|
|
|
1066
1318
|
}
|
|
1067
1319
|
this.render();
|
|
1068
1320
|
}
|
|
1321
|
+
async openKeybindingEditor() {
|
|
1322
|
+
// 显示快捷键配置信息
|
|
1323
|
+
const config = this.keybindings.getConfig();
|
|
1324
|
+
const lines = [
|
|
1325
|
+
"快捷键配置文件: ~/.cc98-cli/keybindings.json",
|
|
1326
|
+
"",
|
|
1327
|
+
"当前配置:"
|
|
1328
|
+
];
|
|
1329
|
+
// 显示主要快捷键
|
|
1330
|
+
const mainActions = [
|
|
1331
|
+
"moveUp", "moveDown", "moveLeft", "moveRight", "confirm", "back",
|
|
1332
|
+
"search", "refresh", "menu", "help", "quit",
|
|
1333
|
+
"topicNextPage", "topicPrevPage", "topicNextFloor", "topicPrevFloor",
|
|
1334
|
+
"topicJumpPage", "topicJumpFloor", "topicJumpLast"
|
|
1335
|
+
];
|
|
1336
|
+
for (const action of mainActions) {
|
|
1337
|
+
const keys = config[action] ?? [];
|
|
1338
|
+
const desc = this.keybindings.getActionDescription(action);
|
|
1339
|
+
const keyStr = keys.map(k => this.keybindings.formatKey(k)).join("/");
|
|
1340
|
+
lines.push(` ${desc}: ${keyStr}`);
|
|
1341
|
+
}
|
|
1342
|
+
lines.push("", "编辑配置文件后重启生效。", "", "按 Esc 返回设置");
|
|
1343
|
+
this.state.modal = "info";
|
|
1344
|
+
this.state.infoTitle = "快捷键设置";
|
|
1345
|
+
this.state.infoLines = lines;
|
|
1346
|
+
this.render();
|
|
1347
|
+
}
|
|
1348
|
+
async openAccountSwitcher() {
|
|
1349
|
+
try {
|
|
1350
|
+
const accounts = await this.tokenStore.listAccounts();
|
|
1351
|
+
const currentAccount = await this.tokenStore.getCurrentAccountName();
|
|
1352
|
+
if (accounts.length === 0) {
|
|
1353
|
+
this.state.modal = "info";
|
|
1354
|
+
this.state.infoTitle = "切换账号";
|
|
1355
|
+
this.state.infoLines = ["暂无保存的账号", "", "请先登录账号。"];
|
|
1356
|
+
this.render();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// 构建账号列表
|
|
1360
|
+
const items = accounts.map(account => ({
|
|
1361
|
+
title: account.displayName || account.username || account.account,
|
|
1362
|
+
meta: `account:${account.account}`,
|
|
1363
|
+
detail: `${account.account === currentAccount ? "✓ 当前" : "切换到此账号"}${account.userId ? ` · ID: ${account.userId}` : ""}`
|
|
1364
|
+
}));
|
|
1365
|
+
this.snapshotParent();
|
|
1366
|
+
this.state.viewTitle = "切换账号";
|
|
1367
|
+
this.state.items = items;
|
|
1368
|
+
this.state.stats = [{ title: "账号数", detail: `${accounts.length}` }];
|
|
1369
|
+
this.state.itemIndex = accounts.findIndex(a => a.account === currentAccount);
|
|
1370
|
+
this.state.scroll = 0;
|
|
1371
|
+
this.state.focus = "content";
|
|
1372
|
+
this.state.mode = "list";
|
|
1373
|
+
this.state.status = "选择账号: j/k 选择 Enter 切换 h 返回";
|
|
1374
|
+
this.render();
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
this.state.status = error instanceof Error ? error.message : "读取账号失败";
|
|
1378
|
+
this.render();
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
async switchAccount(accountName) {
|
|
1382
|
+
try {
|
|
1383
|
+
await this.tokenStore.useAccount(accountName);
|
|
1384
|
+
this.state.status = `已切换到账号: ${accountName}`;
|
|
1385
|
+
this.state.parentList = undefined;
|
|
1386
|
+
this.state.mode = "list";
|
|
1387
|
+
this.state.focus = "nav";
|
|
1388
|
+
await this.load(true);
|
|
1389
|
+
}
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
this.state.status = error instanceof Error ? error.message : "切换账号失败";
|
|
1392
|
+
this.render();
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
async confirmLogout() {
|
|
1396
|
+
const account = await this.tokenStore.getCurrentAccountName();
|
|
1397
|
+
const lines = [
|
|
1398
|
+
`当前账号: ${account || "未知"}`,
|
|
1399
|
+
"",
|
|
1400
|
+
"退出登录将清除所有保存的账号信息。",
|
|
1401
|
+
"清除后需要重新登录。",
|
|
1402
|
+
"",
|
|
1403
|
+
`${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`
|
|
1404
|
+
];
|
|
1405
|
+
this.state.modal = "info";
|
|
1406
|
+
this.state.infoTitle = "退出登录";
|
|
1407
|
+
this.state.infoLines = lines;
|
|
1408
|
+
this.state.confirmCallback = () => void this.performLogout();
|
|
1409
|
+
this.render();
|
|
1410
|
+
}
|
|
1411
|
+
async performLogout() {
|
|
1412
|
+
try {
|
|
1413
|
+
await this.tokenStore.clear();
|
|
1414
|
+
this.state.status = "已退出登录";
|
|
1415
|
+
this.state.parentList = undefined;
|
|
1416
|
+
this.state.mode = "list";
|
|
1417
|
+
this.state.focus = "nav";
|
|
1418
|
+
await this.load(true);
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
this.state.status = error instanceof Error ? error.message : "退出失败";
|
|
1422
|
+
this.render();
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
async openCacheManager() {
|
|
1426
|
+
try {
|
|
1427
|
+
const stats = await this.client.getCacheStats();
|
|
1428
|
+
const cacheDir = "~/.cc98-cli/cache/";
|
|
1429
|
+
const lines = [
|
|
1430
|
+
`缓存目录: ${cacheDir}`,
|
|
1431
|
+
`文件数量: ${stats.fileCacheEntries}`,
|
|
1432
|
+
"",
|
|
1433
|
+
"缓存策略:",
|
|
1434
|
+
" 版面主题: 30s",
|
|
1435
|
+
" 版面信息: 24h",
|
|
1436
|
+
" 用户信息: 5min",
|
|
1437
|
+
"",
|
|
1438
|
+
"Enter 清理缓存 Esc 返回"
|
|
1439
|
+
];
|
|
1440
|
+
this.state.modal = "info";
|
|
1441
|
+
this.state.infoTitle = "缓存管理";
|
|
1442
|
+
this.state.infoLines = lines;
|
|
1443
|
+
this.state.confirmCallback = () => void this.clearCache();
|
|
1444
|
+
this.render();
|
|
1445
|
+
}
|
|
1446
|
+
catch (error) {
|
|
1447
|
+
this.state.status = error instanceof Error ? error.message : "读取缓存信息失败";
|
|
1448
|
+
this.render();
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async clearCache() {
|
|
1452
|
+
try {
|
|
1453
|
+
await this.client.clearCache();
|
|
1454
|
+
this.state.status = "缓存已清理";
|
|
1455
|
+
await this.load(true);
|
|
1456
|
+
}
|
|
1457
|
+
catch {
|
|
1458
|
+
this.state.status = "缓存清理失败";
|
|
1459
|
+
this.render();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1069
1462
|
async openFriendUsers(type, signal) {
|
|
1070
1463
|
const ids = asArray(await this.client.getFriendIds(type, 0, 20, false, signal)).filter((id) => typeof id === "number");
|
|
1071
1464
|
const users = asArray(await this.client.getUsers(ids, false, signal));
|
|
@@ -1083,7 +1476,7 @@ export class TuiController {
|
|
|
1083
1476
|
this.state.currentChat = undefined;
|
|
1084
1477
|
this.state.topic = undefined;
|
|
1085
1478
|
this.state.mode = "list";
|
|
1086
|
-
this.state.status = `${title}: j/k 选择
|
|
1479
|
+
this.state.status = `${title}: j/k 选择 Enter 打开 h 返回`;
|
|
1087
1480
|
this.render();
|
|
1088
1481
|
}
|
|
1089
1482
|
snapshotParent() {
|
|
@@ -1114,5 +1507,51 @@ export class TuiController {
|
|
|
1114
1507
|
this.state.focus = "content";
|
|
1115
1508
|
this.state.scroll = 0;
|
|
1116
1509
|
}
|
|
1510
|
+
async checkUpdate(forceShow = false) {
|
|
1511
|
+
if (forceShow) {
|
|
1512
|
+
this.state.status = "正在检查 GitHub Release...";
|
|
1513
|
+
this.render();
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
const result = await checkForUpdate();
|
|
1517
|
+
if (!result.updateAvailable || !result.latest) {
|
|
1518
|
+
this.state.updateAvailable = undefined;
|
|
1519
|
+
if (forceShow) {
|
|
1520
|
+
this.state.status = result.message;
|
|
1521
|
+
this.render();
|
|
1522
|
+
}
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
const lastSeen = await this.settingsStore.getLastSeenVersion();
|
|
1526
|
+
const isNew = forceShow || lastSeen !== result.latest.version;
|
|
1527
|
+
this.state.updateAvailable = {
|
|
1528
|
+
version: result.latest.version,
|
|
1529
|
+
tagName: result.latest.tagName,
|
|
1530
|
+
url: result.latest.url,
|
|
1531
|
+
body: result.latest.body,
|
|
1532
|
+
isNew
|
|
1533
|
+
};
|
|
1534
|
+
if (forceShow) {
|
|
1535
|
+
this.state.status = result.message;
|
|
1536
|
+
}
|
|
1537
|
+
this.render();
|
|
1538
|
+
}
|
|
1539
|
+
catch (error) {
|
|
1540
|
+
if (forceShow) {
|
|
1541
|
+
this.state.status = error instanceof Error ? error.message : "检查更新失败";
|
|
1542
|
+
this.render();
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
dismissUpdate() {
|
|
1547
|
+
if (this.state.updateAvailable) {
|
|
1548
|
+
const version = this.state.updateAvailable.version;
|
|
1549
|
+
this.state.updateAvailable = undefined;
|
|
1550
|
+
this.render();
|
|
1551
|
+
void this.settingsStore.setLastSeenVersion(version).catch(() => {
|
|
1552
|
+
// 忽略已读状态写入失败,避免影响 TUI 操作。
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1117
1556
|
}
|
|
1118
1557
|
//# sourceMappingURL=controller.js.map
|