izteamslots 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/CONTRIBUTING.md +128 -0
- package/README.md +249 -0
- package/app.py +25 -0
- package/backend/__init__.py +3 -0
- package/backend/__main__.py +3 -0
- package/backend/account_store.py +448 -0
- package/backend/chatgpt_workspace_api.py +104 -0
- package/backend/dto.py +106 -0
- package/backend/file_logger.py +82 -0
- package/backend/jobs.py +77 -0
- package/backend/mail/__init__.py +98 -0
- package/backend/mail/base.py +86 -0
- package/backend/mail/boomlify.py +178 -0
- package/backend/mail/imap.py +221 -0
- package/backend/mail/trickads.py +121 -0
- package/backend/openai_web_auth.py +1402 -0
- package/backend/rpc_protocol.py +78 -0
- package/backend/rpc_server.py +233 -0
- package/backend/slot_orchestrator.py +400 -0
- package/backend/ui_facade.py +368 -0
- package/bin/izteamslots.sh +16 -0
- package/package.json +30 -0
- package/requirements.txt +2 -0
- package/scripts/setup.sh +82 -0
- package/ui/package.json +19 -0
- package/ui/src/main.ts +4 -0
- package/ui/src/menus/format.ts +163 -0
- package/ui/src/menus/mainMenus.ts +221 -0
- package/ui/src/menus/types.ts +75 -0
- package/ui/src/screens/MainScreen.ts +1175 -0
- package/ui/src/transport/stdioClient.ts +162 -0
- package/ui/tsconfig.json +13 -0
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
import { StyledText, bg, bold, createCliRenderer, fg, stringToStyledText, t } from "@opentui/core"
|
|
2
|
+
import * as OpenTUI from "@opentui/core"
|
|
3
|
+
import { execFileSync } from "node:child_process"
|
|
4
|
+
|
|
5
|
+
import { formatDashboard, formatMenu, formatTable } from "../menus/format"
|
|
6
|
+
import { getDashboard, getHint, getMenuOptions, getTable, parentMenu } from "../menus/mainMenus"
|
|
7
|
+
import type { AppState, MenuContext, MenuName, MenuOption } from "../menus/types"
|
|
8
|
+
import { StdioRpcClient } from "../transport/stdioClient"
|
|
9
|
+
|
|
10
|
+
const EMPTY_STATE: AppState = {
|
|
11
|
+
admins: [],
|
|
12
|
+
workers: [],
|
|
13
|
+
accounts: [],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HERO_LOGO = [
|
|
17
|
+
" izTeamSlots",
|
|
18
|
+
" Локальный центр управления слотами",
|
|
19
|
+
].join("\n")
|
|
20
|
+
|
|
21
|
+
type RpcJobResult = { job_id: string }
|
|
22
|
+
|
|
23
|
+
type PromptOption = {
|
|
24
|
+
label: string
|
|
25
|
+
value: string
|
|
26
|
+
hint?: string
|
|
27
|
+
destructive?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type PromptState = {
|
|
31
|
+
active: boolean
|
|
32
|
+
mode: "input" | "select"
|
|
33
|
+
question: string
|
|
34
|
+
hidden: boolean
|
|
35
|
+
value: string
|
|
36
|
+
options: PromptOption[]
|
|
37
|
+
selectedIndex: number
|
|
38
|
+
resolve: ((value: string | null) => void) | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type AnyRenderable = { content?: unknown }
|
|
42
|
+
|
|
43
|
+
type ScrollableTextRenderable = AnyRenderable & {
|
|
44
|
+
scrollY?: number
|
|
45
|
+
maxScrollY?: number
|
|
46
|
+
selectable?: boolean
|
|
47
|
+
getSelectedText?: () => string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setRenderableText(node: AnyRenderable, value: unknown) {
|
|
51
|
+
node.content = value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function joinStyledLines(lines: StyledText[]): StyledText {
|
|
55
|
+
const chunks: StyledText["chunks"] = []
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
chunks.push(...lines[i].chunks)
|
|
58
|
+
if (i < lines.length - 1) {
|
|
59
|
+
chunks.push(...stringToStyledText("\n").chunks)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return new StyledText(chunks)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function copyToSystemClipboard(text: string): boolean {
|
|
66
|
+
const value = text.trim()
|
|
67
|
+
if (!value) return false
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
execFileSync("pbcopy", [], { input: value })
|
|
71
|
+
return true
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatShortDate(value: string | null | undefined): string {
|
|
78
|
+
if (!value) return "не было"
|
|
79
|
+
const date = new Date(value)
|
|
80
|
+
if (Number.isNaN(date.getTime())) return value
|
|
81
|
+
return new Intl.DateTimeFormat("ru-RU", {
|
|
82
|
+
day: "2-digit",
|
|
83
|
+
month: "2-digit",
|
|
84
|
+
hour: "2-digit",
|
|
85
|
+
minute: "2-digit",
|
|
86
|
+
}).format(date)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function timeStamp(): string {
|
|
90
|
+
return new Intl.DateTimeFormat("ru-RU", {
|
|
91
|
+
hour: "2-digit",
|
|
92
|
+
minute: "2-digit",
|
|
93
|
+
second: "2-digit",
|
|
94
|
+
}).format(new Date())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function boolLabel(value: boolean): string {
|
|
98
|
+
return value ? "есть" : "нет"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function statusLabel(value: string | null | undefined): string {
|
|
102
|
+
if (!value) return "неизвестно"
|
|
103
|
+
if (value === "created") return "создан"
|
|
104
|
+
if (value === "invited") return "приглашён"
|
|
105
|
+
if (value === "registered") return "зарегистрирован"
|
|
106
|
+
if (value === "logged_in") return "в работе"
|
|
107
|
+
return value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasFlag(row: unknown, key: string): boolean {
|
|
111
|
+
if (!row || typeof row !== "object") return false
|
|
112
|
+
return Boolean((row as Record<string, unknown>)[key])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getMenuOptionDescription(option: MenuOption | undefined): string {
|
|
116
|
+
const extra = option as MenuOption & { description?: string }
|
|
117
|
+
if (extra?.description) return extra.description
|
|
118
|
+
const optionId = option?.id ?? ""
|
|
119
|
+
|
|
120
|
+
if (optionId === "menu_admins") return "Добавление, логин и обслуживание администраторов."
|
|
121
|
+
if (optionId === "menu_slots") return "Создание слотов, перелогин и работа с профилями."
|
|
122
|
+
if (optionId === "menu_mail") return "Проверка входящих писем админов и слотов."
|
|
123
|
+
if (optionId === "menu_exit") return "Аккуратно завершить интерфейс."
|
|
124
|
+
if (optionId === "adm_add") return "Создать нового админа: в авто-режиме ввод в TUI, в ручном режиме вход сразу в браузере."
|
|
125
|
+
if (optionId === "adm_relogin") return "Выбрать админа и режим перелогина: автоматический или ручной."
|
|
126
|
+
if (optionId === "adm_open") return "Открыть локальный профиль браузера администратора."
|
|
127
|
+
if (optionId === "adm_delete") return "Удалить админа и отвязать связанные слоты."
|
|
128
|
+
if (optionId === "slots_create") return "Запустить полный пайплайн создания новых слотов."
|
|
129
|
+
if (optionId === "slots_relogin") return "Выбрать режим перелогина: один конкретный слот или все доступные сразу."
|
|
130
|
+
if (optionId === "slots_open") return "Открыть профиль браузера выбранного слота."
|
|
131
|
+
if (optionId === "slots_delete") return "Удалить слот и его локальные файлы."
|
|
132
|
+
if (optionId.startsWith("mail_pick:")) return "Открыть входящие письма этого аккаунта."
|
|
133
|
+
if (optionId.startsWith("pick_admin:")) return "Выбрать админа для следующего действия."
|
|
134
|
+
if (optionId.startsWith("pick_worker:")) return "Выбрать слот для следующего действия."
|
|
135
|
+
if (optionId === "confirm_yes") return "Подтвердить действие без возможности быстрого отката."
|
|
136
|
+
if (optionId === "confirm_no") return "Вернуться без изменений."
|
|
137
|
+
|
|
138
|
+
return "Действие доступно через Enter."
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class MainScreen {
|
|
142
|
+
private readonly rpc = new StdioRpcClient()
|
|
143
|
+
|
|
144
|
+
private state: AppState = EMPTY_STATE
|
|
145
|
+
private backendOnline = false
|
|
146
|
+
private connectionError = ""
|
|
147
|
+
private menuName: MenuName = "main"
|
|
148
|
+
private menuCtx: MenuContext = {}
|
|
149
|
+
private menuOptions: MenuOption[] = []
|
|
150
|
+
private selectedIndex = 0
|
|
151
|
+
private busy = false
|
|
152
|
+
private busyTitle = ""
|
|
153
|
+
|
|
154
|
+
private logs: string[] = []
|
|
155
|
+
|
|
156
|
+
private prompt: PromptState = {
|
|
157
|
+
active: false,
|
|
158
|
+
mode: "input",
|
|
159
|
+
question: "",
|
|
160
|
+
hidden: false,
|
|
161
|
+
value: "",
|
|
162
|
+
options: [],
|
|
163
|
+
selectedIndex: 0,
|
|
164
|
+
resolve: null,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private renderer!: Awaited<ReturnType<typeof createCliRenderer>>
|
|
168
|
+
private summaryBox!: OpenTUI.BoxRenderable
|
|
169
|
+
private primaryBox!: OpenTUI.BoxRenderable
|
|
170
|
+
private detailBox!: OpenTUI.BoxRenderable
|
|
171
|
+
private menuBox!: OpenTUI.BoxRenderable
|
|
172
|
+
|
|
173
|
+
private summaryText!: AnyRenderable
|
|
174
|
+
private primaryText!: AnyRenderable
|
|
175
|
+
private detailText!: ScrollableTextRenderable
|
|
176
|
+
private menuText!: AnyRenderable
|
|
177
|
+
private statusText!: AnyRenderable
|
|
178
|
+
|
|
179
|
+
async start() {
|
|
180
|
+
this.renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
181
|
+
this.buildUI()
|
|
182
|
+
|
|
183
|
+
this.rpc.onEvent((evt) => {
|
|
184
|
+
this.backendOnline = true
|
|
185
|
+
if (evt.event === "job.started") {
|
|
186
|
+
this.busy = true
|
|
187
|
+
this.busyTitle = String(evt.data.title ?? "")
|
|
188
|
+
this.pushLog(`Запуск: ${this.busyTitle || "операция"}`)
|
|
189
|
+
if (evt.data.log_path) {
|
|
190
|
+
this.pushLog(`Лог-файл: ${String(evt.data.log_path)}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (evt.event === "job.log") {
|
|
194
|
+
this.pushLog(String(evt.data.message ?? ""))
|
|
195
|
+
}
|
|
196
|
+
if (evt.event === "job.progress") {
|
|
197
|
+
const current = Number(evt.data.current ?? 0)
|
|
198
|
+
const total = Number(evt.data.total ?? 0)
|
|
199
|
+
const message = String(evt.data.message ?? "")
|
|
200
|
+
this.busyTitle = total > 0 ? `${current}/${total} ${message}`.trim() : message || "Выполняется"
|
|
201
|
+
}
|
|
202
|
+
if (evt.event === "job.done") {
|
|
203
|
+
this.busy = false
|
|
204
|
+
this.busyTitle = ""
|
|
205
|
+
this.pushLog("Операция завершена")
|
|
206
|
+
void this.refreshState()
|
|
207
|
+
}
|
|
208
|
+
if (evt.event === "job.error") {
|
|
209
|
+
this.busy = false
|
|
210
|
+
this.busyTitle = ""
|
|
211
|
+
this.pushLog(`Ошибка: ${String(evt.data.error ?? "unknown")}`)
|
|
212
|
+
if (evt.data.log_path) {
|
|
213
|
+
this.pushLog(`Подробности в файле: ${String(evt.data.log_path)}`)
|
|
214
|
+
}
|
|
215
|
+
void this.refreshState()
|
|
216
|
+
}
|
|
217
|
+
this.render()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
this.renderer.keyInput.on("keypress", (key: any) => {
|
|
221
|
+
void this.onKey(key).catch((error) => {
|
|
222
|
+
this.pushLog(`Ошибка: ${String(error)}`)
|
|
223
|
+
this.render()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
this.renderer.keyInput.on("paste", (event: any) => {
|
|
228
|
+
this.onPaste(event)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
this.rpc.start()
|
|
232
|
+
await this.refreshState(false)
|
|
233
|
+
if (this.backendOnline) {
|
|
234
|
+
this.pushLog("Интерфейс готов к работе")
|
|
235
|
+
}
|
|
236
|
+
this.render()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private buildUI() {
|
|
240
|
+
const root = new OpenTUI.BoxRenderable(this.renderer, {
|
|
241
|
+
id: "root",
|
|
242
|
+
width: "100%",
|
|
243
|
+
height: "100%",
|
|
244
|
+
flexDirection: "column",
|
|
245
|
+
justifyContent: "space-between",
|
|
246
|
+
padding: 0,
|
|
247
|
+
backgroundColor: "#020a2b",
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
this.summaryBox = new OpenTUI.BoxRenderable(this.renderer, {
|
|
251
|
+
id: "summary_box",
|
|
252
|
+
title: "izTeamSlots",
|
|
253
|
+
border: true,
|
|
254
|
+
borderColor: "#334155",
|
|
255
|
+
height: 7,
|
|
256
|
+
width: "100%",
|
|
257
|
+
backgroundColor: "#020a2b",
|
|
258
|
+
paddingLeft: 1,
|
|
259
|
+
paddingRight: 1,
|
|
260
|
+
flexShrink: 0,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
this.primaryBox = new OpenTUI.BoxRenderable(this.renderer, {
|
|
264
|
+
id: "primary_box",
|
|
265
|
+
title: "Быстрый старт",
|
|
266
|
+
border: true,
|
|
267
|
+
borderColor: "#334155",
|
|
268
|
+
flexGrow: 2,
|
|
269
|
+
width: "100%",
|
|
270
|
+
backgroundColor: "#08111f",
|
|
271
|
+
paddingLeft: 1,
|
|
272
|
+
paddingRight: 1,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
this.detailBox = new OpenTUI.BoxRenderable(this.renderer, {
|
|
276
|
+
id: "detail_box",
|
|
277
|
+
title: "События и контекст",
|
|
278
|
+
border: true,
|
|
279
|
+
borderColor: "#334155",
|
|
280
|
+
height: 9,
|
|
281
|
+
flexGrow: 3,
|
|
282
|
+
width: "100%",
|
|
283
|
+
backgroundColor: "#020617",
|
|
284
|
+
paddingLeft: 1,
|
|
285
|
+
paddingRight: 1,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
this.menuBox = new OpenTUI.BoxRenderable(this.renderer, {
|
|
289
|
+
id: "menu_box",
|
|
290
|
+
title: "Действия",
|
|
291
|
+
border: true,
|
|
292
|
+
borderColor: "#334155",
|
|
293
|
+
height: 6,
|
|
294
|
+
width: "100%",
|
|
295
|
+
backgroundColor: "#08111f",
|
|
296
|
+
paddingLeft: 1,
|
|
297
|
+
paddingRight: 1,
|
|
298
|
+
flexShrink: 0,
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const statusBox = new OpenTUI.BoxRenderable(this.renderer, {
|
|
302
|
+
id: "status_box",
|
|
303
|
+
border: false,
|
|
304
|
+
backgroundColor: "#1f2937",
|
|
305
|
+
height: 1,
|
|
306
|
+
width: "100%",
|
|
307
|
+
padding: 0,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
this.summaryText = new OpenTUI.TextRenderable(this.renderer, {
|
|
311
|
+
id: "summary_text",
|
|
312
|
+
content: "",
|
|
313
|
+
})
|
|
314
|
+
this.primaryText = new OpenTUI.TextRenderable(this.renderer, {
|
|
315
|
+
id: "primary_text",
|
|
316
|
+
content: "",
|
|
317
|
+
})
|
|
318
|
+
this.detailText = new OpenTUI.TextRenderable(this.renderer, {
|
|
319
|
+
id: "detail_text",
|
|
320
|
+
content: "",
|
|
321
|
+
selectable: true,
|
|
322
|
+
selectionBg: "#1d4ed8",
|
|
323
|
+
selectionFg: "#eff6ff",
|
|
324
|
+
}) as ScrollableTextRenderable
|
|
325
|
+
this.menuText = new OpenTUI.TextRenderable(this.renderer, {
|
|
326
|
+
id: "menu_text",
|
|
327
|
+
content: "",
|
|
328
|
+
})
|
|
329
|
+
this.statusText = new OpenTUI.TextRenderable(this.renderer, {
|
|
330
|
+
id: "status_text",
|
|
331
|
+
content: "",
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
this.summaryBox.add(this.summaryText)
|
|
335
|
+
this.primaryBox.add(this.primaryText)
|
|
336
|
+
this.detailBox.add(this.detailText)
|
|
337
|
+
this.menuBox.add(this.menuText)
|
|
338
|
+
statusBox.add(this.statusText)
|
|
339
|
+
|
|
340
|
+
root.add(this.summaryBox)
|
|
341
|
+
root.add(this.primaryBox)
|
|
342
|
+
root.add(this.detailBox)
|
|
343
|
+
root.add(this.menuBox)
|
|
344
|
+
root.add(statusBox)
|
|
345
|
+
|
|
346
|
+
this.renderer.root.add(root)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async refreshState(logErrors = true) {
|
|
350
|
+
try {
|
|
351
|
+
const state = (await this.rpc.request<AppState>("state.get")) ?? EMPTY_STATE
|
|
352
|
+
this.state = state
|
|
353
|
+
this.backendOnline = true
|
|
354
|
+
this.connectionError = ""
|
|
355
|
+
} catch (error) {
|
|
356
|
+
this.state = EMPTY_STATE
|
|
357
|
+
this.backendOnline = false
|
|
358
|
+
this.connectionError = String(error)
|
|
359
|
+
if (logErrors) {
|
|
360
|
+
this.pushLog(`Не удалось подключиться к backend: ${this.getShortConnectionError()}`)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.rebuildMenuOptions()
|
|
365
|
+
this.render()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private rebuildMenuOptions() {
|
|
369
|
+
this.menuOptions = getMenuOptions(this.menuName, this.state)
|
|
370
|
+
if (this.selectedIndex >= this.menuOptions.length) {
|
|
371
|
+
this.selectedIndex = Math.max(0, this.menuOptions.length - 1)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private setStatus() {
|
|
376
|
+
if (this.prompt.active) {
|
|
377
|
+
if (this.prompt.mode === "select") {
|
|
378
|
+
const option = this.prompt.options[this.prompt.selectedIndex]
|
|
379
|
+
const label = option ? option.label : "нет вариантов"
|
|
380
|
+
const hint = option?.hint ? ` • ${option.hint}` : ""
|
|
381
|
+
setRenderableText(
|
|
382
|
+
this.statusText,
|
|
383
|
+
t`${bg("#111827")(fg("#e5e7eb")(bold(` ${this.prompt.question}: ${label}${hint} `)))}`,
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
const typed = this.prompt.hidden ? "*".repeat(this.prompt.value.length) : this.prompt.value
|
|
388
|
+
const value = typed || "_"
|
|
389
|
+
setRenderableText(
|
|
390
|
+
this.statusText,
|
|
391
|
+
t`${bg("#111827")(fg("#e5e7eb")(bold(` ${this.prompt.question}: ${value} `)))}`,
|
|
392
|
+
)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const hint = getHint(this.menuName, this.menuCtx)
|
|
397
|
+
const backendSeg = this.backendOnline
|
|
398
|
+
? bg("#14532d")(fg("#dcfce7")(" backend: online "))
|
|
399
|
+
: bg("#7f1d1d")(fg("#fee2e2")(" backend: offline "))
|
|
400
|
+
const hintSeg = bg("#334155")(fg("#e2e8f0")(` ${hint} `))
|
|
401
|
+
|
|
402
|
+
if (this.busy) {
|
|
403
|
+
const title = this.busyTitle || "Выполняется..."
|
|
404
|
+
const busySeg = bg("#7c2d12")(fg("#ffedd5")(bold(` ● ${title} `)))
|
|
405
|
+
setRenderableText(this.statusText, t`${busySeg}${hintSeg}${backendSeg}`)
|
|
406
|
+
} else {
|
|
407
|
+
const readySeg = bg("#1e293b")(fg("#e2e8f0")(" ○ Готово "))
|
|
408
|
+
setRenderableText(this.statusText, t`${hintSeg}${readySeg}${backendSeg}`)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private pushLog(message: string) {
|
|
413
|
+
this.logs.push(`[${timeStamp()}] ${message}`)
|
|
414
|
+
this.scrollDetailToEnd()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private scrollDetailToEnd() {
|
|
418
|
+
if (typeof this.detailText.maxScrollY === "number") {
|
|
419
|
+
this.detailText.scrollY = this.detailText.maxScrollY
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private render() {
|
|
424
|
+
this.rebuildMenuOptions()
|
|
425
|
+
const table = getTable(this.menuName, this.state, this.menuCtx)
|
|
426
|
+
const promptMenuOptions: MenuOption[] = this.prompt.mode === "select"
|
|
427
|
+
? this.prompt.options.map((option) => ({
|
|
428
|
+
id: option.value,
|
|
429
|
+
label: option.label,
|
|
430
|
+
hint: option.hint,
|
|
431
|
+
destructive: option.destructive,
|
|
432
|
+
}))
|
|
433
|
+
: []
|
|
434
|
+
const visibleMenuOptions = this.prompt.active && this.prompt.mode === "select"
|
|
435
|
+
? promptMenuOptions
|
|
436
|
+
: this.menuOptions
|
|
437
|
+
const visibleMenuIndex = this.prompt.active && this.prompt.mode === "select"
|
|
438
|
+
? this.prompt.selectedIndex
|
|
439
|
+
: this.selectedIndex
|
|
440
|
+
|
|
441
|
+
this.summaryBox.title = this.menuName === "main" ? "izTeamSlots" : `Раздел: ${this.getScreenTitle()}`
|
|
442
|
+
this.primaryBox.title = this.menuName === "main" ? "Быстрый старт" : this.getPrimaryTitle()
|
|
443
|
+
this.detailBox.title = this.prompt.active && this.prompt.mode === "select"
|
|
444
|
+
? "Выбор"
|
|
445
|
+
: this.menuName === "main" ? "События и подсказки" : "Контекст"
|
|
446
|
+
this.menuBox.title = this.prompt.active && this.prompt.mode === "select" ? "Варианты" : "Действия"
|
|
447
|
+
this.menuBox.height = Math.max(6, Math.min(10, visibleMenuOptions.length + 2))
|
|
448
|
+
|
|
449
|
+
setRenderableText(this.summaryText, this.buildSummaryPanel())
|
|
450
|
+
|
|
451
|
+
if (this.menuName === "main") {
|
|
452
|
+
setRenderableText(this.primaryText, this.buildMainPanel())
|
|
453
|
+
} else if (table.rows.length > 0) {
|
|
454
|
+
setRenderableText(
|
|
455
|
+
this.primaryText,
|
|
456
|
+
formatTable(table.headers, table.rows, Math.max(72, process.stdout.columns || 120)),
|
|
457
|
+
)
|
|
458
|
+
} else {
|
|
459
|
+
setRenderableText(this.primaryText, this.buildEmptyStatePanel())
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
setRenderableText(this.detailText, this.buildDetailPanel())
|
|
463
|
+
setRenderableText(
|
|
464
|
+
this.menuText,
|
|
465
|
+
formatMenu(visibleMenuOptions, visibleMenuIndex, Math.max(48, (process.stdout.columns || 120) - 6)),
|
|
466
|
+
)
|
|
467
|
+
this.setStatus()
|
|
468
|
+
this.renderer.requestRender()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private move(delta: number) {
|
|
472
|
+
if (this.menuOptions.length === 0) return
|
|
473
|
+
const last = this.menuOptions.length - 1
|
|
474
|
+
if (delta > 0) {
|
|
475
|
+
this.selectedIndex = this.selectedIndex >= last ? 0 : this.selectedIndex + 1
|
|
476
|
+
} else {
|
|
477
|
+
this.selectedIndex = this.selectedIndex <= 0 ? last : this.selectedIndex - 1
|
|
478
|
+
}
|
|
479
|
+
this.render()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private getScreenTitle(): string {
|
|
483
|
+
if (this.menuName === "admins") return "Админы"
|
|
484
|
+
if (this.menuName === "slots") return "Слоты"
|
|
485
|
+
if (this.menuName === "mail") return "Почта"
|
|
486
|
+
if (this.menuName === "pick_admin") return "Выбор админа"
|
|
487
|
+
if (this.menuName === "pick_worker") return "Выбор слота"
|
|
488
|
+
if (this.menuName === "pick_account") return "Выбор аккаунта"
|
|
489
|
+
if (this.menuName === "confirm") return "Подтверждение"
|
|
490
|
+
return "Обзор"
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private getPrimaryTitle(): string {
|
|
494
|
+
if (this.menuCtx.title) return this.menuCtx.title
|
|
495
|
+
return this.getScreenTitle()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private buildSummaryPanel(): StyledText {
|
|
499
|
+
const connectionLine = this.backendOnline
|
|
500
|
+
? t`${fg("#4ade80")("Подключение активно")}`
|
|
501
|
+
: t`${fg("#fca5a5")("Подключение потеряно")}`
|
|
502
|
+
const contextLine = this.backendOnline
|
|
503
|
+
? t`${fg("#94a3b8")("Экран")} ${fg("#e2e8f0")(this.getScreenTitle())}`
|
|
504
|
+
: t`${fg("#94a3b8")("Причина")} ${fg("#fca5a5")(this.getShortConnectionError())}`
|
|
505
|
+
|
|
506
|
+
return joinStyledLines([
|
|
507
|
+
t`${fg("#f8fafc")(bold(HERO_LOGO))}`,
|
|
508
|
+
contextLine,
|
|
509
|
+
connectionLine,
|
|
510
|
+
formatDashboard(getDashboard(this.state)),
|
|
511
|
+
])
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private buildMainPanel(): StyledText {
|
|
515
|
+
if (!this.backendOnline) {
|
|
516
|
+
return this.buildRecoveryPanel()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const adminsReady = this.state.admins.filter((admin) => hasFlag(admin, "has_access_token")).length
|
|
520
|
+
const workersReady = this.state.workers.filter((worker) => hasFlag(worker, "has_access_token")).length
|
|
521
|
+
const lines: StyledText[] = [
|
|
522
|
+
t`${fg("#f8fafc")(bold("Что делать дальше"))}`,
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
for (const step of this.getRecommendedSteps()) {
|
|
526
|
+
lines.push(t`${fg("#94a3b8")("•")} ${fg("#e2e8f0")(step)}`)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
lines.push(t`${fg("#475569")(" ")}`)
|
|
530
|
+
lines.push(
|
|
531
|
+
t`${fg("#94a3b8")("Готовность")} ${fg("#e2e8f0")(`${adminsReady}/${this.state.admins.length || 0} админов, ${workersReady}/${this.state.workers.length || 0} слотов`)}`
|
|
532
|
+
)
|
|
533
|
+
lines.push(
|
|
534
|
+
t`${fg("#94a3b8")("Подсказка")} ${fg("#cbd5e1")("Стрелки и цифры работают и в меню, и в режимах выбора. Enter подтверждает, r обновляет состояние.")}`
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
return joinStyledLines(lines)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private buildRecoveryPanel(): StyledText {
|
|
541
|
+
const lines: StyledText[] = [
|
|
542
|
+
t`${fg("#f8fafc")(bold("Backend недоступен"))}`,
|
|
543
|
+
t`${fg("#fca5a5")(this.getShortConnectionError())}`,
|
|
544
|
+
t`${fg("#94a3b8")("Проверьте окружение и повторите обновление клавишей r.")}`,
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
const error = this.connectionError.toLowerCase()
|
|
548
|
+
if (error.includes("no module named 'requests'")) {
|
|
549
|
+
lines.push(t`${fg("#93c5fd")("Подсказка: установите Python-пакет requests.")}`)
|
|
550
|
+
} else if (error.includes("no module named 'seleniumbase'")) {
|
|
551
|
+
lines.push(t`${fg("#93c5fd")("Подсказка: установите seleniumbase.")}`)
|
|
552
|
+
} else {
|
|
553
|
+
lines.push(t`${fg("#93c5fd")("Подсказка: проверьте python-зависимости и запуск backend модуля.")}`)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return joinStyledLines(lines)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private getRecommendedSteps(): string[] {
|
|
560
|
+
if (this.state.admins.length === 0) {
|
|
561
|
+
return [
|
|
562
|
+
"Добавьте первого админа через раздел «Админы».",
|
|
563
|
+
"После добавления выполните логин, чтобы сохранить токен и профиль браузера.",
|
|
564
|
+
"Затем создайте слоты и проверьте входящие письма.",
|
|
565
|
+
]
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const adminsWithoutLogin = this.state.admins.filter((admin) => !hasFlag(admin, "has_access_token")).length
|
|
569
|
+
if (adminsWithoutLogin > 0) {
|
|
570
|
+
return [
|
|
571
|
+
"Перелогиньте хотя бы одного админа.",
|
|
572
|
+
"Убедитесь, что у него есть токен и рабочий браузерный профиль.",
|
|
573
|
+
"После этого можно запускать создание слотов.",
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (this.state.workers.length === 0) {
|
|
578
|
+
return [
|
|
579
|
+
"Откройте раздел «Слоты».",
|
|
580
|
+
"Запустите создание слотов под нужным админом.",
|
|
581
|
+
"После завершения проверьте почту и недавние события.",
|
|
582
|
+
]
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const workersWithoutPassword = this.state.workers.filter((worker) => !worker.has_openai_password).length
|
|
586
|
+
if (workersWithoutPassword > 0) {
|
|
587
|
+
return [
|
|
588
|
+
"Часть слотов ещё не готова к перелогину.",
|
|
589
|
+
"Проверьте, что для нужных слотов сохранён OpenAI-пароль.",
|
|
590
|
+
"Используйте раздел «Почта», чтобы контролировать инвайты и письма.",
|
|
591
|
+
]
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return [
|
|
595
|
+
"Система выглядит рабочей.",
|
|
596
|
+
"Для обслуживания слотов используйте разделы «Слоты» и «Почта».",
|
|
597
|
+
"Последние события и ошибки всегда видны в нижней панели.",
|
|
598
|
+
]
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private buildDetailPanel(): StyledText {
|
|
602
|
+
if (!this.backendOnline) {
|
|
603
|
+
return this.buildRecoveryPanel()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (this.prompt.active && this.prompt.mode === "select") {
|
|
607
|
+
const option = this.prompt.options[this.prompt.selectedIndex]
|
|
608
|
+
const lines: StyledText[] = [
|
|
609
|
+
t`${fg("#f8fafc")(bold(this.prompt.question))}`,
|
|
610
|
+
]
|
|
611
|
+
if (option) {
|
|
612
|
+
lines.push(t`${fg("#93c5fd")("•")} ${fg("#e2e8f0")(option.label)}`)
|
|
613
|
+
if (option.hint) {
|
|
614
|
+
lines.push(t`${fg("#94a3b8")(option.hint)}`)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
lines.push(t`${fg("#64748b")("Enter выбрать • Esc отмена • Стрелки перемещают курсор")}`)
|
|
618
|
+
return joinStyledLines(lines)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const lines: StyledText[] = []
|
|
622
|
+
const selected = this.menuOptions[this.selectedIndex]
|
|
623
|
+
const extra = selected as MenuOption & { badge?: string }
|
|
624
|
+
|
|
625
|
+
if (selected) {
|
|
626
|
+
const badge = extra?.badge ? ` • ${extra.badge}` : ""
|
|
627
|
+
lines.push(t`${fg("#f8fafc")(bold(`${selected.label}${badge}`))}`)
|
|
628
|
+
lines.push(t`${fg("#94a3b8")(getMenuOptionDescription(selected))}`)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const note of this.getContextNotes()) {
|
|
632
|
+
lines.push(t`${fg("#93c5fd")("•")} ${fg("#cbd5e1")(note)}`)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const recentLogs = this.logs.slice(-15)
|
|
636
|
+
if (recentLogs.length > 0) {
|
|
637
|
+
lines.push(t`${fg("#475569")(" ")}`)
|
|
638
|
+
lines.push(t`${fg("#f8fafc")(bold("Последние события"))}`)
|
|
639
|
+
for (const log of recentLogs) {
|
|
640
|
+
lines.push(t`${fg("#94a3b8")(log)}`)
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
lines.push(t`${fg("#64748b")("Событий пока нет.")}`)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return joinStyledLines(lines)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private buildEmptyStatePanel(): StyledText {
|
|
650
|
+
const lines: StyledText[] = [t`${fg("#f8fafc")(bold("Пока пусто"))}`]
|
|
651
|
+
|
|
652
|
+
if (this.menuName === "admins") {
|
|
653
|
+
lines.push(t`${fg("#cbd5e1")("Сначала добавьте первого админа, затем выполните логин.")}`)
|
|
654
|
+
} else if (this.menuName === "slots") {
|
|
655
|
+
lines.push(t`${fg("#cbd5e1")("Слоты появятся после запуска пайплайна от выбранного админа.")}`)
|
|
656
|
+
} else if (this.menuName === "mail") {
|
|
657
|
+
lines.push(t`${fg("#cbd5e1")("Почтовые аккаунты появятся после добавления админа или создания слотов.")}`)
|
|
658
|
+
} else if (this.menuName === "pick_admin" || this.menuName === "pick_worker") {
|
|
659
|
+
lines.push(t`${fg("#cbd5e1")("Нет доступных элементов для выбора. Вернитесь назад и подготовьте данные.")}`)
|
|
660
|
+
} else {
|
|
661
|
+
lines.push(t`${fg("#cbd5e1")("Добавьте данные или вернитесь на главный экран.")}`)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return joinStyledLines(lines)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private getContextNotes(): string[] {
|
|
668
|
+
if (this.menuName === "admins") {
|
|
669
|
+
const ready = this.state.admins.filter((admin) => hasFlag(admin, "has_access_token")).length
|
|
670
|
+
return [
|
|
671
|
+
`Всего админов: ${this.state.admins.length}. Готовы к работе: ${ready}.`,
|
|
672
|
+
"Для создания слотов нужен админ с токеном и рабочим браузерным профилем.",
|
|
673
|
+
]
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (this.menuName === "slots") {
|
|
677
|
+
const withPassword = this.state.workers.filter((worker) => worker.has_openai_password).length
|
|
678
|
+
return [
|
|
679
|
+
`Всего слотов: ${this.state.workers.length}. С OpenAI-паролем: ${withPassword}.`,
|
|
680
|
+
"Массовый перелогин работает только для слотов с сохранённым паролем.",
|
|
681
|
+
]
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (this.menuName === "mail") {
|
|
685
|
+
const notes = [
|
|
686
|
+
`Доступно почтовых аккаунтов: ${this.state.accounts.length}.`,
|
|
687
|
+
"Выберите аккаунт, чтобы подтянуть последние письма в лог.",
|
|
688
|
+
]
|
|
689
|
+
const selectedId = this.menuOptions[this.selectedIndex]?.id ?? ""
|
|
690
|
+
if (selectedId.startsWith("mail_pick:")) {
|
|
691
|
+
const index = Number(selectedId.split(":", 2)[1])
|
|
692
|
+
const account = this.state.accounts[index]
|
|
693
|
+
if (account) {
|
|
694
|
+
notes.push(`Текущий выбор: ${account.email} (${account.kind}).`)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return notes
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (this.menuName === "pick_admin") {
|
|
701
|
+
const option = this.menuOptions[this.selectedIndex]
|
|
702
|
+
const email = option?.id.split(":", 2)[1]
|
|
703
|
+
const admin = this.state.admins.find((item) => item.email === email)
|
|
704
|
+
if (!admin) return ["Админ не найден в текущем состоянии."]
|
|
705
|
+
|
|
706
|
+
return [
|
|
707
|
+
`${admin.email}`,
|
|
708
|
+
`Токен: ${boolLabel(admin.has_access_token)} • Профиль: ${boolLabel(hasFlag(admin, "has_browser_profile"))}`,
|
|
709
|
+
`Последний логин: ${formatShortDate(admin.last_login)}`,
|
|
710
|
+
]
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (this.menuName === "pick_worker") {
|
|
714
|
+
const option = this.menuOptions[this.selectedIndex]
|
|
715
|
+
const email = option?.id.split(":", 2)[1]
|
|
716
|
+
const worker = this.state.workers.find((item) => item.email === email)
|
|
717
|
+
if (!worker) return ["Слот не найден в текущем состоянии."]
|
|
718
|
+
|
|
719
|
+
return [
|
|
720
|
+
`${worker.email}`,
|
|
721
|
+
`Статус: ${statusLabel(worker.status)} • Профиль: ${boolLabel(hasFlag(worker, "has_browser_profile"))}`,
|
|
722
|
+
`Админ: ${worker.admin_email ?? "не назначен"} • OpenAI-пароль: ${boolLabel(worker.has_openai_password)}`,
|
|
723
|
+
]
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (this.menuName === "confirm") {
|
|
727
|
+
return [
|
|
728
|
+
`Действие: ${this.menuCtx.confirm_action ?? "подтверждение"}`,
|
|
729
|
+
`Объект: ${this.menuCtx.target ?? "не выбран"}`,
|
|
730
|
+
]
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return []
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private getShortConnectionError(): string {
|
|
737
|
+
const message = this.connectionError || "ошибка подключения"
|
|
738
|
+
if (message.includes("RPC process exited before response")) {
|
|
739
|
+
return "backend завершился во время запуска или не смог ответить"
|
|
740
|
+
}
|
|
741
|
+
if (message.includes("RPC spawn error")) {
|
|
742
|
+
return "не удалось запустить backend-процесс"
|
|
743
|
+
}
|
|
744
|
+
return message.length > 120 ? `${message.slice(0, 119)}…` : message
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private async goBack() {
|
|
748
|
+
if (this.busy || this.prompt.active) return
|
|
749
|
+
if (this.menuName === "main") return
|
|
750
|
+
this.menuName = parentMenu(this.menuName, this.menuCtx)
|
|
751
|
+
this.menuCtx = {}
|
|
752
|
+
this.selectedIndex = 0
|
|
753
|
+
await this.refreshState()
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private async promptInput(question: string, hidden = false): Promise<string | null> {
|
|
757
|
+
this.prompt.active = true
|
|
758
|
+
this.prompt.mode = "input"
|
|
759
|
+
this.prompt.question = question
|
|
760
|
+
this.prompt.hidden = hidden
|
|
761
|
+
this.prompt.value = ""
|
|
762
|
+
this.prompt.options = []
|
|
763
|
+
this.prompt.selectedIndex = 0
|
|
764
|
+
this.render()
|
|
765
|
+
|
|
766
|
+
return await new Promise((resolve) => {
|
|
767
|
+
this.prompt.resolve = resolve
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private async promptSelect(question: string, options: PromptOption[]): Promise<string | null> {
|
|
772
|
+
if (options.length === 0) return null
|
|
773
|
+
this.prompt.active = true
|
|
774
|
+
this.prompt.mode = "select"
|
|
775
|
+
this.prompt.question = question
|
|
776
|
+
this.prompt.hidden = false
|
|
777
|
+
this.prompt.value = ""
|
|
778
|
+
this.prompt.options = options
|
|
779
|
+
this.prompt.selectedIndex = 0
|
|
780
|
+
this.render()
|
|
781
|
+
|
|
782
|
+
return await new Promise((resolve) => {
|
|
783
|
+
this.prompt.resolve = resolve
|
|
784
|
+
})
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private finishPrompt(value: string | null) {
|
|
788
|
+
const resolve = this.prompt.resolve
|
|
789
|
+
this.prompt.active = false
|
|
790
|
+
this.prompt.mode = "input"
|
|
791
|
+
this.prompt.question = ""
|
|
792
|
+
this.prompt.hidden = false
|
|
793
|
+
this.prompt.value = ""
|
|
794
|
+
this.prompt.options = []
|
|
795
|
+
this.prompt.selectedIndex = 0
|
|
796
|
+
this.prompt.resolve = null
|
|
797
|
+
resolve?.(value)
|
|
798
|
+
this.render()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async promptLoginMode(): Promise<"auto" | "manual" | null> {
|
|
802
|
+
const value = await this.promptSelect("Режим входа", [
|
|
803
|
+
{ value: "auto", label: "Авто", hint: "Email и пароль вводятся в TUI, дальше логин идёт автоматически." },
|
|
804
|
+
{ value: "manual", label: "Вручную", hint: "Браузер откроется сразу, вход и код подтверждения вы вводите сами." },
|
|
805
|
+
])
|
|
806
|
+
if (value === "auto" || value === "manual") return value
|
|
807
|
+
return null
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private async startAdminLogin(email: string, mode: "auto" | "manual") {
|
|
811
|
+
const method = mode === "manual" ? "job.login_admin_manual" : "job.login_admin"
|
|
812
|
+
await this.startJob(method, { email })
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async promptSlotReloginScope(): Promise<"one" | "all" | null> {
|
|
816
|
+
const value = await this.promptSelect("Перелогин слотов", [
|
|
817
|
+
{ value: "one", label: "Один слот", hint: "Сначала выберете конкретный слот, потом запустится перелогин." },
|
|
818
|
+
{ value: "all", label: "Все слоты", hint: "Запустить перелогин для всех слотов с сохранённым паролем." },
|
|
819
|
+
])
|
|
820
|
+
if (value === "one" || value === "all") return value
|
|
821
|
+
return null
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private async submitOption(optionId: string) {
|
|
825
|
+
if (this.menuName === "main") {
|
|
826
|
+
if (optionId === "menu_admins") this.menuName = "admins"
|
|
827
|
+
else if (optionId === "menu_slots") this.menuName = "slots"
|
|
828
|
+
else if (optionId === "menu_mail") this.menuName = "mail"
|
|
829
|
+
else if (optionId === "menu_exit") { await this.exit(); return }
|
|
830
|
+
this.menuCtx = {}
|
|
831
|
+
this.selectedIndex = 0
|
|
832
|
+
await this.refreshState()
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (this.menuName === "admins") {
|
|
837
|
+
if (optionId === "adm_add") {
|
|
838
|
+
const loginMode = await this.promptLoginMode()
|
|
839
|
+
if (!loginMode) return
|
|
840
|
+
if (loginMode === "manual") {
|
|
841
|
+
await this.startJob("job.add_admin_manual")
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const email = await this.promptInput("Email админа")
|
|
846
|
+
if (!email) return
|
|
847
|
+
const password = await this.promptInput("Пароль почты", true)
|
|
848
|
+
if (!password) return
|
|
849
|
+
try {
|
|
850
|
+
await this.rpc.request("admin.add", { email, password })
|
|
851
|
+
} catch (error) {
|
|
852
|
+
this.pushLog(`Ошибка: ${String(error)}`)
|
|
853
|
+
this.render()
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
await this.startAdminLogin(email, loginMode)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
if (optionId === "adm_relogin") {
|
|
860
|
+
this.goToPicker("pick_admin", "admins", "relogin_admin", "Выберите админа")
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
if (optionId === "adm_open") {
|
|
864
|
+
this.goToPicker("pick_admin", "admins", "open_admin", "Открыть браузер админа")
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
if (optionId === "adm_delete") {
|
|
868
|
+
this.goToPicker("pick_admin", "admins", "delete_admin", "Удалить админа")
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (this.menuName === "slots") {
|
|
874
|
+
if (optionId === "slots_create") {
|
|
875
|
+
this.goToPicker("pick_admin", "slots", "slots_create", "Выберите админа для слотов")
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
if (optionId === "slots_relogin") {
|
|
879
|
+
const scope = await this.promptSlotReloginScope()
|
|
880
|
+
if (!scope) return
|
|
881
|
+
if (scope === "all") {
|
|
882
|
+
await this.startJob("job.relogin_all_workers")
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
this.goToPicker("pick_worker", "slots", "relogin_worker", "Выберите слот")
|
|
886
|
+
return
|
|
887
|
+
}
|
|
888
|
+
if (optionId === "slots_open") {
|
|
889
|
+
this.goToPicker("pick_worker", "slots", "open_worker", "Открыть браузер слота")
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
if (optionId === "slots_delete") {
|
|
893
|
+
this.goToPicker("pick_worker", "slots", "delete_worker", "Удалить слот")
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (this.menuName === "mail" && optionId.startsWith("mail_pick:")) {
|
|
899
|
+
const index = Number(optionId.split(":", 2)[1])
|
|
900
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.state.accounts.length) return
|
|
901
|
+
const email = this.state.accounts[index].email
|
|
902
|
+
this.menuName = "mail"
|
|
903
|
+
this.menuCtx = {}
|
|
904
|
+
await this.startJob("job.fetch_mail", { email })
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (this.menuName === "pick_admin" && optionId.startsWith("pick_admin:")) {
|
|
909
|
+
const email = optionId.split(":", 2)[1]
|
|
910
|
+
const action = this.menuCtx.action
|
|
911
|
+
const parent = (this.menuCtx.parent ?? "main") as MenuName
|
|
912
|
+
|
|
913
|
+
if (action === "relogin_admin") {
|
|
914
|
+
const loginMode = await this.promptLoginMode()
|
|
915
|
+
if (!loginMode) return
|
|
916
|
+
this.menuName = parent
|
|
917
|
+
this.menuCtx = {}
|
|
918
|
+
await this.startAdminLogin(email, loginMode)
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
if (action === "open_admin") {
|
|
922
|
+
this.menuName = parent
|
|
923
|
+
this.menuCtx = {}
|
|
924
|
+
await this.startJob("job.open_admin_browser", { email })
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
if (action === "delete_admin") {
|
|
928
|
+
this.goToConfirm(parent, "delete_admin", "Удаление админа", email)
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
if (action === "slots_create") {
|
|
932
|
+
const countRaw = await this.promptInput(`Количество слотов для ${email}`)
|
|
933
|
+
if (!countRaw) return
|
|
934
|
+
const count = Number(countRaw)
|
|
935
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
936
|
+
this.pushLog("Некорректное число")
|
|
937
|
+
this.render()
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
this.menuName = parent
|
|
941
|
+
this.menuCtx = {}
|
|
942
|
+
await this.startJob("job.run_slots", { admin_email: email, count })
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (this.menuName === "pick_worker" && optionId.startsWith("pick_worker:")) {
|
|
948
|
+
const email = optionId.split(":", 2)[1]
|
|
949
|
+
const action = this.menuCtx.action
|
|
950
|
+
const parent = (this.menuCtx.parent ?? "slots") as MenuName
|
|
951
|
+
|
|
952
|
+
if (action === "relogin_worker") {
|
|
953
|
+
this.menuName = parent
|
|
954
|
+
this.menuCtx = {}
|
|
955
|
+
await this.startJob("job.relogin_worker", { email })
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
if (action === "open_worker") {
|
|
959
|
+
this.menuName = parent
|
|
960
|
+
this.menuCtx = {}
|
|
961
|
+
await this.startJob("job.open_worker_browser", { email })
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
if (action === "delete_worker") {
|
|
965
|
+
this.goToConfirm(parent, "delete_worker", "Удаление слота", email)
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (this.menuName === "confirm") {
|
|
971
|
+
const action = this.menuCtx.action
|
|
972
|
+
const target = this.menuCtx.target
|
|
973
|
+
const parent = (this.menuCtx.parent ?? "main") as MenuName
|
|
974
|
+
|
|
975
|
+
if (optionId === "confirm_no") {
|
|
976
|
+
this.menuName = parent
|
|
977
|
+
this.menuCtx = {}
|
|
978
|
+
await this.refreshState()
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (optionId === "confirm_yes" && target) {
|
|
983
|
+
try {
|
|
984
|
+
if (action === "delete_admin") {
|
|
985
|
+
await this.rpc.request("admin.delete", { email: target })
|
|
986
|
+
this.pushLog(`Удалён админ: ${target}`)
|
|
987
|
+
}
|
|
988
|
+
if (action === "delete_worker") {
|
|
989
|
+
await this.rpc.request("worker.delete", { email: target })
|
|
990
|
+
this.pushLog(`Удалён слот: ${target}`)
|
|
991
|
+
}
|
|
992
|
+
} catch (error) {
|
|
993
|
+
this.pushLog(`Ошибка: ${String(error)}`)
|
|
994
|
+
}
|
|
995
|
+
this.menuName = parent
|
|
996
|
+
this.menuCtx = {}
|
|
997
|
+
await this.refreshState()
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private goToPicker(picker: MenuName, parent: MenuName, action: string, title: string) {
|
|
1003
|
+
this.menuName = picker
|
|
1004
|
+
this.menuCtx = { parent, action, title }
|
|
1005
|
+
this.selectedIndex = 0
|
|
1006
|
+
this.render()
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private goToConfirm(parent: MenuName, action: string, confirmAction: string, target: string) {
|
|
1010
|
+
this.menuName = "confirm"
|
|
1011
|
+
this.menuCtx = {
|
|
1012
|
+
parent,
|
|
1013
|
+
action,
|
|
1014
|
+
confirm_action: confirmAction,
|
|
1015
|
+
title: `${confirmAction}: ${target}`,
|
|
1016
|
+
target,
|
|
1017
|
+
}
|
|
1018
|
+
this.selectedIndex = 1
|
|
1019
|
+
this.render()
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private async startJob(method: string, params: Record<string, unknown> = {}) {
|
|
1023
|
+
if (this.busy) return
|
|
1024
|
+
this.busy = true
|
|
1025
|
+
try {
|
|
1026
|
+
const result = await this.rpc.request<RpcJobResult>(method, params)
|
|
1027
|
+
this.backendOnline = true
|
|
1028
|
+
this.pushLog(`Задача ${result.job_id.slice(0, 8)} поставлена в очередь`)
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
this.busy = false
|
|
1031
|
+
this.pushLog(`Ошибка: ${String(error)}`)
|
|
1032
|
+
}
|
|
1033
|
+
this.render()
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private onPaste(event: any) {
|
|
1037
|
+
if (!this.prompt.active) return
|
|
1038
|
+
if (this.prompt.mode !== "input") return
|
|
1039
|
+
const text = String(event?.text ?? "")
|
|
1040
|
+
if (!text) return
|
|
1041
|
+
const normalized = text.replace(/\r\n?/g, "\n").split("\n")[0]
|
|
1042
|
+
this.prompt.value += normalized
|
|
1043
|
+
this.render()
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private copySelectedLogToClipboard() {
|
|
1047
|
+
const selected = this.renderer.getSelection()?.getSelectedText()?.trim() ?? ""
|
|
1048
|
+
const fallback = this.detailText.getSelectedText?.().trim() ?? ""
|
|
1049
|
+
const text = selected || fallback
|
|
1050
|
+
if (!text) return
|
|
1051
|
+
const copiedViaOsc52 = this.renderer.copyToClipboardOSC52(text)
|
|
1052
|
+
const copied = copiedViaOsc52 || copyToSystemClipboard(text)
|
|
1053
|
+
this.pushLog(copied ? "Скопировано в буфер обмена" : "Копирование не поддерживается")
|
|
1054
|
+
this.render()
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private async onKey(key: any) {
|
|
1058
|
+
if (this.prompt.active) {
|
|
1059
|
+
if (this.prompt.mode === "select") {
|
|
1060
|
+
if (key.name === "return") {
|
|
1061
|
+
const option = this.prompt.options[this.prompt.selectedIndex]
|
|
1062
|
+
this.finishPrompt(option?.value ?? null)
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
if (key.name === "escape") {
|
|
1066
|
+
this.finishPrompt(null)
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
if (key.name === "up" || key.name === "k") {
|
|
1070
|
+
const total = this.prompt.options.length
|
|
1071
|
+
if (total > 0) {
|
|
1072
|
+
this.prompt.selectedIndex = this.prompt.selectedIndex <= 0 ? total - 1 : this.prompt.selectedIndex - 1
|
|
1073
|
+
this.render()
|
|
1074
|
+
}
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
if (key.name === "down" || key.name === "j") {
|
|
1078
|
+
const total = this.prompt.options.length
|
|
1079
|
+
if (total > 0) {
|
|
1080
|
+
this.prompt.selectedIndex = this.prompt.selectedIndex >= total - 1 ? 0 : this.prompt.selectedIndex + 1
|
|
1081
|
+
this.render()
|
|
1082
|
+
}
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
if (key.sequence >= "1" && key.sequence <= "9" && !key.ctrl && !key.meta) {
|
|
1086
|
+
const index = Number(key.sequence) - 1
|
|
1087
|
+
if (index < this.prompt.options.length) {
|
|
1088
|
+
this.prompt.selectedIndex = index
|
|
1089
|
+
this.render()
|
|
1090
|
+
}
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (key.name === "return") {
|
|
1097
|
+
const value = this.prompt.value.trim()
|
|
1098
|
+
this.finishPrompt(value ? value : null)
|
|
1099
|
+
return
|
|
1100
|
+
}
|
|
1101
|
+
if (key.name === "escape") {
|
|
1102
|
+
this.finishPrompt(null)
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
if (key.name === "backspace") {
|
|
1106
|
+
this.prompt.value = this.prompt.value.slice(0, -1)
|
|
1107
|
+
this.render()
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
if (typeof key.sequence === "string" && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
1111
|
+
this.prompt.value += key.sequence
|
|
1112
|
+
this.render()
|
|
1113
|
+
}
|
|
1114
|
+
return
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (key.name === "y" && !key.ctrl && !key.meta) {
|
|
1118
|
+
this.copySelectedLogToClipboard()
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
if ((key.ctrl || key.meta) && key.name === "c") {
|
|
1122
|
+
this.copySelectedLogToClipboard()
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (this.busy && key.name !== "q") {
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (key.name === "r" && !key.ctrl && !key.meta) {
|
|
1131
|
+
await this.refreshState()
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (key.sequence >= "1" && key.sequence <= "9" && !key.ctrl && !key.meta) {
|
|
1136
|
+
const index = Number(key.sequence) - 1
|
|
1137
|
+
if (index < this.menuOptions.length) {
|
|
1138
|
+
this.selectedIndex = index
|
|
1139
|
+
await this.submitOption(this.menuOptions[index].id)
|
|
1140
|
+
}
|
|
1141
|
+
return
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (key.name === "up" || key.name === "k") {
|
|
1145
|
+
this.move(-1)
|
|
1146
|
+
return
|
|
1147
|
+
}
|
|
1148
|
+
if (key.name === "down" || key.name === "j") {
|
|
1149
|
+
this.move(1)
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
if (key.name === "escape") {
|
|
1153
|
+
await this.goBack()
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
if (key.name === "return") {
|
|
1157
|
+
const selected = this.menuOptions[this.selectedIndex]
|
|
1158
|
+
if (selected) await this.submitOption(selected.id)
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
1161
|
+
if (key.name === "q") {
|
|
1162
|
+
await this.exit()
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private async exit() {
|
|
1167
|
+
try {
|
|
1168
|
+
await this.rpc.shutdown()
|
|
1169
|
+
} catch {
|
|
1170
|
+
// no-op
|
|
1171
|
+
}
|
|
1172
|
+
this.renderer.destroy()
|
|
1173
|
+
process.exit(0)
|
|
1174
|
+
}
|
|
1175
|
+
}
|