This commit is contained in:
Ivan Neplokhov 2026-02-01 14:39:30 +04:00
parent f4a0711ac3
commit f48cc365e1
58 changed files with 6175 additions and 0 deletions

118
src/renderer/appDefaults.js Normal file
View File

@ -0,0 +1,118 @@
export const emptySettings = {
competitorGroups: [""],
ourGroup: "",
minIntervalMinutes: 5,
maxIntervalMinutes: 10,
dailyLimit: 100,
historyLimit: 200,
accountMaxGroups: 10,
accountDailyLimit: 50,
floodCooldownMinutes: 1440,
queueTtlHours: 24
};
export const emptyTaskForm = {
id: null,
name: "",
ourGroup: "",
minIntervalMinutes: 1,
maxIntervalMinutes: 3,
dailyLimit: 15,
historyLimit: 35,
maxInvitesPerCycle: 1,
maxCompetitorBots: 1,
maxOurBots: 1,
randomAccounts: false,
multiAccountsPerRun: false,
retryOnFail: true,
autoJoinCompetitors: true,
autoJoinOurGroup: true,
separateBotRoles: false,
requireSameBotInBoth: true,
parseParticipants: false,
inviteViaAdmins: false,
inviteAdminMasterId: 0,
inviteAdminAllowFlood: false,
inviteAdminAnonymous: true,
separateConfirmRoles: false,
maxConfirmBots: 1,
useWatcherInviteNoUsername: true,
warmupEnabled: true,
warmupStartLimit: 3,
warmupDailyIncrease: 2,
cycleCompetitors: false,
competitorCursor: 0,
inviteLinkOnFail: false,
rolesMode: "manual",
stopOnBlocked: true,
stopBlockedPercent: 25,
notes: "",
enabled: true,
autoAssignAccounts: true,
allowStartWithoutInviteRights: true
};
export const normalizeIntervals = (form) => {
const minValue = Number(form.minIntervalMinutes);
const maxValue = Number(form.maxIntervalMinutes);
const min = Number.isFinite(minValue) && minValue > 0 ? minValue : 1;
let max = Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1;
if (max < min) max = min;
return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max };
};
export const sanitizeTaskForm = (form) => {
let normalized = { ...form };
normalized = normalizeIntervals(normalized);
if (normalized.requireSameBotInBoth) {
normalized.separateBotRoles = false;
normalized.maxOurBots = normalized.maxCompetitorBots;
} else {
normalized.separateBotRoles = true;
}
if (!normalized.separateBotRoles) {
normalized.separateConfirmRoles = false;
}
return normalized;
};
export const normalizeTask = (row) => ({
id: row.id,
name: row.name || "",
ourGroup: row.our_group || "",
minIntervalMinutes: Number(row.min_interval_minutes || 1),
maxIntervalMinutes: Number(row.max_interval_minutes || 3),
dailyLimit: Number(row.daily_limit || 15),
historyLimit: Number(row.history_limit || 35),
maxInvitesPerCycle: Number(row.max_invites_per_cycle || 1),
maxCompetitorBots: Number(row.max_competitor_bots || 1),
maxOurBots: Number(row.max_our_bots || 1),
randomAccounts: Boolean(row.random_accounts),
multiAccountsPerRun: Boolean(row.multi_accounts_per_run),
retryOnFail: Boolean(row.retry_on_fail),
autoJoinCompetitors: Boolean(row.auto_join_competitors),
autoJoinOurGroup: Boolean(row.auto_join_our_group),
separateBotRoles: Boolean(row.separate_bot_roles),
requireSameBotInBoth: Boolean(row.require_same_bot_in_both),
parseParticipants: Boolean(row.parse_participants),
inviteViaAdmins: Boolean(row.invite_via_admins),
inviteAdminMasterId: Number(row.invite_admin_master_id || 0),
inviteAdminAllowFlood: Boolean(row.invite_admin_allow_flood),
inviteAdminAnonymous: row.invite_admin_anonymous == null ? true : Boolean(row.invite_admin_anonymous),
separateConfirmRoles: Boolean(row.separate_confirm_roles),
maxConfirmBots: Number(row.max_confirm_bots || 1),
useWatcherInviteNoUsername: row.use_watcher_invite_no_username == null ? true : Boolean(row.use_watcher_invite_no_username),
warmupEnabled: row.warmup_enabled == null ? true : Boolean(row.warmup_enabled),
warmupStartLimit: Number(row.warmup_start_limit || 3),
warmupDailyIncrease: Number(row.warmup_daily_increase || 2),
cycleCompetitors: Boolean(row.cycle_competitors),
competitorCursor: Number(row.competitor_cursor || 0),
inviteLinkOnFail: Boolean(row.invite_link_on_fail),
rolesMode: row.role_mode || "manual",
stopOnBlocked: Boolean(row.stop_on_blocked),
stopBlockedPercent: Number(row.stop_blocked_percent || 25),
notes: row.notes || "",
enabled: Boolean(row.enabled),
allowStartWithoutInviteRights: row.allow_start_without_invite_rights == null ? true : Boolean(row.allow_start_without_invite_rights),
autoAssignAccounts: true
});

View File

@ -0,0 +1,76 @@
import React from "react";
import QuickActionsBar from "./QuickActionsBar.jsx";
import NowStatusCard from "./NowStatusCard.jsx";
import ChecklistCard from "./ChecklistCard.jsx";
import MainTabs from "./MainTabs.jsx";
import TaskSettingsTab from "./TaskSettingsTab.jsx";
import MainTabContent from "./MainTabContent.jsx";
import TestRunCard from "./TestRunCard.jsx";
import useTabProps from "../hooks/useTabProps.js";
const AccountsTab = React.lazy(() => import("../tabs/AccountsTab.jsx"));
const LogsTab = React.lazy(() => import("../tabs/LogsTab.jsx"));
const QueueTab = React.lazy(() => import("../tabs/QueueTab.jsx"));
const EventsTab = React.lazy(() => import("../tabs/EventsTab.jsx"));
const SettingsTab = React.lazy(() => import("../tabs/SettingsTab.jsx"));
export default function AppMain({
quickActions,
nowStatus,
testRun,
runTestLive,
checklist,
tabs,
taskSettings,
accountsTab,
logsTab,
queueTab,
eventsTab,
settingsTab
}) {
const {
taskSettingsProps,
accountsTabProps,
logsTabProps,
queueTabProps,
eventsTabProps,
settingsTabProps
} = useTabProps(taskSettings, accountsTab, logsTab, queueTab, eventsTab, settingsTab);
return (
<div className="main">
<QuickActionsBar
{...quickActions}
/>
<NowStatusCard
{...nowStatus}
/>
<TestRunCard
testRun={testRun}
onRunSafe={quickActions.runTestSafe}
onRunLive={runTestLive}
/>
<ChecklistCard
{...checklist}
/>
<MainTabs
{...tabs}
/>
<MainTabContent
activeTab={tabs.activeTab}
TaskSettingsTab={TaskSettingsTab}
AccountsTab={AccountsTab}
LogsTab={LogsTab}
QueueTab={QueueTab}
EventsTab={EventsTab}
SettingsTab={SettingsTab}
taskSettingsProps={taskSettingsProps}
accountsTabProps={accountsTabProps}
logsTabProps={logsTabProps}
queueTabProps={queueTabProps}
eventsTabProps={eventsTabProps}
settingsTabProps={settingsTabProps}
/>
</div>
);
}

View File

@ -0,0 +1,100 @@
import React from "react";
import ImportAccountsModal from "./ImportAccountsModal.jsx";
import NotificationsModal from "./NotificationsModal.jsx";
import InfoModal from "./InfoModal.jsx";
import ToastStack from "./ToastStack.jsx";
import ConfirmModal from "./ConfirmModal.jsx";
export default function AppOverlays({
importModalOpen,
setImportModalOpen,
manualLoginOpen,
setManualLoginOpen,
hasSelectedTask,
loginForm,
setLoginForm,
startLogin,
completeLogin,
loginStatus,
tdataForm,
setTdataForm,
importTdata,
tdataLoading,
tdataResult,
explainTdataError,
notificationsOpen,
setNotificationsOpen,
notificationsModalRef,
notifications,
filteredNotifications,
notificationFilter,
setNotificationFilter,
setNotifications,
infoOpen,
setInfoOpen,
infoTab,
setInfoTab,
liveConfirmOpen,
setLiveConfirmOpen,
liveConfirmContext,
onConfirmLiveInvite,
onCancelLiveInvite,
toasts,
dismissToast
}) {
return (
<>
<ImportAccountsModal
open={importModalOpen}
onClose={() => setImportModalOpen(false)}
manualLoginOpen={manualLoginOpen}
setManualLoginOpen={setManualLoginOpen}
hasSelectedTask={hasSelectedTask}
loginForm={loginForm}
setLoginForm={setLoginForm}
startLogin={startLogin}
completeLogin={completeLogin}
loginStatus={loginStatus}
tdataForm={tdataForm}
setTdataForm={setTdataForm}
importTdata={importTdata}
tdataLoading={tdataLoading}
tdataResult={tdataResult}
explainTdataError={explainTdataError}
/>
<NotificationsModal
open={notificationsOpen}
onClose={() => setNotificationsOpen(false)}
notificationsModalRef={notificationsModalRef}
notifications={notifications}
filteredNotifications={filteredNotifications}
notificationFilter={notificationFilter}
setNotificationFilter={setNotificationFilter}
setNotifications={setNotifications}
/>
<InfoModal
open={infoOpen}
onClose={() => setInfoOpen(false)}
infoTab={infoTab}
setInfoTab={setInfoTab}
/>
<ConfirmModal
open={liveConfirmOpen}
title="Liveинвайт"
message={
liveConfirmContext
? `Liveпрогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые).\ополнительно он делает один реальный инвайт из очереди для проверки фактической работы и ошибок Telegram.\n\ользователь: ${liveConfirmContext.userId || "—"}${liveConfirmContext.username ? ` (@${liveConfirmContext.username})` : ""}\сточник: ${liveConfirmContext.sourceChat || "—"}`
: "Liveпрогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые). Дополнительно он делает один реальный инвайт из очереди."
}
confirmLabel="Сделать инвайт"
cancelLabel="Отмена"
onConfirm={onConfirmLiveInvite}
onCancel={onCancelLiveInvite}
/>
<ToastStack
toasts={toasts}
onDismiss={dismissToast}
/>
</>
);
}

View File

@ -0,0 +1,154 @@
import React from "react";
import SidebarOverview from "./SidebarOverview.jsx";
import SidebarAccounts from "./SidebarAccounts.jsx";
import TasksSidebar from "./TasksSidebar.jsx";
export function AppSidebarOverview({
taskSummary,
globalStatus,
selectedTaskName,
competitorGroups,
assignedAccountCount,
taskStatus,
notificationsOpen,
setNotificationsOpen,
infoOpen,
setInfoOpen,
bellRef,
notificationsCount,
onOpenImport
}) {
return (
<>
<SidebarOverview
taskSummary={taskSummary}
globalStatus={globalStatus}
selectedTaskName={selectedTaskName}
competitorGroups={competitorGroups}
assignedAccountCount={assignedAccountCount}
taskStatus={taskStatus}
notificationsOpen={notificationsOpen}
setNotificationsOpen={setNotificationsOpen}
infoOpen={infoOpen}
setInfoOpen={setInfoOpen}
bellRef={bellRef}
notificationsCount={notificationsCount}
/>
<SidebarAccounts onOpenImport={onOpenImport} />
</>
);
}
export function AppSidebarTasks({
createTask,
taskSearch,
setTaskSearch,
taskFilter,
setTaskFilter,
taskSort,
setTaskSort,
filteredTasks,
taskStatusMap,
selectedTaskId,
selectTask,
deleteTask,
hasSelectedTask,
formatCountdown,
formatTimestamp,
accountById,
formatAccountLabel
}) {
return (
<TasksSidebar
createTask={createTask}
taskSearch={taskSearch}
setTaskSearch={setTaskSearch}
taskFilter={taskFilter}
setTaskFilter={setTaskFilter}
taskSort={taskSort}
setTaskSort={setTaskSort}
filteredTasks={filteredTasks}
taskStatusMap={taskStatusMap}
selectedTaskId={selectedTaskId}
selectTask={selectTask}
deleteTask={deleteTask}
hasSelectedTask={hasSelectedTask}
formatCountdown={formatCountdown}
formatTimestamp={formatTimestamp}
accountById={accountById}
formatAccountLabel={formatAccountLabel}
/>
);
}
export default function AppSidebar({
taskSummary,
globalStatus,
selectedTaskName,
competitorGroups,
assignedAccountCount,
taskStatus,
notificationsOpen,
setNotificationsOpen,
infoOpen,
setInfoOpen,
bellRef,
notificationsCount,
onOpenImport,
createTask,
taskSearch,
setTaskSearch,
taskFilter,
setTaskFilter,
taskSort,
setTaskSort,
filteredTasks,
taskStatusMap,
selectedTaskId,
selectTask,
deleteTask,
hasSelectedTask,
formatCountdown,
formatTimestamp,
accountById,
formatAccountLabel
}) {
return (
<aside className="sidebar left">
<AppSidebarOverview
taskSummary={taskSummary}
globalStatus={globalStatus}
selectedTaskName={selectedTaskName}
competitorGroups={competitorGroups}
assignedAccountCount={assignedAccountCount}
taskStatus={taskStatus}
notificationsOpen={notificationsOpen}
setNotificationsOpen={setNotificationsOpen}
infoOpen={infoOpen}
setInfoOpen={setInfoOpen}
bellRef={bellRef}
notificationsCount={notificationsCount}
onOpenImport={onOpenImport}
/>
<AppSidebarTasks
createTask={createTask}
taskSearch={taskSearch}
setTaskSearch={setTaskSearch}
taskFilter={taskFilter}
setTaskFilter={setTaskFilter}
taskSort={taskSort}
setTaskSort={setTaskSort}
filteredTasks={filteredTasks}
taskStatusMap={taskStatusMap}
selectedTaskId={selectedTaskId}
selectTask={selectTask}
deleteTask={deleteTask}
hasSelectedTask={hasSelectedTask}
formatCountdown={formatCountdown}
formatTimestamp={formatTimestamp}
accountById={accountById}
formatAccountLabel={formatAccountLabel}
/>
</aside>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
export default function ChecklistCard({
checklistStats,
checklistOpen,
setChecklistOpen,
checklistItems,
hasSelectedTask
}) {
return (
<section className="card checklist">
<div className="row-header">
<div className="row-header-main">
<h3>Чек-лист запуска</h3>
<div className="status-caption">Готово: {checklistStats.ok}/{checklistStats.total} · Проблемы: {checklistStats.fail}</div>
</div>
<button type="button" className="ghost" onClick={() => setChecklistOpen(!checklistOpen)}>
{checklistOpen ? "Свернуть" : "Развернуть"}
</button>
</div>
{checklistOpen && (
<div className="checklist-list">
{checklistItems.map((item) => {
const status = item.ok ? "ok" : (item.warn ? "warn" : "fail");
const statusLabel = item.ok ? "Готово" : (item.warn ? "Есть проблемы" : "Нужно внимание");
return (
<div key={item.id} className={`checklist-item ${status}`}>
<div className="checklist-meta">
<div className="checklist-title">{item.label}</div>
<div className="checklist-hint">{item.hint}</div>
</div>
<div className="checklist-actions">
<span className={`checklist-badge ${status}`}>{statusLabel}</span>
<button
type="button"
className="ghost"
onClick={item.action}
disabled={!hasSelectedTask}
>
{item.actionLabel}
</button>
</div>
</div>
);
})}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,29 @@
import React from "react";
export default function ConfirmModal({
open,
title,
message,
confirmLabel = "Подтвердить",
cancelLabel = "Отмена",
onConfirm,
onCancel
}) {
if (!open) return null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="row-header">
<h3>{title}</h3>
<button className="ghost" type="button" onClick={onCancel}>Закрыть</button>
</div>
<div className="help-note">{message}</div>
<div className="row-inline">
<button className="secondary" type="button" onClick={onCancel}>{cancelLabel}</button>
<button className="primary" type="button" onClick={onConfirm}>{confirmLabel}</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
import React from "react";
export default function ImportAccountsModal({
open,
onClose,
manualLoginOpen,
setManualLoginOpen,
hasSelectedTask,
loginForm,
setLoginForm,
startLogin,
completeLogin,
loginStatus,
tdataForm,
setTdataForm,
importTdata,
tdataLoading,
tdataResult,
explainTdataError
}) {
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal large" onClick={(event) => event.stopPropagation()}>
<div className="row-header">
<h3>Импорт аккаунтов</h3>
<button className="ghost" type="button" onClick={onClose}>Закрыть</button>
</div>
<div className="login-box">
<div className="row-header">
<h4>Добавить аккаунт по коду</h4>
<button className="ghost" type="button" onClick={() => setManualLoginOpen(!manualLoginOpen)}>
{manualLoginOpen ? "Свернуть" : "Развернуть"}
</button>
</div>
{manualLoginOpen && (
<div className="collapsible">
{!hasSelectedTask && (
<div className="status-text compact">Выберите задачу, чтобы добавить аккаунт.</div>
)}
<div className="row">
<label>
<span className="label-line">API ID <span className="required">*</span></span>
<input
type="text"
value={loginForm.apiId}
onChange={(event) => setLoginForm({ ...loginForm, apiId: event.target.value })}
/>
</label>
<label>
<span className="label-line">API Hash <span className="required">*</span></span>
<input
type="text"
value={loginForm.apiHash}
onChange={(event) => setLoginForm({ ...loginForm, apiHash: event.target.value })}
/>
</label>
</div>
<label>
<span className="label-line">Телефон <span className="required">*</span></span>
<input
type="text"
value={loginForm.phone}
onChange={(event) => setLoginForm({ ...loginForm, phone: event.target.value })}
/>
</label>
<div className="row">
<label>
<span className="label-line">Код <span className="required">*</span></span>
<input
type="text"
value={loginForm.code}
onChange={(event) => setLoginForm({ ...loginForm, code: event.target.value })}
/>
</label>
<label>
<span className="label-line">2FA пароль <span className="optional">необязательно</span></span>
<input
type="password"
value={loginForm.password}
onChange={(event) => setLoginForm({ ...loginForm, password: event.target.value })}
/>
</label>
</div>
<div className="row actions">
<button className="secondary" onClick={startLogin} disabled={!hasSelectedTask}>Отправить код</button>
<button className="primary" onClick={completeLogin} disabled={!hasSelectedTask}>Подтвердить</button>
</div>
{loginStatus && <div className="status-text">{loginStatus}</div>}
</div>
)}
</div>
<div className="login-box">
<h4>Импорт из tdata</h4>
<div className="status-text compact">
Можно выбрать сразу несколько папок. Значения по умолчанию API Telegram Desktop.
</div>
<div className="row">
<label>
<span className="label-line">API ID</span>
<input
type="text"
value={tdataForm.apiId}
onChange={(event) => setTdataForm({ ...tdataForm, apiId: event.target.value })}
/>
</label>
<label>
<span className="label-line">API Hash</span>
<input
type="text"
value={tdataForm.apiHash}
onChange={(event) => setTdataForm({ ...tdataForm, apiHash: event.target.value })}
/>
</label>
</div>
<button className="primary" onClick={importTdata} disabled={tdataLoading}>
{tdataLoading ? "Импортируем..." : "Импортировать tdata"}
</button>
{tdataLoading && <div className="status-text">Идет импорт, это может занять несколько секунд.</div>}
{tdataResult && (
<div className="tdata-report">
<div>Импортировано: {(tdataResult.imported || []).length}</div>
<div>Пропущено: {(tdataResult.skipped || []).length}</div>
<div>Ошибок: {(tdataResult.failed || []).length}</div>
{(tdataResult.failed || []).length > 0 && (
<div className="tdata-errors">
{tdataResult.failed.map((item, index) => (
<div key={`${item.path}-${index}`} className="tdata-error-row">
<div className="tdata-error-path">{item.path}</div>
<div className="tdata-error-text">{item.error}</div>
{explainTdataError(item.error) && (
<div className="tdata-error-hint">{explainTdataError(item.error)}</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
import React from "react";
export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="row-header">
<h3>Как пользоваться</h3>
<button className="ghost" type="button" onClick={onClose}>Закрыть</button>
</div>
<div className="info-tabs">
<button
type="button"
className={`tab ${infoTab === "usage" ? "active" : ""}`}
onClick={() => setInfoTab("usage")}
>
Быстрый старт
</button>
<button
type="button"
className={`tab ${infoTab === "features" ? "active" : ""}`}
onClick={() => setInfoTab("features")}
>
Функции
</button>
<button
type="button"
className={`tab ${infoTab === "strategies" ? "active" : ""}`}
onClick={() => setInfoTab("strategies")}
>
Стратегии
</button>
<button
type="button"
className={`tab ${infoTab === "limits" ? "active" : ""}`}
onClick={() => setInfoTab("limits")}
>
Ограничения Telegram
</button>
</div>
{infoTab === "usage" && (
<>
<ol className="help-list">
<li>Создайте задачу: название, наша группа и группы конкурентов.</li>
<li>Импортируйте аккаунты (tdata) и назначьте роли для задачи.</li>
<li>Нажмите Собрать историю, чтобы добавить авторов из последних сообщений.</li>
<li>Нажмите Запустить, чтобы отслеживать новые сообщения и приглашать по расписанию.</li>
<li>Следите за статусом, логами, событиями и очередью.</li>
</ol>
<p className="help-note">
Собрать историю добавляет в очередь авторов старых сообщений. Без этого учитываются только новые сообщения.
</p>
</>
)}
{infoTab === "features" && (
<div className="help-note">
<strong>Функции и режимы:</strong>
<div>1) Мониторинг: отслеживает новые сообщения в чатах конкурентов и добавляет авторов в очередь.</div>
<div>2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.</div>
<div>3) Инвайт через админов: временно выдает право Приглашать, затем снимает.</div>
<div>4) Инвайт в чаты с флудом: распределяет инвайт через цепочку выдачи прав.</div>
<div>5) Циклический обход конкурентов: переключает мониторинг по списку групп.</div>
<div>6) Парсинг участников: пытается получить список участников для закрытых чатов.</div>
<div>7) Прогрев лимита: плавно увеличивает дневной лимит по дням.</div>
<div>8) Fallbackлист: собирает проблемные инвайты и предлагает маршруты.</div>
</div>
)}
{infoTab === "strategies" && (
<div className="help-note">
<strong>Стратегии инвайта:</strong>
<div>1) access_hash из сообщения.</div>
<div>2) Резолв через участников/источник.</div>
<div>3) Инвайт по username (если доступен).</div>
<div>4) Инвайт через админов (если включен).</div>
<div>5) Отправка инвайтссылки (если включено).</div>
<div>После успешного инвайта выполняется проверка фактического вступления.</div>
</div>
)}
{infoTab === "limits" && (
<div className="help-note">
<strong>Особенности Telegram и ошибки:</strong>
<div>1) AUTH_KEY_DUPLICATED: tdata уже используется выйдите из аккаунта на других устройствах и пересоберите tdata.</div>
<div>2) CHAT_ADMIN_REQUIRED: аккаунт должен быть админом с правом добавлять участников.</div>
<div>3) USER_ID_INVALID: скрытые/анонимные авторы, удаленные аккаунты инвайт возможен только по username.</div>
<div>4) USER_NOT_MUTUAL_CONTACT: ограничения Telegram/приватность пользователя помогает инвайтссылка или другой аккаунт.</div>
<div>5) USER_PRIVACY_RESTRICTED: пользователь запретил инвайты в чаты.</div>
<div>6) FLOOD/PEER_FLOOD: снизить лимиты, увеличить интервалы, распределить нагрузку.</div>
<div>7) CHANNEL_PRIVATE/INVITE_HASH_INVALID: ссылка недействительна или чат приватный.</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import React, { Suspense } from "react";
export default function MainTabContent({
activeTab,
TaskSettingsTab,
AccountsTab,
LogsTab,
QueueTab,
EventsTab,
SettingsTab,
taskSettingsProps,
accountsTabProps,
logsTabProps,
queueTabProps,
eventsTabProps,
settingsTabProps
}) {
return (
<>
{activeTab === "task" && (
<TaskSettingsTab {...taskSettingsProps} />
)}
{activeTab === "accounts" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<AccountsTab {...accountsTabProps} />
</Suspense>
)}
{activeTab === "logs" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<LogsTab {...logsTabProps} />
</Suspense>
)}
{activeTab === "queue" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<QueueTab {...queueTabProps} />
</Suspense>
)}
{activeTab === "events" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<EventsTab {...eventsTabProps} />
</Suspense>
)}
{activeTab === "settings" && (
<Suspense fallback={<div className="card">Загрузка...</div>}>
<SettingsTab {...settingsTabProps} />
</Suspense>
)}
</>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
export default function MainTabs({ activeTab, setActiveTab }) {
return (
<div className="tabs sticky-tabs">
<button
type="button"
className={`tab ${activeTab === "task" ? "active" : ""}`}
onClick={() => setActiveTab("task")}
>
Задача
</button>
<button
type="button"
className={`tab ${activeTab === "accounts" ? "active" : ""}`}
onClick={() => setActiveTab("accounts")}
>
Аккаунты
</button>
<button
type="button"
className={`tab ${activeTab === "events" ? "active" : ""}`}
onClick={() => setActiveTab("events")}
>
Основной поток
</button>
<button
type="button"
className={`tab ${activeTab === "logs" ? "active" : ""}`}
onClick={() => setActiveTab("logs")}
>
История запусков
</button>
<button
type="button"
className={`tab ${activeTab === "queue" ? "active" : ""}`}
onClick={() => setActiveTab("queue")}
>
Очередь
</button>
<button
type="button"
className={`tab ${activeTab === "settings" ? "active" : ""}`}
onClick={() => setActiveTab("settings")}
>
Настройки
</button>
</div>
);
}

View File

@ -0,0 +1,56 @@
import React from "react";
export default function NotificationsModal({
open,
onClose,
notificationsModalRef,
notifications,
filteredNotifications,
notificationFilter,
setNotificationFilter,
setNotifications
}) {
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" ref={notificationsModalRef} onClick={(event) => event.stopPropagation()}>
<div className="row-header">
<h3>Уведомления</h3>
<button type="button" className="ghost" onClick={() => setNotifications([])}>
Очистить
</button>
</div>
<div className="bell-filters">
<button
type="button"
className={`chip ${notificationFilter === "all" ? "active" : ""}`}
onClick={() => setNotificationFilter("all")}
>
Все
</button>
<button
type="button"
className={`chip ${notificationFilter === "error" ? "active" : ""}`}
onClick={() => setNotificationFilter("error")}
>
Ошибки
</button>
<button
type="button"
className={`chip ${notificationFilter === "info" ? "active" : ""}`}
onClick={() => setNotificationFilter("info")}
>
Инфо
</button>
</div>
{filteredNotifications.length === 0 && <div className="empty">Пока пусто.</div>}
{filteredNotifications.map((item) => (
<div key={item.id} className={`notice ${item.tone}`}>
{item.text}{item.count > 1 ? ` (x${item.count})` : ""}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import React from "react";
export default function NowStatusCard({
nowLine,
nowExpanded,
setNowExpanded,
primaryIssue,
openFixTab,
monitorLabels,
inviteLabels,
roleSummary,
taskStatus,
groupVisibility,
lastEvents,
formatTimestamp
}) {
return (
<section className="card now-status">
<div className="now-line">
<div className="now-text">{nowLine}</div>
<button
type="button"
className="ghost"
onClick={() => setNowExpanded(!nowExpanded)}
>
{nowExpanded ? "Скрыть детали" : "Подробнее"}
</button>
</div>
{primaryIssue && (
<div className="primary-issue">
Причина: {primaryIssue}
<button type="button" className="ghost tiny" onClick={openFixTab}>
Исправить
</button>
</div>
)}
{nowExpanded && (
<div className="now-details">
<div>Мониторит: {monitorLabels.length ? monitorLabels.join(", ") : "—"}</div>
<div>Инвайтят: {inviteLabels.length ? inviteLabels.join(", ") : "—"}</div>
<div>Схема: мониторинг {roleSummary.monitor.length} · инвайт {roleSummary.invite.length} · подтверждение {roleSummary.confirm.length}</div>
<div>Последнее сообщение: {formatTimestamp(taskStatus.monitorInfo ? taskStatus.monitorInfo.lastMessageAt : "")}</div>
<div>Источник: {taskStatus.monitorInfo && taskStatus.monitorInfo.lastSource ? taskStatus.monitorInfo.lastSource : "—"}</div>
{taskStatus.readiness && (
<div className={`notice inline ${taskStatus.readiness.ok ? "success" : "warn"}`}>
Готовность: {taskStatus.readiness.ok ? "Да" : "Нет"}
{!taskStatus.readiness.ok && taskStatus.readiness.reasons && (
<div className="pre-line">{taskStatus.readiness.reasons.join("\n")}</div>
)}
</div>
)}
{taskStatus.lastStopReason && (
<div className="notice inline warn">
Последняя остановка: {taskStatus.lastStopReason}
{taskStatus.lastStopAt ? ` (${formatTimestamp(taskStatus.lastStopAt)})` : ""}
</div>
)}
{taskStatus.warnings && taskStatus.warnings.length > 0 && (
<div className="notice inline warn">
{taskStatus.warnings.map((warning, index) => (
<div key={`warn-${index}`}>{warning}</div>
))}
</div>
)}
{groupVisibility.length > 0 && groupVisibility.some((item) => item.hidden) && (
<div className="notice inline warn">
В некоторых группах скрыты участники инвайт возможен только по username.
<div className="visibility-list">
{groupVisibility
.filter((item) => item.hidden)
.map((item) => (
<div key={item.source} className="visibility-item">
{item.title ? `${item.title} (${item.source})` : item.source}
</div>
))}
</div>
</div>
)}
<div className="now-events">
Последние события:
{lastEvents.length === 0 && <div className="status-caption"></div>}
{lastEvents.map((event) => {
const firstLine = event.message ? String(event.message).split("\n")[0] : "";
return (
<div key={event.id} className="status-caption">
{formatTimestamp(event.createdAt)} {event.eventType}{firstLine ? `${firstLine}` : ""}
</div>
);
})}
</div>
</div>
)}
</section>
);
}

View File

@ -0,0 +1,142 @@
import React from "react";
export default function QuickActionsBar({
selectedTaskName,
autosaveNote,
taskStatus,
hasSelectedTask,
canSaveTask,
taskActionLoading,
saveTask,
parseHistory,
joinGroupsForTask,
checkAll,
startTask,
stopTask,
moreActionsOpen,
setMoreActionsOpen,
moreActionsRef,
clearQueue,
startAllTasks,
stopAllTasks,
clearDatabase,
resetSessions,
pauseReason,
setActiveTab,
tasksLength,
runTestSafe
}) {
return (
<section className="card action-bar">
<div className="row-header">
<div className="row-header-main">
<h3>Быстрые действия</h3>
<div className="status-caption">
Задача: {selectedTaskName}
{autosaveNote && <span className="autosave-note">{autosaveNote}</span>}
</div>
</div>
<div className={`status-pill ${taskStatus.running ? "ok" : "off"}`}>
{taskStatus.running ? "Запущено" : "Остановлено"}
</div>
</div>
<div className="row-inline action-buttons">
<button className="secondary" onClick={() => saveTask("bar")} disabled={!canSaveTask}>Сохранить</button>
<button className="secondary" onClick={() => parseHistory("bar")} disabled={!hasSelectedTask}>Собрать историю</button>
<button className="secondary" onClick={() => joinGroupsForTask("bar")} disabled={!hasSelectedTask}>
Добавить ботов в Telegram группы
</button>
<button className="secondary" onClick={() => checkAll("bar")} disabled={!hasSelectedTask}>Проверить всё</button>
<button className="secondary" onClick={runTestSafe} disabled={!hasSelectedTask}>Тестовый прогон</button>
{taskStatus.running ? (
<button className="danger cta" onClick={() => stopTask("bar")} disabled={!hasSelectedTask || taskActionLoading}>
<span className="cta-icon">[]</span>
{taskActionLoading ? "Остановка..." : "Остановить"}
</button>
) : (
<button className="primary cta" onClick={() => startTask("bar")} disabled={!hasSelectedTask || taskActionLoading}>
<span className="cta-icon">[&gt;]</span>
{taskActionLoading ? "Запуск..." : "Запустить"}
</button>
)}
</div>
<details className="more-actions" open={moreActionsOpen} ref={moreActionsRef}>
<summary
className="secondary"
onClick={(event) => {
event.preventDefault();
setMoreActionsOpen((prev) => !prev);
}}
>
Ещё
</summary>
<div className="more-actions-panel">
<button
className="secondary"
onClick={() => {
clearQueue("bar");
setMoreActionsOpen(false);
}}
disabled={!hasSelectedTask}
>
Очистить очередь
</button>
<button
type="button"
className="secondary"
onClick={() => {
startAllTasks();
setMoreActionsOpen(false);
}}
disabled={!tasksLength}
>
Запустить все
</button>
<button
type="button"
className="secondary"
onClick={() => {
stopAllTasks();
setMoreActionsOpen(false);
}}
disabled={!tasksLength}
>
Остановить все
</button>
<button
type="button"
className="danger"
onClick={() => {
clearDatabase();
setMoreActionsOpen(false);
}}
>
Очистить БД
</button>
<button
type="button"
className="danger ghost"
onClick={() => {
resetSessions();
setMoreActionsOpen(false);
}}
>
Сбросить сессии
</button>
</div>
</details>
{!taskStatus.running && pauseReason && (
<div className="pause-reason">
Пауза: {pauseReason}
<button
type="button"
className="ghost tiny"
onClick={() => setActiveTab("accounts")}
>
Исправить
</button>
</div>
)}
</section>
);
}

View File

@ -0,0 +1,26 @@
import React from "react";
export default function SidebarAccounts({ onOpenImport }) {
return (
<details className="card collapsible compact-card" open>
<summary>
<div className="row-header">
<div className="row-header-main">
<h3>Аккаунты</h3>
<div className="status-caption">Общий импорт</div>
</div>
<button
type="button"
className="secondary"
onClick={(event) => {
event.preventDefault();
onOpenImport();
}}
>
Открыть импорт
</button>
</div>
</summary>
</details>
);
}

View File

@ -0,0 +1,75 @@
import React from "react";
export default function SidebarOverview({
taskSummary,
globalStatus,
selectedTaskName,
competitorGroups,
assignedAccountCount,
taskStatus,
notificationsOpen,
setNotificationsOpen,
infoOpen,
setInfoOpen,
bellRef,
notificationsCount
}) {
return (
<details className="card collapsible overview compact" open>
<summary>
<div className="row-header">
<div className="row-header-main">
<h3>Общий обзор</h3>
</div>
<div className="row-inline overview-actions">
<div className="notification-bell" ref={bellRef}>
<button
type="button"
className={`icon-btn secondary ${notificationsOpen ? "active" : ""}`}
onClick={() => setNotificationsOpen((prev) => !prev)}
>
🔔
</button>
{notificationsCount > 0 && (
<span className="bell-badge">{notificationsCount}</span>
)}
</div>
<button
type="button"
className={`icon-btn secondary ${infoOpen ? "active" : ""}`}
onClick={() => setInfoOpen(true)}
>
</button>
</div>
</div>
</summary>
<div className="summary-grid">
<div className="summary-card">
<div className="live-label">Всего задач</div>
<div className="summary-value">{taskSummary.total}</div>
</div>
<div className="summary-card">
<div className="live-label">Сессии</div>
<div className="summary-value">
{globalStatus.connectedSessions}/{globalStatus.totalAccounts}
</div>
</div>
</div>
<div className="summary-row">
<div className="summary-compact">
<span>Задача:</span> {selectedTaskName}
</div>
<div className="summary-compact">
<span>Конкуренты:</span> {competitorGroups.length}
</div>
<div className="summary-compact">
<span>Аккаунты:</span> {assignedAccountCount}
</div>
<div className="summary-compact">
<span>Лимит:</span> {taskStatus.dailyUsed}/{taskStatus.dailyLimit}
</div>
</div>
</details>
);
}

View File

@ -0,0 +1,740 @@
import React from "react";
export default function TaskSettingsTab({
selectedTaskName,
taskForm,
setTaskForm,
activePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
competitorText,
setCompetitorText,
roleMode,
applyRoleMode,
normalizeIntervals,
taskStatus,
perAccountInviteSum,
hasSelectedTask,
inviteAccessStatus,
inviteAccessCheckedAt,
formatTimestamp,
checkInviteAccess,
accounts,
showNotification,
copyToClipboard,
sanitizeTaskForm,
hasPerAccountInviteLimits,
fileImportResult,
importInviteFile,
fileImportForm,
setFileImportForm,
criticalErrorAccounts
}) {
return (
<div className="task-columns">
<details className="card collapsible task-editor" open>
<summary>
<div className="row-header">
<h3>Настройки задачи</h3>
<div className="status-caption">Для: {selectedTaskName}</div>
</div>
</summary>
<div className="section-title">Основное</div>
<div className="row-inline">
<div className="status-caption">Пресеты:</div>
{taskForm.rolesMode === "auto" ? (
<>
<button
className={`secondary ${activePreset === "admin" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("admin")}
>
Автораспределение + Инвайт через админа
</button>
<button
className={`secondary ${activePreset === "no_admin" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("no_admin")}
>
Автораспределение + Без админки
</button>
<button
className={`secondary ${activePreset === "soft_50" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("soft_50")}
>
Мягкий 50/день (5 инвайтеров)
</button>
<button
className={`secondary ${activePreset === "soft_25" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("soft_25")}
>
Мягкий 25/день (2 инвайтера)
</button>
<button
className={`secondary ${activePreset === "soft_50_admin" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("soft_50_admin")}
>
Мягкий 50/день + админы
</button>
<button
className={`secondary ${activePreset === "soft_25_admin" ? "active" : ""}`}
type="button"
onClick={() => applyTaskPreset("soft_25_admin")}
>
Мягкий 25/день + админы
</button>
<span className="status-caption">
Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
</span>
</>
) : (
<span className="status-caption">В ручном режиме пресеты недоступны.</span>
)}
</div>
<div className="row">
<label>
<span className="label-line">Название задачи <span className="required">*</span></span>
<input
type="text"
value={taskForm.name}
onChange={(event) => setTaskForm({ ...taskForm, name: event.target.value })}
placeholder="Например, Таиланд"
/>
</label>
<label>
<span className="label-line">Наша группа <span className="required">*</span></span>
<input
type="text"
value={taskForm.ourGroup}
onChange={(event) => setTaskForm({ ...taskForm, ourGroup: event.target.value })}
placeholder="https://t.me/..."
/>
</label>
</div>
<label>
<span className="label-line">Группы конкурентов <span className="required">*</span></span>
<textarea
rows="6"
value={competitorText}
onChange={(event) => setCompetitorText(event.target.value)}
placeholder="Каждая группа с новой строки"
/>
</label>
<div className="task-editor-grid">
<details className="section" open>
<summary className="section-title">Базовые настройки</summary>
<details className="section" open>
<summary className="section-title">Роли ботов и вступление</summary>
<div className="toggle-row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.autoJoinCompetitors)}
onChange={(event) => setTaskForm({ ...taskForm, autoJoinCompetitors: event.target.checked })}
/>
Автодобавление аккаунтов в группы конкурентов
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.autoJoinOurGroup)}
onChange={(event) => setTaskForm({ ...taskForm, autoJoinOurGroup: event.target.checked })}
/>
Автодобавление аккаунтов в нашу группу
</label>
</div>
<div className="toggle-row">
<label className="checkbox">
<input
type="radio"
name="roleMode"
checked={roleMode === "split"}
onChange={() => applyRoleMode("split")}
/>
Разделить роли (конкуренты и наша группа разными аккаунтами)
</label>
<label className="checkbox">
<input
type="radio"
name="roleMode"
checked={roleMode === "same"}
onChange={() => applyRoleMode("same")}
/>
Один и тот же бот в конкурентах и нашей группе
</label>
</div>
<div className="status-text compact">
Режим один и тот же бот нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта.
</div>
</details>
<details className="section" open>
<summary className="section-title">Интервалы и лимиты</summary>
<div className="row">
<label>
<span className="label-line">Мин. интервал (мин) <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.minIntervalMinutes || ""}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, minIntervalMinutes: value === "" ? "" : Number(value) });
}}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
<label>
<span className="label-line">Макс. интервал (мин) <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.maxIntervalMinutes || ""}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxIntervalMinutes: value === "" ? "" : Number(value) });
}}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
<label>
<span className="label-line">Лимит в день <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.dailyLimit === "" ? "" : taskForm.dailyLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, dailyLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.dailyLimit);
setTaskForm({ ...taskForm, dailyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</span>
</label>
<label>
<span className="label-line">История сообщений (шт) <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.historyLimit === 0 ? "" : taskForm.historyLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, historyLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.historyLimit);
setTaskForm({ ...taskForm, historyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
</label>
<div className="limit-block">
<label>
<span className="label-line">Инвайтов за цикл <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.maxInvitesPerCycle === "" ? "" : taskForm.maxInvitesPerCycle}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxInvitesPerCycle: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxInvitesPerCycle);
setTaskForm({ ...taskForm, maxInvitesPerCycle: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">Это общий потолок на цикл. Пераккаунтные лимиты распределяют инвайты внутри этого числа.</span>
</label>
<div className="hint">
Итоговая формула: <strong>фактический лимит сегодня</strong> = min(дневной лимит задачи, разогрев).
Внутри цикла инвайты распределяются по аккаунтам согласно их лимитам.
</div>
<div className="status-caption">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</div>
<div className="status-caption">Сумма лимитов по аккаунтам: {perAccountInviteSum || "—"}</div>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.warmupEnabled)}
onChange={(event) => setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })}
/>
Разогрев лимита
<span
className="hint"
title="График: дни 13 — 1/д; 47 — 2/д; 812 — 3/д; 1318 — 4/д; 1925 — 5/д; 2633 — 6/д; с 34-го — 7/д. Итоговый лимит не превышает дневной лимит задачи."
>
Плавно увеличивает дневной лимит по дням.
</span>
<span className="hint">Нужен для прогрева новых аккаунтов и снижения риска флуда.</span>
<span className="hint">График: 1/д ×3, 2/д ×4, 3/д ×5, 4/д ×6, 5/д ×7, 6/д ×8, далее 7/д.</span>
</label>
{taskForm.warmupEnabled && (
<>
<label>
<span className="label-line">Стартовый лимит</span>
<input
type="number"
min="1"
value={taskForm.warmupStartLimit === "" ? "" : taskForm.warmupStartLimit}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupStartLimit: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupStartLimit);
setTaskForm({ ...taskForm, warmupStartLimit: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
</label>
<div className="hint">
Стартовый лимит применяется в первый день. Далее лимит увеличивается по графику прогрева: итог = Стартовый лимит + (ступень прогрева 1), но не выше дневного лимита задачи.
</div>
<label>
<span className="label-line">Прирост в день</span>
<input
type="number"
min="0"
value={taskForm.warmupDailyIncrease === "" ? "" : taskForm.warmupDailyIncrease}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, warmupDailyIncrease: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.warmupDailyIncrease);
setTaskForm({ ...taskForm, warmupDailyIncrease: Number.isFinite(value) && value >= 0 ? value : 0 });
}}
/>
</label>
</>
)}
</div>
</details>
</details>
<details className="section" open>
<summary className="section-title">Расширенные настройки</summary>
<details className="section">
<summary className="section-title">Инвайт через админов</summary>
<div className="status-text compact">
Шаги: 1) Включить режим 2) Выбрать мастерадмина 3) Проверить права
</div>
<div className="admin-invite-grid">
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteViaAdmins)}
onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
/>
Инвайтить через админов
<span className="hint">
Временно назначаем пользователя админом с правом Приглашать, затем снимаем права.
</span>
</label>
<div className="admin-invite-actions">
<button
type="button"
className="secondary"
onClick={() => checkInviteAccess("admin_block")}
disabled={!hasSelectedTask}
>
Проверить права
</button>
<span className={`status-pill ${inviteAccessStatus && inviteAccessStatus.length ? "ok" : "off"}`}>
{inviteAccessStatus && inviteAccessStatus.length
? `Права проверены${inviteAccessCheckedAt ? ` (${formatTimestamp(inviteAccessCheckedAt)})` : ""}${
inviteAccessStatus.every((item) => item.canInvite) ? " · OK" : " · есть ошибки"
}`
: "Нет проверки"}
</span>
</div>
{taskForm.inviteViaAdmins && !taskForm.inviteAdminMasterId && (
<div className="notice inline warn">
Не выбран мастерадмин. Инвайт через админов работать не будет.
</div>
)}
<label className="admin-invite-master">
<span className="label-line">Главный аккаунт</span>
<div className="input-row">
<select
value={taskForm.inviteAdminMasterId || ""}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
disabled={!taskForm.inviteViaAdmins}
>
<option value="">Не выбран</option>
{accounts.map((account) => (
<option key={`master-${account.id}`} value={account.id}>
{formatAccountLabel(account)}
</option>
))}
</select>
<button
type="button"
className="secondary"
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
onClick={async () => {
const account = accountById.get(taskForm.inviteAdminMasterId);
const username = account && account.username ? `@${account.username}` : "";
if (!username) {
showNotification("У выбранного аккаунта нет username.", "error");
return;
}
const ok = await copyToClipboard(username);
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
}}
>
Копировать username
</button>
</div>
<span className="hint">
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
</span>
</label>
<label className="checkbox admin-invite-toggle">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAnonymous)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAnonymous: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Делать админов анонимными
<span className="hint">
Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
</span>
</label>
<label className="checkbox admin-invite-flood">
<input
type="checkbox"
checked={Boolean(taskForm.inviteAdminAllowFlood)}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
disabled={!taskForm.inviteViaAdmins}
/>
Инвайтить в чаты с флудом
<span className="hint">
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
</span>
</label>
</div>
</details>
<details className="section">
<summary className="section-title">Распределение ботов</summary>
<div className="row">
{roleMode === "same" ? (
<label>
<span className="label-line">Ботов в обеих группах</span>
<input
type="number"
min="1"
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
onChange={(event) => {
const value = event.target.value;
if (value === "") {
setTaskForm({ ...taskForm, maxCompetitorBots: "", maxOurBots: "" });
return;
}
const nextValue = Number(value);
const nextForm = { ...taskForm, maxCompetitorBots: nextValue, maxOurBots: nextValue };
setTaskForm(sanitizeTaskForm(nextForm));
}}
onBlur={() => {
const value = Number(taskForm.maxCompetitorBots);
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
setTaskForm(sanitizeTaskForm({ ...taskForm, maxCompetitorBots: normalized, maxOurBots: normalized }));
}}
/>
<span className="hint">Одинаковое количество для конкурентов и нашей группы.</span>
</label>
) : (
<>
<label>
<span className="label-line">Ботов в конкурентах</span>
<input
type="number"
min="1"
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({
...taskForm,
maxCompetitorBots: value === "" ? "" : Number(value)
});
}}
onBlur={() => {
const value = Number(taskForm.maxCompetitorBots);
setTaskForm({ ...taskForm, maxCompetitorBots: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">Используется для авто-вступления в группы конкурентов.</span>
</label>
<label>
<span className="label-line">Ботов в нашей группе</span>
<input
type="number"
min="1"
value={taskForm.maxOurBots === "" ? "" : taskForm.maxOurBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxOurBots: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxOurBots);
setTaskForm({ ...taskForm, maxOurBots: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
<span className="hint">
Ограничивает аккаунты, которые будут приглашать.
</span>
</label>
</>
)}
</div>
<div className="row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.separateConfirmRoles)}
onChange={(event) => setTaskForm({ ...taskForm, separateConfirmRoles: event.target.checked })}
disabled={!taskForm.separateBotRoles}
/>
Подтверждение отдельными аккаунтами
<span className="hint">
Если включено, проверку участия выполняют отдельные аккаунты, не совпадающие с инвайтерами.
Ручные чекбоксы ролей в разделе Аккаунты имеют приоритет над автораспределением.
</span>
</label>
<label>
<span className="label-line">Ботов для подтверждения</span>
<input
type="number"
min="1"
value={taskForm.maxConfirmBots === "" ? "" : taskForm.maxConfirmBots}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxConfirmBots: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxConfirmBots);
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
setTaskForm({ ...taskForm, maxConfirmBots: normalized });
}}
disabled={!taskForm.separateBotRoles || !taskForm.separateConfirmRoles}
/>
<span className="hint">Используется при авто-разделении ролей.</span>
</label>
</div>
</details>
</details>
<details className="section">
<summary className="section-title">Дополнительные настройки</summary>
<details className="section" open={false}>
<summary className="section-title">Экспертные настройки</summary>
<div className="status-text compact">Не трогайте, если не уверены.</div>
<details className="section">
<summary className="section-title">Безопасность</summary>
<div className="toggle-row">
{!hasPerAccountInviteLimits && (
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.randomAccounts)}
onChange={(event) => setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
/>
Случайный выбор аккаунтов
<span className="hint">Инвайты распределяются случайно между доступными аккаунтами.</span>
</label>
)}
{!hasPerAccountInviteLimits && (
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.multiAccountsPerRun)}
onChange={(event) => setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
/>
Несколько аккаунтов за цикл
<span className="hint">Если выключено в каждом цикле используется один аккаунт.</span>
</label>
)}
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.retryOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
/>
Повторять при ошибке
<span className="hint">Повторяем до 2 раз при неудачном инвайте.</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.inviteLinkOnFail)}
onChange={(event) => setTaskForm({ ...taskForm, inviteLinkOnFail: event.target.checked })}
/>
Отправлять ссылку при USER_NOT_MUTUAL_CONTACT
<span className="hint">
Отправляет пользователю ссылку из поля Наша группа.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.stopOnBlocked)}
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
/>
Останавливать при блокировках
<span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span>
</label>
</div>
<details className="section">
<summary className="section-title">Очень редко</summary>
<div className="toggle-row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.allowStartWithoutInviteRights)}
onChange={(event) => setTaskForm({ ...taskForm, allowStartWithoutInviteRights: event.target.checked })}
/>
Разрешать запуск без прав инвайта
<span className="hint">Полезно, если вы выдаёте админов после автодобавления.</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.useWatcherInviteNoUsername)}
onChange={(event) => setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
/>
Инвайт через наблюдателя, если нет username
<span className="hint">
Правило 1: сначала резолвим @username в сессии инвайтера участие в группе конкурентов не требуется.
</span>
<span className="hint">
Правило 2: если username нет инвайтим наблюдателем, потому что access_hash валиден только в его сессии.
</span>
</label>
</div>
</details>
<div className="row">
<label>
<span className="label-line">Остановить при блоке, %</span>
<input
type="number"
min="1"
value={taskForm.stopBlockedPercent === "" ? "" : taskForm.stopBlockedPercent}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, stopBlockedPercent: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.stopBlockedPercent);
setTaskForm({ ...taskForm, stopBlockedPercent: Number.isFinite(value) && value > 0 ? value : 1 });
}}
disabled={!taskForm.stopOnBlocked}
/>
</label>
</div>
</details>
</details>
<details className="section">
<summary className="section-title">Импорт аудитории <span className="status-caption">необязательно</span></summary>
<div className="status-text compact">Используйте только если нужен импорт из файла или полный список участников.</div>
<div className="row-inline">
<button className="secondary" type="button" onClick={importInviteFile}>
Импортировать файл
</button>
{fileImportResult && (
<div className="status-text compact">
Импортировано: {fileImportResult.importedCount} · Пропущено: {fileImportResult.skippedCount} · Ошибок: {fileImportResult.failed.length}
</div>
)}
</div>
<details className="section">
<summary className="section-title">Дополнительные параметры</summary>
<div className="row">
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.parseParticipants)}
onChange={(event) => setTaskForm({ ...taskForm, parseParticipants: event.target.checked })}
/>
Собирать участников чатов конкурентов
<span className="hint">
Используется для закрытых участников и полного списка аудитории.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(taskForm.cycleCompetitors)}
onChange={(event) => setTaskForm({ ...taskForm, cycleCompetitors: event.target.checked })}
/>
Циклически обходить конкурентов
<span className="hint">
Мониторинг и сбор будут переключаться по группам по очереди.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={Boolean(fileImportForm.onlyIds)}
onChange={(event) => setFileImportForm({ ...fileImportForm, onlyIds: event.target.checked })}
/>
В файле только ID
<span className="hint">Если включено нужен источник (чат), из которого брались ID.</span>
</label>
<label>
<span className="label-line">Источник для ID</span>
<input
type="text"
placeholder="https://t.me/чат"
value={fileImportForm.sourceChat}
onChange={(event) => setFileImportForm({ ...fileImportForm, sourceChat: event.target.value })}
disabled={!fileImportForm.onlyIds}
/>
<span className="hint">Используется для резолва ID при инвайте.</span>
</label>
</div>
</details>
{fileImportResult && fileImportResult.failed.length > 0 && (
<div className="access-list">
{fileImportResult.failed.map((item, index) => (
<div key={`${item.path}-${index}`} className="access-row fail">
<div className="access-title">{item.path}</div>
<div className="access-error">{item.error}</div>
</div>
))}
</div>
)}
</details>
<details className="section">
<summary className="section-title">Ошибки аккаунтов</summary>
{criticalErrorAccounts.length === 0 && (
<div className="status-text compact">Ошибок нет.</div>
)}
{criticalErrorAccounts.length > 0 && (
<div className="access-list">
{criticalErrorAccounts.map((account) => (
<div key={account.id} className="access-row fail">
<div className="access-title">{formatAccountLabel(account)}</div>
<div className="access-error">{account.last_error || "Ошибка сессии"}</div>
</div>
))}
</div>
)}
</details>
<label>
<span className="label-line">Заметки</span>
<textarea
rows="2"
value={taskForm.notes}
onChange={(event) => setTaskForm({ ...taskForm, notes: event.target.value })}
/>
</label>
</details>
</div>
</details>
</div>
);
}

View File

@ -0,0 +1,147 @@
import React from "react";
export default function TasksSidebar({
createTask,
taskSearch,
setTaskSearch,
taskFilter,
setTaskFilter,
taskSort,
setTaskSort,
filteredTasks,
taskStatusMap,
selectedTaskId,
selectTask,
deleteTask,
hasSelectedTask,
formatCountdown,
formatTimestamp,
accountById,
formatAccountLabel
}) {
return (
<section className="card sticky">
<div className="row-header">
<h3>Задачи</h3>
<button className="ghost" type="button" onClick={createTask}>Новая задача</button>
</div>
<div className="task-controls">
<div className="task-search">
<input
type="text"
value={taskSearch}
onChange={(event) => setTaskSearch(event.target.value)}
placeholder="Поиск по названию или ссылке"
/>
</div>
<div className="task-filters">
<button
type="button"
className={`chip ${taskFilter === "all" ? "active" : ""}`}
onClick={() => setTaskFilter("all")}
>
Все
</button>
<button
type="button"
className={`chip ${taskFilter === "running" ? "active" : ""}`}
onClick={() => setTaskFilter("running")}
>
Запущены
</button>
<button
type="button"
className={`chip ${taskFilter === "stopped" ? "active" : ""}`}
onClick={() => setTaskFilter("stopped")}
>
Остановлены
</button>
</div>
<div className="row-inline">
<label className="select-inline">
<span>Сортировка</span>
<select value={taskSort} onChange={(event) => setTaskSort(event.target.value)}>
<option value="activity">Активные сверху</option>
<option value="queue">По очереди</option>
<option value="limit">По лимиту</option>
<option value="lastMessage">По последнему сообщению</option>
<option value="id">По ID</option>
</select>
</label>
</div>
</div>
<div className="task-list">
{filteredTasks.length === 0 && <div className="empty">Совпадений нет.</div>}
{filteredTasks.map((task) => {
const status = taskStatusMap[task.id];
const statusLabel = status ? (status.running ? "Запущено" : "Остановлено") : "—";
const statusClass = status ? (status.running ? "ok" : "off") : "off";
const unconfirmedCount = status ? Number(status.unconfirmedCount || 0) : 0;
const queueLabel = status ? `Очередь: ${status.queueCount}` : "Очередь: —";
const dailyLabel = status ? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}` : "Лимит сегодня: —";
const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —";
const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
? status.monitorInfo.lastMessageAt
: "";
const lastMessage = formatTimestamp(lastMessageRaw);
const lastSource = status && status.monitorInfo && status.monitorInfo.lastSource
? status.monitorInfo.lastSource
: "—";
const monitoring = Boolean(status && status.monitorInfo && status.monitorInfo.monitoring);
const monitorAccountIds = status && status.monitorInfo && status.monitorInfo.accountIds
? status.monitorInfo.accountIds
: (status && status.monitorInfo && status.monitorInfo.accountId ? [status.monitorInfo.accountId] : []);
const monitorLabels = monitorAccountIds
.map((id) => {
const account = accountById.get(id);
return account ? formatAccountLabel(account) : String(id);
})
.filter(Boolean);
const monitorLabel = monitorLabels.length
? (monitorLabels.length > 2 ? `${monitorLabels.length} аккаунта` : monitorLabels.join(", "))
: "—";
const tooltip = [
`Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`,
`Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}` : "—"}`,
`Мониторинг: ${monitoring ? "активен" : "нет"}`,
`Мониторит: ${monitorLabel}`,
`Последнее: ${lastMessage}`,
`Источник: ${lastSource}`
].join(" | ");
return (
<button
key={task.id}
type="button"
className={`task-item ${selectedTaskId === task.id ? "active" : ""}`}
onClick={() => selectTask(task.id)}
title={tooltip}
>
<div className="task-info">
<div className="task-title-row">
<div className="task-title">{task.name || `Задача #${task.id}`}</div>
<div className="task-badge-row">
<div className={`task-badge ${statusClass}`}>{statusLabel}</div>
{unconfirmedCount > 0 && (
<div className="task-badge warn" title={`Не подтверждено: ${unconfirmedCount}`}>
Не подтверждено: {unconfirmedCount}
</div>
)}
</div>
</div>
<div className="task-meta-row">
<span className="task-meta">{queueLabel}</span>
<span className="task-meta">{dailyLabel}</span>
<span className="task-meta">{cycleLabel}</span>
</div>
</div>
</button>
);
})}
</div>
<div className="sidebar-actions">
<button className="danger" type="button" onClick={deleteTask} disabled={!hasSelectedTask}>Удалить задачу</button>
</div>
</section>
);
}

View File

@ -0,0 +1,51 @@
import React from "react";
export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
if (!testRun) return null;
const { status, mode, steps, startedAt, finishedAt, summary } = testRun;
const statusLabel = status === "running"
? "В процессе"
: status === "ok"
? "Готово"
: status === "warn"
? "С предупреждениями"
: status === "error"
? "Ошибки"
: "Не запускался";
return (
<section className="card">
<div className="row-header">
<h3>Тестовый прогон</h3>
<div className="row-inline">
<button className="secondary" type="button" onClick={onRunSafe}>Safe</button>
<button className="secondary" type="button" onClick={onRunLive}>Live</button>
</div>
</div>
<div className="status-banner">
Статус: <strong>{statusLabel}</strong>{mode ? ` · режим: ${mode}` : ""}{summary ? ` · ${summary}` : ""}
</div>
{(startedAt || finishedAt) && (
<div className="status-caption">
Старт: {startedAt || "—"} · Завершение: {finishedAt || "—"}
</div>
)}
<div className="log-table">
<div className="log-head">
<span>Шаг</span>
<span>Статус</span>
<span>Детали</span>
</div>
{steps.length === 0 && (
<div className="log-empty">Прогон ещё не запускался.</div>
)}
{steps.map((step, index) => (
<div className="log-row" key={`${step.key || step.title}-${index}`}>
<div>{step.title}</div>
<div>{step.status}</div>
<div>{step.details || "—"}</div>
</div>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,18 @@
import React from "react";
export default function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null;
return (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`toast ${toast.tone || "info"}`}>
<span>{toast.text}{toast.count > 1 ? ` (x${toast.count})` : ""}</span>
<button type="button" className="ghost" onClick={() => onDismiss(toast)}>
</button>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,51 @@
export default function useAccessChecks({
selectedTaskId,
setAccessStatus,
setInviteAccessStatus,
setInviteAccessCheckedAt,
setTaskNotice,
showNotification
}) {
const checkAccess = async (source = "editor", silent = false) => {
if (!window.api || selectedTaskId == null) {
if (!silent) showNotification("Сначала выберите задачу.", "error");
return;
}
if (!silent) showNotification("Проверяем доступ к группам...", "info");
try {
const result = await window.api.checkAccessByTask(selectedTaskId);
if (!result.ok) {
if (!silent) showNotification(result.error || "Не удалось проверить доступ", "error");
return;
}
setAccessStatus(result.result || []);
if (!silent) setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
} catch (error) {
if (!silent) showNotification(error.message || String(error), "error");
}
};
const checkInviteAccess = async (source = "editor", silent = false) => {
if (!window.api || selectedTaskId == null) {
if (!silent) showNotification("Сначала выберите задачу.", "error");
return;
}
setInviteAccessStatus([]);
setInviteAccessCheckedAt("");
if (!silent) showNotification("Проверяем права инвайта...", "info");
try {
const result = await window.api.checkInviteAccessByTask(selectedTaskId);
if (!result.ok) {
if (!silent) showNotification(result.error || "Не удалось проверить права", "error");
return;
}
setInviteAccessStatus(result.result || []);
setInviteAccessCheckedAt(new Date().toISOString());
if (!silent) setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source });
} catch (error) {
if (!silent) showNotification(error.message || String(error), "error");
}
};
return { checkAccess, checkInviteAccess };
}

View File

@ -0,0 +1,110 @@
import { useMemo } from "react";
export default function useAccountComputed({
accounts,
accountStats,
taskAccountRoles,
accountAssignments,
selectedTaskId,
tasks
}) {
const accountById = useMemo(() => {
const map = new Map();
accounts.forEach((account) => {
map.set(account.id, account);
});
return map;
}, [accounts]);
const accountStatsMap = useMemo(() => {
const map = new Map();
(accountStats || []).forEach((item) => {
map.set(item.id, item);
});
return map;
}, [accountStats]);
const roleSummary = useMemo(() => {
const monitor = [];
const invite = [];
const confirm = [];
Object.entries(taskAccountRoles).forEach(([id, roles]) => {
const accountId = Number(id);
if (roles.monitor) monitor.push(accountId);
if (roles.invite) invite.push(accountId);
if (roles.confirm) confirm.push(accountId);
});
return { monitor, invite, confirm };
}, [taskAccountRoles]);
const roleIntersectionCount = useMemo(() => {
let count = 0;
Object.values(taskAccountRoles).forEach((roles) => {
if (roles.monitor && roles.invite) count += 1;
});
return count;
}, [taskAccountRoles]);
const assignedAccountCount = useMemo(() => {
const ids = new Set([...roleSummary.monitor, ...roleSummary.invite, ...roleSummary.confirm]);
return ids.size;
}, [roleSummary]);
const assignedAccountMap = useMemo(() => {
const map = new Map();
accountAssignments.forEach((row) => {
const list = map.get(row.account_id) || [];
list.push({
taskId: row.task_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite),
roleConfirm: row.role_confirm != null ? Boolean(row.role_confirm) : Boolean(row.role_invite),
inviteLimit: Number(row.invite_limit || 0)
});
map.set(row.account_id, list);
});
return map;
}, [accountAssignments]);
const filterFreeAccounts = tasks.length > 1;
const accountBuckets = useMemo(() => {
const selected = selectedTaskId;
const freeOrSelected = [];
const busy = [];
const taskNameMap = new Map();
tasks.forEach((task) => {
taskNameMap.set(task.id, task.name || `Задача #${task.id}`);
});
accounts.forEach((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || [];
const assignedToSelected = selected != null && assignedTasks.some((item) => item.taskId === selected);
const isFree = assignedTasks.length === 0;
if (filterFreeAccounts && !isFree && !assignedToSelected) {
busy.push(account);
} else {
freeOrSelected.push(account);
}
});
return { freeOrSelected, busy, taskNameMap };
}, [accounts, assignedAccountMap, selectedTaskId, filterFreeAccounts, tasks]);
const perAccountInviteSum = useMemo(() => {
return Object.values(taskAccountRoles || {}).reduce((sum, roles) => {
if (!roles || !roles.invite) return sum;
const value = Number(roles.inviteLimit || 0);
return value > 0 ? sum + value : sum;
}, 0);
}, [taskAccountRoles]);
return {
accountById,
accountStatsMap,
roleSummary,
roleIntersectionCount,
assignedAccountCount,
assignedAccountMap,
filterFreeAccounts,
accountBuckets,
perAccountInviteSum
};
}

View File

@ -0,0 +1,165 @@
import { useState } from "react";
export default function useAccountImport({
selectedTaskId,
hasSelectedTask,
assignAccountsToTask,
setAccounts,
showNotification,
explainTdataError,
setTdataNotice
}) {
const [loginForm, setLoginForm] = useState({
apiId: "",
apiHash: "",
phone: "",
code: "",
password: ""
});
const [tdataForm, setTdataForm] = useState({
apiId: "2040",
apiHash: "b18441a1ff607e10a989891a5462e627"
});
const [loginId, setLoginId] = useState("");
const [loginStatus, setLoginStatus] = useState("");
const [tdataResult, setTdataResult] = useState(null);
const [tdataLoading, setTdataLoading] = useState(false);
const startLogin = async () => {
if (!window.api) {
setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (selectedTaskId == null) {
setLoginStatus("Сначала выберите задачу.");
showNotification("Сначала выберите задачу.", "error");
return;
}
setLoginStatus("Отправляем код...");
showNotification("Отправляем код...", "info");
try {
const result = await window.api.startLogin({
apiId: loginForm.apiId,
apiHash: loginForm.apiHash,
phone: loginForm.phone
});
setLoginId(result.loginId);
setLoginStatus("Код отправлен. Введите код для входа.");
showNotification("Код отправлен. Введите код для входа.", "success");
} catch (error) {
const message = error.message || String(error);
setLoginStatus(message);
showNotification(message, "error");
}
};
const completeLogin = async () => {
if (!window.api) {
setLoginStatus("Electron API недоступен. Откройте приложение в Electron.");
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (selectedTaskId == null) {
setLoginStatus("Сначала выберите задачу.");
showNotification("Сначала выберите задачу.", "error");
return;
}
setLoginStatus("Завершаем вход...");
showNotification("Завершаем вход...", "info");
const result = await window.api.completeLogin({
loginId,
code: loginForm.code,
password: loginForm.password
});
if (result.ok) {
setLoginStatus("Аккаунт добавлен.");
setLoginId("");
setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
await assignAccountsToTask([result.accountId].filter(Boolean));
setAccounts(await window.api.listAccounts());
return;
}
if (result.error === "DUPLICATE_ACCOUNT") {
setLoginStatus("Аккаунт уже добавлен. Привязан к задаче.");
setLoginId("");
setLoginForm({ apiId: "", apiHash: "", phone: "", code: "", password: "" });
await assignAccountsToTask([result.accountId].filter(Boolean));
setAccounts(await window.api.listAccounts());
return;
}
if (result.error === "PASSWORD_REQUIRED") {
setLoginStatus("Нужен пароль 2FA. Введите пароль.");
showNotification("Нужен пароль 2FA. Введите пароль.", "info");
return;
}
setLoginStatus(result.error || "Ошибка входа");
showNotification(result.error || "Ошибка входа", "error");
};
const importTdata = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
showNotification("Импортируем tdata...", "info");
setTdataLoading(true);
try {
const result = await window.api.importTdata({
apiId: tdataForm.apiId,
apiHash: tdataForm.apiHash,
taskId: selectedTaskId || undefined
});
if (result && result.canceled) return;
if (!result.ok) {
const hint = explainTdataError(result.error || "");
showNotification(hint ? `${result.error}. ${hint}` : (result.error || "Ошибка импорта tdata"), "error");
return;
}
setTdataResult(result);
const importedCount = (result.imported || []).length;
const skippedCount = (result.skipped || []).length;
const failedCount = (result.failed || []).length;
const importedIds = (result.imported || []).map((item) => item.accountId).filter(Boolean);
const skippedIds = (result.skipped || []).map((item) => item.accountId).filter(Boolean);
if ((importedIds.length || skippedIds.length) && hasSelectedTask) {
await assignAccountsToTask([...importedIds, ...skippedIds]);
}
if (result.authKeyDuplicatedCount) {
setTdataNotice({
text: `AUTH_KEY_DUPLICATED: ${result.authKeyDuplicatedCount}. Сессии сброшены после импорта.`,
tone: "warn"
});
} else if (importedCount > 0) {
setTdataNotice({ text: `Импортировано аккаунтов: ${importedCount}`, tone: "success" });
} else if (skippedCount > 0 && failedCount === 0) {
setTdataNotice({ text: `Пропущено дубликатов: ${skippedCount}`, tone: "success" });
}
if (failedCount > 0) {
showNotification(`Не удалось импортировать: ${failedCount}`, "error");
}
setAccounts(await window.api.listAccounts());
} catch (error) {
showNotification(error.message || String(error), "error");
} finally {
setTdataLoading(false);
}
};
return {
loginForm,
setLoginForm,
tdataForm,
setTdataForm,
loginStatus,
tdataResult,
tdataLoading,
startLogin,
completeLogin,
importTdata
};
}

View File

@ -0,0 +1,243 @@
export default function useAccountManagement({
selectedTaskId,
taskAccountRoles,
setTaskAccountRoles,
selectedAccountIds,
setSelectedAccountIds,
accounts,
accountBuckets,
taskForm,
hasSelectedTask,
loadAccountAssignments,
showNotification,
setTaskNotice,
setAccounts
}) {
const persistAccountRoles = async (next) => {
if (!window.api || selectedTaskId == null) return;
const rolePayload = Object.entries(next).map(([id, roles]) => ({
accountId: Number(id),
roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite),
roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite),
inviteLimit: Number(roles.inviteLimit || 0)
}));
await window.api.appendTaskAccounts({
taskId: selectedTaskId,
accountRoles: rolePayload
});
await window.api.addAccountEvent({
accountId: 0,
phone: "",
action: "roles_changed",
details: `задача ${selectedTaskId}: обновлены роли`
});
await loadAccountAssignments();
};
const updateAccountRole = (accountId, role, value) => {
const next = { ...taskAccountRoles };
const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
let inviteLimit = existing.inviteLimit || 0;
if (role === "invite" && value && inviteLimit === 0) {
inviteLimit = 1;
}
next[accountId] = { ...existing, [role]: value, inviteLimit };
if (!next[accountId].monitor && !next[accountId].invite && !next[accountId].confirm) {
delete next[accountId];
}
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
};
const updateAccountInviteLimit = (accountId, value) => {
const next = { ...taskAccountRoles };
const existing = next[accountId] || { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
next[accountId] = { ...existing, inviteLimit: value };
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
};
const setAccountRolesAll = (accountId, value) => {
const next = { ...taskAccountRoles };
if (value) {
const existing = next[accountId] || { inviteLimit: 0 };
next[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
} else {
delete next[accountId];
}
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
};
const applyRolePreset = (type) => {
if (!hasSelectedTask) return;
const availableIds = selectedAccountIds.length
? selectedAccountIds
: accountBuckets.freeOrSelected.map((account) => account.id);
if (!availableIds.length) {
showNotification("Нет доступных аккаунтов для назначения.", "error");
return;
}
const next = {};
if (type === "all") {
availableIds.forEach((id) => {
const existing = taskAccountRoles[id] || {};
next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
});
} else if (type === "one") {
const id = availableIds[0];
const existing = taskAccountRoles[id] || {};
next[id] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
} else if (type === "split") {
const monitorCount = Math.max(1, Number(taskForm.maxCompetitorBots || 1));
const inviteCount = Math.max(1, Number(taskForm.maxOurBots || 1));
const monitorIds = availableIds.slice(0, monitorCount);
const inviteIds = availableIds.slice(monitorCount, monitorCount + inviteCount);
const confirmCount = taskForm.separateConfirmRoles ? Math.max(1, Number(taskForm.maxConfirmBots || 1)) : 0;
const confirmIds = taskForm.separateConfirmRoles
? availableIds.slice(monitorCount + inviteCount, monitorCount + inviteCount + confirmCount)
: [];
monitorIds.forEach((id) => {
const existing = taskAccountRoles[id] || {};
next[id] = { monitor: true, invite: false, confirm: false, inviteLimit: existing.inviteLimit || 0 };
});
inviteIds.forEach((id) => {
const existing = taskAccountRoles[id] || {};
next[id] = { monitor: false, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
});
confirmIds.forEach((id) => {
const existing = taskAccountRoles[id] || {};
next[id] = { monitor: false, invite: false, confirm: true, inviteLimit: existing.inviteLimit || 0 };
});
if (inviteIds.length < inviteCount) {
showNotification("Не хватает аккаунтов для роли инвайта.", "error");
}
if (taskForm.separateConfirmRoles && confirmIds.length < confirmCount) {
showNotification("Не хватает аккаунтов для роли подтверждения.", "error");
}
}
const ids = Object.keys(next).map((id) => Number(id));
setTaskAccountRoles(next);
setSelectedAccountIds(ids);
persistAccountRoles(next);
const label = type === "one" ? "Один бот" : type === "split" ? "Разделить роли" : "Все роли";
setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "accounts" });
};
const assignAccountsToTask = async (accountIds) => {
if (!window.api || selectedTaskId == null) return;
if (!accountIds.length) return;
const nextRoles = { ...taskAccountRoles };
accountIds.forEach((accountId) => {
if (!nextRoles[accountId]) {
nextRoles[accountId] = { monitor: true, invite: true, confirm: true };
}
});
const rolePayload = Object.entries(nextRoles).map(([accountId, roles]) => ({
accountId: Number(accountId),
roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite),
roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite)
}));
const result = await window.api.appendTaskAccounts({
taskId: selectedTaskId,
accountRoles: rolePayload
});
if (result && result.ok) {
setTaskAccountRoles(nextRoles);
setSelectedAccountIds(Object.keys(nextRoles).map((id) => Number(id)));
await loadAccountAssignments();
}
};
const moveAccountToTask = async (accountId) => {
if (!window.api || selectedTaskId == null) return;
await assignAccountsToTask([accountId]);
setTaskNotice({ text: "Аккаунт добавлен в задачу.", tone: "success", source: "accounts" });
};
const removeAccountFromTask = async (accountId) => {
if (!window.api || selectedTaskId == null) return;
const result = await window.api.removeTaskAccount({
taskId: selectedTaskId,
accountId
});
if (result && result.ok) {
setTaskAccountRoles((prev) => {
const next = { ...prev };
delete next[accountId];
setSelectedAccountIds(Object.keys(next).map((id) => Number(id)));
return next;
});
await loadAccountAssignments();
setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" });
}
};
const resetCooldown = async (accountId) => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.resetAccountCooldown(accountId);
const updated = await window.api.listAccounts();
setAccounts(updated);
setTaskNotice({ text: "Аккаунт снова активен.", tone: "success", source: "accounts" });
} catch (error) {
setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
showNotification(error.message || String(error), "error");
}
};
const deleteAccount = async (accountId) => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.deleteAccount(accountId);
setAccounts(await window.api.listAccounts());
setTaskNotice({ text: "Аккаунт удален.", tone: "success", source: "accounts" });
} catch (error) {
setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
showNotification(error.message || String(error), "error");
}
};
const refreshIdentity = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.refreshAccountIdentity();
setAccounts(await window.api.listAccounts());
setTaskNotice({ text: "ID аккаунтов обновлены.", tone: "success", source: "accounts" });
} catch (error) {
setTaskNotice({ text: error.message || String(error), tone: "error", source: "accounts" });
showNotification(error.message || String(error), "error");
}
};
return {
persistAccountRoles,
resetCooldown,
deleteAccount,
refreshIdentity,
updateAccountRole,
updateAccountInviteLimit,
setAccountRolesAll,
applyRolePreset,
assignAccountsToTask,
moveAccountToTask,
removeAccountFromTask
};
}

View File

@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { emptySettings, emptyTaskForm } from "../appDefaults.js";
export default function useAppDataState() {
const [settings, setSettings] = useState(emptySettings);
const [accounts, setAccounts] = useState([]);
const [accountStats, setAccountStats] = useState([]);
const [accountAssignments, setAccountAssignments] = useState([]);
const [globalStatus, setGlobalStatus] = useState({ connectedSessions: 0, totalAccounts: 0 });
const [logs, setLogs] = useState([]);
const [invites, setInvites] = useState([]);
const [fallbackList, setFallbackList] = useState([]);
const [confirmQueue, setConfirmQueue] = useState([]);
const [tasks, setTasks] = useState([]);
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [taskForm, setTaskForm] = useState(emptyTaskForm);
const [competitorText, setCompetitorText] = useState("");
const [selectedAccountIds, setSelectedAccountIds] = useState([]);
const [taskAccountRoles, setTaskAccountRoles] = useState({});
const [activePreset, setActivePreset] = useState("");
const presetSignatureRef = useRef("");
const [taskStatus, setTaskStatus] = useState({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
cycleCompetitors: false,
competitorCursor: 0,
monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: "",
nextInviteAccountId: 0,
lastInviteAccountId: 0,
pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 },
warnings: [],
lastStopReason: "",
lastStopAt: ""
});
const [taskStatusMap, setTaskStatusMap] = useState({});
const [membershipStatus, setMembershipStatus] = useState({});
const [groupVisibility, setGroupVisibility] = useState([]);
const [accessStatus, setAccessStatus] = useState([]);
const [inviteAccessStatus, setInviteAccessStatus] = useState([]);
const [inviteAccessCheckedAt, setInviteAccessCheckedAt] = useState("");
const [accountEvents, setAccountEvents] = useState([]);
const [taskAudit, setTaskAudit] = useState([]);
const [testRun, setTestRun] = useState({
status: "idle",
mode: "safe",
steps: [],
startedAt: "",
finishedAt: "",
summary: ""
});
const [queueItems, setQueueItems] = useState([]);
const [queueStats, setQueueStats] = useState({ total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 });
const [fileImportForm, setFileImportForm] = useState({
onlyIds: false,
sourceChat: ""
});
const [fileImportResult, setFileImportResult] = useState(null);
const [taskActionLoading, setTaskActionLoading] = useState(false);
const [taskNotice, setTaskNotice] = useState(null);
const [autosaveNote, setAutosaveNote] = useState("");
const [settingsNotice, setSettingsNotice] = useState(null);
const [tdataNotice, setTdataNotice] = useState(null);
return {
settings,
setSettings,
accounts,
setAccounts,
accountStats,
setAccountStats,
accountAssignments,
setAccountAssignments,
globalStatus,
setGlobalStatus,
logs,
setLogs,
invites,
setInvites,
fallbackList,
setFallbackList,
confirmQueue,
setConfirmQueue,
tasks,
setTasks,
selectedTaskId,
setSelectedTaskId,
taskForm,
setTaskForm,
competitorText,
setCompetitorText,
selectedAccountIds,
setSelectedAccountIds,
taskAccountRoles,
setTaskAccountRoles,
activePreset,
setActivePreset,
presetSignatureRef,
taskStatus,
setTaskStatus,
taskStatusMap,
setTaskStatusMap,
membershipStatus,
setMembershipStatus,
groupVisibility,
setGroupVisibility,
accessStatus,
setAccessStatus,
inviteAccessStatus,
setInviteAccessStatus,
inviteAccessCheckedAt,
setInviteAccessCheckedAt,
accountEvents,
setAccountEvents,
taskAudit,
setTaskAudit,
testRun,
setTestRun,
queueItems,
setQueueItems,
queueStats,
setQueueStats,
fileImportForm,
setFileImportForm,
fileImportResult,
setFileImportResult,
taskActionLoading,
setTaskActionLoading,
taskNotice,
setTaskNotice,
autosaveNote,
setAutosaveNote,
settingsNotice,
setSettingsNotice,
tdataNotice,
setTdataNotice
};
}

View File

@ -0,0 +1,88 @@
import { useCallback } from "react";
export default function useAppLoaders({
selectedTaskId,
setTasks,
setSelectedTaskId,
setAccountAssignments,
setTaskStatusMap,
setSettings,
setAccounts,
setAccountEvents,
setAccountStats,
setGlobalStatus
}) {
const loadTasks = useCallback(async () => {
const tasksData = await window.api.listTasks();
setTasks(tasksData);
if (!tasksData.length) {
setSelectedTaskId(null);
return tasksData;
}
if (selectedTaskId == null) {
setSelectedTaskId(tasksData[0].id);
return tasksData;
}
if (!tasksData.some((task) => task.id === selectedTaskId)) {
setSelectedTaskId(tasksData[0].id);
}
return tasksData;
}, [selectedTaskId, setSelectedTaskId, setTasks]);
const loadAccountAssignments = useCallback(async () => {
if (!window.api) return;
const assignments = await window.api.listAccountAssignments();
setAccountAssignments(assignments || []);
}, [setAccountAssignments]);
const loadTaskStatuses = useCallback(async (tasksData) => {
const entries = await Promise.all(
(tasksData || []).map(async (task) => {
const status = await window.api.taskStatus(task.id);
return [task.id, status];
})
);
const map = {};
entries.forEach(([id, status]) => {
map[id] = status;
});
setTaskStatusMap(map);
}, [setTaskStatusMap]);
const loadBase = useCallback(async () => {
if (!window.api) return;
const [settingsData, accountsData, eventsData, statusData] = await Promise.all([
window.api.getSettings(),
window.api.listAccounts(),
window.api.listAccountEvents(200),
window.api.getStatus()
]);
setSettings(settingsData);
setAccounts(accountsData);
setAccountEvents(eventsData);
setAccountStats(statusData.accountStats || []);
setGlobalStatus({
connectedSessions: statusData.connectedSessions || 0,
totalAccounts: statusData.totalAccounts || 0
});
const tasksData = await loadTasks();
await loadAccountAssignments();
await loadTaskStatuses(tasksData);
}, [
loadAccountAssignments,
loadTaskStatuses,
loadTasks,
setAccountEvents,
setAccountStats,
setAccounts,
setGlobalStatus,
setSettings
]);
return {
loadTasks,
loadAccountAssignments,
loadTaskStatuses,
loadBase
};
}

View File

@ -0,0 +1,152 @@
import useTaskLifecycle from "./useTaskLifecycle.js";
import useTaskStatusSync from "./useTaskStatusSync.js";
import useNotices from "./useNotices.js";
import useAutosave from "./useAutosave.js";
import useAppPolling from "./useAppPolling.js";
export default function useAppOrchestration({
activeTab,
selectedTaskId,
isVisible,
setIsVisible,
setNow,
tasksPollInFlight,
accountsPollInFlight,
logsPollInFlight,
eventsPollInFlight,
setTasks,
loadTaskStatuses,
setTaskStatus,
setAccounts,
setAccountAssignments,
setAccountStats,
setGlobalStatus,
setLogs,
setInvites,
setFallbackList,
setConfirmQueue,
setTaskAudit,
setAccountEvents,
setQueueItems,
setQueueStats,
loadBase,
loadSelectedTask,
setAccessStatus,
setInviteAccessStatus,
setMembershipStatus,
setTaskNotice,
setActivePreset,
checkAccess,
checkInviteAccess,
activePreset,
taskForm,
taskAccountRoles,
presetSignatureRef,
taskStatus,
setTaskStatusMap,
checklistStats,
setChecklistOpen,
hasSelectedTask,
roleIntersectionCount,
roleSummary,
setTaskForm,
sanitizeTaskForm,
taskNotice,
settingsNotice,
tdataNotice,
setSettingsNotice,
setTdataNotice,
showNotification,
settings,
settingsAutosaveReady,
taskAutosaveReady,
taskAutosaveTimer,
competitorText,
selectedAccountIds,
canSaveTask,
saveTask,
setSettings
}) {
useAppPolling({
activeTab,
selectedTaskId,
isVisible,
setIsVisible,
setNow,
tasksPollInFlight,
accountsPollInFlight,
logsPollInFlight,
eventsPollInFlight,
setTasks,
loadTaskStatuses,
setTaskStatus,
setAccounts,
setAccountAssignments,
setAccountStats,
setGlobalStatus,
setLogs,
setInvites,
setFallbackList,
setConfirmQueue,
setTaskAudit,
setAccountEvents,
setQueueItems,
setQueueStats
});
useTaskLifecycle({
loadBase,
selectedTaskId,
loadSelectedTask,
setAccessStatus,
setInviteAccessStatus,
setMembershipStatus,
setTaskNotice,
setActivePreset,
checkAccess,
checkInviteAccess,
activePreset,
taskForm,
taskAccountRoles,
presetSignatureRef
});
useTaskStatusSync({
selectedTaskId,
taskStatus,
setTaskStatusMap,
checklistStats,
setChecklistOpen,
hasSelectedTask,
taskForm,
roleIntersectionCount,
roleSummary,
setTaskForm,
sanitizeTaskForm
});
useNotices({
taskNotice,
setTaskNotice,
settingsNotice,
setSettingsNotice,
tdataNotice,
setTdataNotice,
showNotification
});
useAutosave({
settings,
settingsAutosaveReady,
taskAutosaveReady,
taskAutosaveTimer,
taskForm,
competitorText,
taskAccountRoles,
selectedAccountIds,
canSaveTask,
saveTask,
setSettings,
showNotification
});
}

View File

@ -0,0 +1,22 @@
import useOutsideClick from "./useOutsideClick.js";
export default function useAppOutsideClicks({
notificationsOpen,
notificationsModalRef,
bellRef,
setNotificationsOpen,
moreActionsOpen,
moreActionsRef,
setMoreActionsOpen
}) {
useOutsideClick({
enabled: notificationsOpen,
refs: [notificationsModalRef, bellRef],
onOutside: () => setNotificationsOpen(false)
});
useOutsideClick({
enabled: moreActionsOpen,
refs: [moreActionsRef],
onOutside: () => setMoreActionsOpen(false)
});
}

View File

@ -0,0 +1,132 @@
import { useEffect } from "react";
export default function useAppPolling({
activeTab,
selectedTaskId,
isVisible,
setIsVisible,
setNow,
tasksPollInFlight,
accountsPollInFlight,
logsPollInFlight,
eventsPollInFlight,
setTasks,
loadTaskStatuses,
setTaskStatus,
setAccounts,
setAccountAssignments,
setAccountStats,
setGlobalStatus,
setLogs,
setInvites,
setFallbackList,
setConfirmQueue,
setTaskAudit,
setAccountEvents,
setQueueItems,
setQueueStats
}) {
useEffect(() => {
if (!window.api) return undefined;
const load = async () => {
if (!isVisible || tasksPollInFlight.current) return;
tasksPollInFlight.current = true;
try {
const tasksData = await window.api.listTasks();
setTasks(tasksData);
await loadTaskStatuses(tasksData);
if (selectedTaskId != null) {
setTaskStatus(await window.api.taskStatus(selectedTaskId));
}
} finally {
tasksPollInFlight.current = false;
}
};
load();
const interval = setInterval(async () => {
await load();
}, 5000);
return () => clearInterval(interval);
}, [selectedTaskId, isVisible]);
useEffect(() => {
if (!window.api) return undefined;
const load = async () => {
if (!isVisible || accountsPollInFlight.current) return;
accountsPollInFlight.current = true;
try {
setAccounts(await window.api.listAccounts());
setAccountAssignments(await window.api.listAccountAssignments());
const statusData = await window.api.getStatus();
setAccountStats(statusData.accountStats || []);
setGlobalStatus({
connectedSessions: statusData.connectedSessions || 0,
totalAccounts: statusData.totalAccounts || 0
});
} finally {
accountsPollInFlight.current = false;
}
};
load();
const interval = setInterval(load, 15000);
return () => clearInterval(interval);
}, [isVisible]);
useEffect(() => {
if (!window.api || (activeTab !== "logs" && activeTab !== "queue") || selectedTaskId == null) return undefined;
const load = async () => {
if (!isVisible || logsPollInFlight.current) return;
logsPollInFlight.current = true;
try {
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId: selectedTaskId }));
setTaskAudit(await window.api.listTaskAudit(selectedTaskId));
const queueData = await window.api.listQueue({ limit: 200, taskId: selectedTaskId });
if (queueData && queueData.items) setQueueItems(queueData.items);
if (queueData && queueData.stats) setQueueStats(queueData.stats);
} finally {
logsPollInFlight.current = false;
}
};
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, [activeTab, selectedTaskId, isVisible]);
useEffect(() => {
if (!window.api || activeTab !== "events") return undefined;
const load = async () => {
if (!isVisible || eventsPollInFlight.current) return;
eventsPollInFlight.current = true;
try {
setAccountEvents(await window.api.listAccountEvents(200));
} finally {
eventsPollInFlight.current = false;
}
};
load();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, [activeTab, isVisible]);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [setNow]);
useEffect(() => {
const handleVisibility = () => {
setIsVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibility);
window.addEventListener("focus", handleVisibility);
window.addEventListener("blur", handleVisibility);
return () => {
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("focus", handleVisibility);
window.removeEventListener("blur", handleVisibility);
};
}, [setIsVisible]);
}

View File

@ -0,0 +1,9 @@
import useAppDataState from "./useAppDataState.js";
import useAppUiState from "./useAppUiState.js";
export default function useAppState() {
return {
...useAppDataState(),
...useAppUiState()
};
}

View File

@ -0,0 +1,283 @@
export default function useAppTabGroups({
selectedTaskName,
taskForm,
setTaskForm,
activePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
competitorText,
setCompetitorText,
applyRoleMode,
normalizeIntervals,
taskStatus,
perAccountInviteSum,
hasSelectedTask,
inviteAccessStatus,
inviteAccessCheckedAt,
formatTimestamp,
checkInviteAccess,
accounts,
showNotification,
copyToClipboard,
sanitizeTaskForm,
hasPerAccountInviteLimits,
fileImportResult,
importInviteFile,
fileImportForm,
setFileImportForm,
criticalErrorAccounts,
accountStatsMap,
settings,
membershipStatus,
assignedAccountMap,
accountBuckets,
filterFreeAccounts,
selectedAccountIds,
taskAccountRoles,
inviteAdminMasterId,
refreshMembership,
refreshIdentity,
formatAccountStatus,
resetCooldown,
deleteAccount,
updateAccountRole,
updateAccountInviteLimit,
setAccountRolesAll,
applyRolePreset,
removeAccountFromTask,
moveAccountToTask,
logsTab,
setLogsTab,
exportLogs,
clearLogs,
exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearInvites,
logSearch,
setLogSearch,
logPage,
setLogPage,
logPageCount,
pagedLogs,
inviteSearch,
setInviteSearch,
invitePage,
setInvitePage,
invitePageCount,
inviteFilter,
setInviteFilter,
pagedInvites,
fallbackSearch,
setFallbackSearch,
fallbackPage,
setFallbackPage,
fallbackPageCount,
pagedFallback,
confirmQueue,
confirmSearch,
setConfirmSearch,
confirmPage,
setConfirmPage,
confirmPageCount,
pagedConfirmQueue,
queueItems,
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue,
clearConfirmQueue,
auditSearch,
setAuditSearch,
auditPage,
setAuditPage,
auditPageCount,
pagedAudit,
explainInviteError,
expandedInviteId,
setExpandedInviteId,
inviteStats,
invites,
selectedTask,
accessStatus,
roleSummary,
mutualContactDiagnostics,
accountById,
formatAccountLabel,
accountEvents,
clearAccountEvents,
onSettingsChange,
saveSettings
}) {
const taskSettings = {
selectedTaskName,
taskForm,
setTaskForm,
activePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
competitorText,
setCompetitorText,
roleMode: taskForm.requireSameBotInBoth ? "same" : "split",
applyRoleMode,
normalizeIntervals,
taskStatus,
perAccountInviteSum,
hasSelectedTask,
inviteAccessStatus,
inviteAccessCheckedAt,
formatTimestamp,
checkInviteAccess,
accounts,
showNotification,
copyToClipboard,
sanitizeTaskForm,
hasPerAccountInviteLimits,
fileImportResult,
importInviteFile,
fileImportForm,
setFileImportForm,
criticalErrorAccounts
};
const accountsTab = {
accounts,
accountStatsMap,
settings,
membershipStatus,
assignedAccountMap,
accountBuckets,
filterFreeAccounts,
selectedAccountIds,
taskAccountRoles,
rolesMode: taskForm.rolesMode,
setRolesMode: (mode) => {
setTaskForm((prev) => ({ ...prev, rolesMode: mode }));
if (mode === "auto") {
applyRolePreset("split");
}
},
hasSelectedTask,
inviteAdminMasterId,
refreshMembership,
refreshIdentity,
formatAccountStatus,
formatAccountLabel,
resetCooldown,
deleteAccount,
updateAccountRole,
updateAccountInviteLimit,
setAccountRolesAll,
applyRolePreset,
removeAccountFromTask,
moveAccountToTask
};
const logsTabGroup = {
logsTab,
setLogsTab,
hasSelectedTask,
exportLogs,
clearLogs,
exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearInvites,
logSearch,
setLogSearch,
logPage,
setLogPage,
logPageCount,
pagedLogs,
inviteSearch,
setInviteSearch,
invitePage,
setInvitePage,
invitePageCount,
inviteFilter,
setInviteFilter,
pagedInvites,
fallbackSearch,
setFallbackSearch,
fallbackPage,
setFallbackPage,
fallbackPageCount,
pagedFallback,
confirmQueue,
confirmSearch,
setConfirmSearch,
confirmPage,
setConfirmPage,
confirmPageCount,
pagedConfirmQueue,
queueItems,
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue,
clearConfirmQueue,
auditSearch,
setAuditSearch,
auditPage,
setAuditPage,
auditPageCount,
pagedAudit,
explainInviteError,
expandedInviteId,
setExpandedInviteId,
inviteStats,
invites,
selectedTask,
accessStatus,
inviteAccessStatus,
selectedTaskName,
roleSummary,
mutualContactDiagnostics,
accountById,
formatAccountLabel
};
const queueTabGroup = {
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue
};
const eventsTab = {
accountEvents,
formatTimestamp,
onClearEvents: clearAccountEvents,
accountById,
formatAccountLabel
};
const settingsTab = {
settings,
onSettingsChange,
saveSettings
};
return {
taskSettings,
accountsTab,
logsTab: logsTabGroup,
queueTab: queueTabGroup,
eventsTab,
settingsTab
};
}

View File

@ -0,0 +1,32 @@
import { useMemo } from "react";
export default function useAppTaskDerived({
tasks,
selectedTaskId,
taskForm,
competitorText
}) {
const competitorGroups = useMemo(() => {
return competitorText
.split("\\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}, [competitorText]);
const hasSelectedTask = selectedTaskId != null;
const selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
const selectedTaskName = selectedTask ? (selectedTask.name || `Задача #${selectedTask.id}`) : "—";
const canSaveTask = Boolean(
taskForm.name.trim() &&
taskForm.ourGroup.trim() &&
competitorGroups.length > 0
);
return {
competitorGroups,
hasSelectedTask,
selectedTask,
selectedTaskName,
canSaveTask
};
}

View File

@ -0,0 +1,83 @@
import { useRef, useState } from "react";
export default function useAppUiState() {
const [notificationsOpen, setNotificationsOpen] = useState(false);
const notificationsModalRef = useRef(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [nowExpanded, setNowExpanded] = useState(false);
const [moreActionsOpen, setMoreActionsOpen] = useState(false);
const moreActionsRef = useRef(null);
const [checklistOpen, setChecklistOpen] = useState(true);
const [manualLoginOpen, setManualLoginOpen] = useState(false);
const [taskSearch, setTaskSearch] = useState("");
const [taskFilter, setTaskFilter] = useState("all");
const [infoOpen, setInfoOpen] = useState(false);
const [infoTab, setInfoTab] = useState("usage");
const [activeTab, setActiveTab] = useState("events");
const [logsTab, setLogsTab] = useState("logs");
const [taskSort, setTaskSort] = useState("activity");
const [expandedInviteId, setExpandedInviteId] = useState(null);
const [liveConfirmOpen, setLiveConfirmOpen] = useState(false);
const [liveConfirmContext, setLiveConfirmContext] = useState(null);
const [now, setNow] = useState(Date.now());
const [isVisible, setIsVisible] = useState(!document.hidden);
const bellRef = useRef(null);
const settingsAutosaveReady = useRef(false);
const taskAutosaveReady = useRef(false);
const taskAutosaveTimer = useRef(null);
const autosaveNoteTimer = useRef(null);
const tasksPollInFlight = useRef(false);
const accountsPollInFlight = useRef(false);
const logsPollInFlight = useRef(false);
const eventsPollInFlight = useRef(false);
return {
notificationsOpen,
setNotificationsOpen,
notificationsModalRef,
importModalOpen,
setImportModalOpen,
nowExpanded,
setNowExpanded,
moreActionsOpen,
setMoreActionsOpen,
moreActionsRef,
checklistOpen,
setChecklistOpen,
manualLoginOpen,
setManualLoginOpen,
taskSearch,
setTaskSearch,
taskFilter,
setTaskFilter,
infoOpen,
setInfoOpen,
infoTab,
setInfoTab,
activeTab,
setActiveTab,
logsTab,
setLogsTab,
taskSort,
setTaskSort,
expandedInviteId,
setExpandedInviteId,
liveConfirmOpen,
setLiveConfirmOpen,
liveConfirmContext,
setLiveConfirmContext,
now,
setNow,
isVisible,
setIsVisible,
bellRef,
settingsAutosaveReady,
taskAutosaveReady,
taskAutosaveTimer,
autosaveNoteTimer,
tasksPollInFlight,
accountsPollInFlight,
logsPollInFlight,
eventsPollInFlight
};
}

View File

@ -0,0 +1,45 @@
import { useEffect } from "react";
export default function useAutosave({
settings,
settingsAutosaveReady,
taskAutosaveReady,
taskAutosaveTimer,
taskForm,
competitorText,
taskAccountRoles,
selectedAccountIds,
canSaveTask,
saveTask,
setSettings,
showNotification
}) {
useEffect(() => {
if (!settingsAutosaveReady.current) {
settingsAutosaveReady.current = true;
return;
}
if (!window.api) return;
const timer = setTimeout(async () => {
try {
const updated = await window.api.saveSettings(settings);
setSettings(updated);
} catch (error) {
showNotification(error.message || String(error), "error");
}
}, 600);
return () => clearTimeout(timer);
}, [settings]);
useEffect(() => {
if (!taskAutosaveReady.current) return;
if (!canSaveTask) return;
if (taskAutosaveTimer.current) {
clearTimeout(taskAutosaveTimer.current);
}
taskAutosaveTimer.current = setTimeout(() => {
saveTask("autosave", { silent: true });
}, 800);
return () => clearTimeout(taskAutosaveTimer.current);
}, [taskForm, competitorText, taskAccountRoles, selectedAccountIds, canSaveTask]);
}

View File

@ -0,0 +1,25 @@
import { useMemo } from "react";
export default function useCriticalEvents({ accountEvents, accounts }) {
const criticalEvents = useMemo(() => {
const criticalTypes = new Set([
"connect_failed",
"invite_failed",
"invite_user_invalid",
"monitor_handler_error",
"flood"
]);
const now = Date.now();
return (accountEvents || []).filter((event) => {
const ts = new Date(event.createdAt).getTime();
const recent = Number.isFinite(ts) ? (now - ts) < 24 * 60 * 60 * 1000 : true;
return criticalTypes.has(event.eventType) && recent;
});
}, [accountEvents]);
const criticalErrorAccounts = useMemo(() => {
return accounts.filter((account) => account.status && account.status !== "ok");
}, [accounts]);
return { criticalEvents, criticalErrorAccounts };
}

View File

@ -0,0 +1,50 @@
export default function useInviteImport({
fileImportForm,
setFileImportForm,
setFileImportResult,
hasSelectedTask,
selectedTaskId,
showNotification,
setInvites,
setFallbackList,
loadTaskStatuses
}) {
const importInviteFile = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (!hasSelectedTask) {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (fileImportForm.onlyIds && !fileImportForm.sourceChat.trim()) {
showNotification("Для файла только с ID нужен источник.", "error");
return;
}
try {
const result = await window.api.importInviteFile({
taskId: selectedTaskId,
onlyIds: fileImportForm.onlyIds,
sourceChat: fileImportForm.sourceChat
});
if (result && result.canceled) return;
if (!result.ok) {
showNotification(result.error || "Ошибка импорта файла", "error");
return;
}
setFileImportResult(result);
setFileImportForm({ onlyIds: false, sourceChat: "" });
showNotification("Файл импортирован.", "success");
const invitesData = await window.api.listInvites(selectedTaskId);
setInvites(invitesData);
const fallbackData = await window.api.listFallbackInvites(selectedTaskId);
setFallbackList(fallbackData);
await loadTaskStatuses();
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
return { importInviteFile };
}

View File

@ -0,0 +1,262 @@
import { useMemo, useState } from "react";
const createPager = (items, page, pageSize) => {
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
const start = (page - 1) * pageSize;
const end = start + pageSize;
return { pageCount, paged: items.slice(start, end) };
};
export default function useLogsView({
logs,
invites,
fallbackList,
taskAudit,
confirmQueue,
queueItems
}) {
const [logSearch, setLogSearch] = useState("");
const [inviteSearch, setInviteSearch] = useState("");
const [fallbackSearch, setFallbackSearch] = useState("");
const [auditSearch, setAuditSearch] = useState("");
const [confirmSearch, setConfirmSearch] = useState("");
const [logPage, setLogPage] = useState(1);
const [invitePage, setInvitePage] = useState(1);
const [fallbackPage, setFallbackPage] = useState(1);
const [auditPage, setAuditPage] = useState(1);
const [confirmPage, setConfirmPage] = useState(1);
const [inviteFilter, setInviteFilter] = useState("all");
const [queueSearch, setQueueSearch] = useState("");
const [queuePage, setQueuePage] = useState(1);
const filteredLogs = useMemo(() => {
const query = logSearch.trim().toLowerCase();
if (!query) return logs;
return logs.filter((log) => {
const text = [
log.startedAt,
log.finishedAt,
String(log.invitedCount),
(log.successIds || []).join(","),
(log.errors || []).join("|")
]
.join(" ")
.toLowerCase();
return text.includes(query);
});
}, [logs, logSearch]);
const filteredInvites = useMemo(() => {
const query = inviteSearch.trim().toLowerCase();
return invites.filter((invite) => {
if (inviteFilter === "success" && invite.status !== "success") return false;
if (inviteFilter === "error" && invite.status !== "failed") return false;
if (inviteFilter === "skipped" && invite.status !== "skipped") return false;
if (inviteFilter === "unconfirmed" && invite.status !== "unconfirmed") return false;
const text = [
invite.invitedAt,
invite.userId,
invite.username,
invite.sourceChat,
invite.accountPhone,
invite.watcherPhone,
invite.strategy,
invite.strategyMeta,
invite.error,
invite.skippedReason,
invite.confirmError
]
.join(" ")
.toLowerCase();
if (!query) return true;
return text.includes(query);
});
}, [invites, inviteSearch, inviteFilter]);
const filteredFallback = useMemo(() => {
const query = fallbackSearch.trim().toLowerCase();
return fallbackList.filter((item) => {
const text = [
item.userId,
item.username,
item.reason,
item.route,
item.sourceChat,
item.targetChat,
item.status,
item.createdAt
]
.join(" ")
.toLowerCase();
if (!query) return true;
return text.includes(query);
});
}, [fallbackList, fallbackSearch]);
const filteredConfirmQueue = useMemo(() => {
const query = confirmSearch.trim().toLowerCase();
if (!query) return confirmQueue;
return confirmQueue.filter((item) => {
const text = [
item.user_id,
item.username,
item.account_id,
item.watcher_account_id,
item.last_error,
item.next_check_at
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return text.includes(query);
});
}, [confirmQueue, confirmSearch]);
const filteredAudit = useMemo(() => {
const query = auditSearch.trim().toLowerCase();
if (!query) return taskAudit;
return taskAudit.filter((item) => {
const text = [item.action, item.details, item.createdAt].join(" ").toLowerCase();
return text.includes(query);
});
}, [taskAudit, auditSearch]);
const filteredQueue = useMemo(() => {
const query = queueSearch.trim().toLowerCase();
if (!query) return queueItems || [];
return (queueItems || []).filter((item) => {
const text = [
item.user_id,
item.username,
item.source_chat,
item.watcher_account_id,
item.attempts,
item.created_at
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return text.includes(query);
});
}, [queueItems, queueSearch]);
const inviteStrategyStats = useMemo(() => {
let success = 0;
let failed = 0;
invites.forEach((invite) => {
if (!invite.strategyMeta) return;
try {
const parsed = JSON.parse(invite.strategyMeta);
if (!Array.isArray(parsed) || !parsed.length) return;
const hasOk = parsed.some((item) => item.ok);
if (hasOk) success += 1;
else failed += 1;
} catch (error) {
// ignore parse errors
}
});
return { success, failed };
}, [invites]);
const inviteStats = useMemo(() => {
const stats = {
total: invites.length,
success: 0,
failed: 0,
skipped: 0,
unconfirmed: 0
};
invites.forEach((invite) => {
switch (invite.status) {
case "success":
stats.success += 1;
break;
case "failed":
stats.failed += 1;
break;
case "skipped":
stats.skipped += 1;
break;
case "unconfirmed":
stats.unconfirmed += 1;
break;
default:
break;
}
});
return stats;
}, [invites]);
const logPageSize = 20;
const invitePageSize = 20;
const fallbackPageSize = 20;
const auditPageSize = 20;
const confirmPageSize = 20;
const queuePageSize = 20;
const { pageCount: logPageCount, paged: pagedLogs } = createPager(filteredLogs, logPage, logPageSize);
const { pageCount: invitePageCount, paged: pagedInvites } = createPager(filteredInvites, invitePage, invitePageSize);
const { pageCount: fallbackPageCount, paged: pagedFallback } = createPager(filteredFallback, fallbackPage, fallbackPageSize);
const { pageCount: auditPageCount, paged: pagedAudit } = createPager(filteredAudit, auditPage, auditPageSize);
const { pageCount: confirmPageCount, paged: pagedConfirmQueue } = createPager(filteredConfirmQueue, confirmPage, confirmPageSize);
const { pageCount: queuePageCount, paged: pagedQueue } = createPager(filteredQueue, queuePage, queuePageSize);
const mutualContactDiagnostics = useMemo(() => {
const items = invites
.filter((invite) => invite.error === "USER_NOT_MUTUAL_CONTACT")
.slice()
.sort((a, b) => (b.invitedAt || "").localeCompare(a.invitedAt || ""));
return {
count: items.length,
recent: items.slice(0, 5)
};
}, [invites]);
return {
logSearch,
setLogSearch,
inviteSearch,
setInviteSearch,
fallbackSearch,
setFallbackSearch,
auditSearch,
setAuditSearch,
confirmSearch,
setConfirmSearch,
logPage,
setLogPage,
invitePage,
setInvitePage,
fallbackPage,
setFallbackPage,
auditPage,
setAuditPage,
confirmPage,
setConfirmPage,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
inviteFilter,
setInviteFilter,
filteredLogs,
filteredInvites,
filteredFallback,
filteredAudit,
filteredConfirmQueue,
logPageCount,
invitePageCount,
fallbackPageCount,
auditPageCount,
confirmPageCount,
queuePageCount,
pagedLogs,
pagedInvites,
pagedFallback,
pagedAudit,
pagedConfirmQueue,
pagedQueue,
inviteStrategyStats,
inviteStats,
mutualContactDiagnostics
};
}

View File

@ -0,0 +1,100 @@
export default function useMainUiProps({
selectedTaskName,
autosaveNote,
taskStatus,
hasSelectedTask,
canSaveTask,
taskActionLoading,
saveTask,
parseHistory,
joinGroupsForTask,
checkAll,
startTask,
stopTask,
moreActionsOpen,
setMoreActionsOpen,
moreActionsRef,
clearQueue,
startAllTasks,
stopAllTasks,
clearDatabase,
resetSessions,
pauseReason,
setActiveTab,
tasksLength,
runTestSafe,
nowLine,
nowExpanded,
setNowExpanded,
primaryIssue,
openFixTab,
monitorLabels,
inviteLabels,
roleSummary,
groupVisibility,
lastEvents,
formatTimestamp,
checklistOpen,
setChecklistOpen,
checklistStats,
checklistItems,
activeTab,
logsTab,
setLogsTab
}) {
const quickActions = {
selectedTaskName,
autosaveNote,
taskStatus,
hasSelectedTask,
canSaveTask,
taskActionLoading,
saveTask,
parseHistory,
joinGroupsForTask,
checkAll,
startTask,
stopTask,
moreActionsOpen,
setMoreActionsOpen,
moreActionsRef,
clearQueue,
startAllTasks,
stopAllTasks,
clearDatabase,
resetSessions,
pauseReason,
setActiveTab,
tasksLength,
runTestSafe
};
const nowStatus = {
nowLine,
nowExpanded,
setNowExpanded,
primaryIssue,
openFixTab,
monitorLabels,
inviteLabels,
roleSummary,
taskStatus,
groupVisibility,
lastEvents,
formatTimestamp
};
const checklist = {
checklistOpen,
setChecklistOpen,
checklistStats,
checklistItems,
setActiveTab
};
const tabs = {
activeTab,
setActiveTab,
logsTab,
setLogsTab
};
return { quickActions, nowStatus, checklist, tabs };
}

View File

@ -0,0 +1,29 @@
import { useEffect } from "react";
export default function useNotices({
taskNotice,
setTaskNotice,
settingsNotice,
setSettingsNotice,
tdataNotice,
setTdataNotice,
showNotification
}) {
useEffect(() => {
if (!taskNotice) return;
showNotification(taskNotice.text, taskNotice.tone || "info");
setTaskNotice(null);
}, [taskNotice]);
useEffect(() => {
if (!settingsNotice) return;
showNotification(settingsNotice.text, settingsNotice.tone || "info");
setSettingsNotice(null);
}, [settingsNotice]);
useEffect(() => {
if (!tdataNotice) return;
showNotification(tdataNotice.text, tdataNotice.tone || "info");
setTdataNotice(null);
}, [tdataNotice]);
}

View File

@ -0,0 +1,66 @@
import { useMemo, useRef, useState } from "react";
export default function useNotifications() {
const [toasts, setToasts] = useState([]);
const toastTimers = useRef(new Map());
const [notifications, setNotifications] = useState([]);
const [notificationFilter, setNotificationFilter] = useState("all");
const showNotification = (text, tone = "info") => {
if (!text) return;
const key = `${tone}|${text}`;
const now = Date.now();
setNotifications((prev) => {
const existingIndex = prev.findIndex((item) => item.key === key);
if (existingIndex >= 0) {
const updated = { ...prev[existingIndex], count: (prev[existingIndex].count || 1) + 1, lastAt: now };
const next = [updated, ...prev.filter((_, index) => index !== existingIndex)];
return next.slice(0, 20);
}
const entry = { text, tone, id: `${now}-${Math.random().toString(36).slice(2)}`, key, count: 1, lastAt: now };
return [entry, ...prev].slice(0, 20);
});
setToasts((prev) => {
const existingIndex = prev.findIndex((item) => item.key === key);
if (existingIndex >= 0) {
const updated = { ...prev[existingIndex], count: (prev[existingIndex].count || 1) + 1, lastAt: now };
const next = [updated, ...prev.filter((_, index) => index !== existingIndex)];
return next.slice(0, 4);
}
const entry = { text, tone, id: `${now}-${Math.random().toString(36).slice(2)}`, key, count: 1, lastAt: now };
return [entry, ...prev].slice(0, 4);
});
if (toastTimers.current.has(key)) {
clearTimeout(toastTimers.current.get(key));
}
const timeoutId = setTimeout(() => {
setToasts((prev) => prev.filter((item) => item.key !== key));
toastTimers.current.delete(key);
}, 6000);
toastTimers.current.set(key, timeoutId);
};
const dismissToast = (toast) => {
setToasts((prev) => prev.filter((item) => item.id !== toast.id));
if (toast.key && toastTimers.current.has(toast.key)) {
clearTimeout(toastTimers.current.get(toast.key));
toastTimers.current.delete(toast.key);
}
};
const filteredNotifications = useMemo(() => {
if (notificationFilter === "all") return notifications;
return notifications.filter((item) => item.tone === notificationFilter);
}, [notifications, notificationFilter]);
return {
toasts,
notifications,
setNotifications,
notificationFilter,
setNotificationFilter,
filteredNotifications,
showNotification,
dismissToast
};
}

View File

@ -0,0 +1,9 @@
import { useEffect } from "react";
export default function useOpenLogsTabListener(setActiveTab) {
useEffect(() => {
const handleOpenLogs = () => setActiveTab("logs");
window.addEventListener("openLogsTab", handleOpenLogs);
return () => window.removeEventListener("openLogsTab", handleOpenLogs);
}, [setActiveTab]);
}

View File

@ -0,0 +1,19 @@
import { useEffect } from "react";
export default function useOutsideClick({
enabled,
refs = [],
onOutside
}) {
useEffect(() => {
if (!enabled) return;
const handleClickOutside = (event) => {
const isInside = refs.some((ref) => ref && ref.current && ref.current.contains(event.target));
if (!isInside) {
onOutside();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [enabled, onOutside, refs]);
}

View File

@ -0,0 +1,33 @@
export default function useSettingsActions({
settings,
setSettings,
setSettingsNotice,
showNotification
}) {
const onSettingsChange = (field, value) => {
setSettings((prev) => ({
...prev,
[field]: value
}));
};
const saveSettings = async () => {
if (!window.api) {
setSettingsNotice({ text: "Electron API недоступен. Откройте приложение в Electron.", tone: "error" });
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
showNotification("Сохраняем настройки...", "info");
const updated = await window.api.saveSettings(settings);
setSettings(updated);
setSettingsNotice({ text: "Настройки сохранены.", tone: "success" });
} catch (error) {
const message = error.message || String(error);
setSettingsNotice({ text: message, tone: "error" });
showNotification(message, "error");
}
};
return { onSettingsChange, saveSettings };
}

View File

@ -0,0 +1,312 @@
export default function useTabProps(
taskSettings,
accountsTab,
logsTab,
queueTab,
eventsTab,
settingsTab
) {
const {
selectedTaskName,
taskForm,
setTaskForm,
activePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
competitorText,
setCompetitorText,
roleMode,
applyRoleMode,
normalizeIntervals,
taskStatus,
perAccountInviteSum,
hasSelectedTask,
inviteAccessStatus,
inviteAccessCheckedAt,
formatTimestamp,
checkInviteAccess,
accounts,
showNotification,
copyToClipboard,
sanitizeTaskForm,
hasPerAccountInviteLimits,
fileImportResult,
importInviteFile,
fileImportForm,
setFileImportForm,
criticalErrorAccounts
} = taskSettings;
const {
accountStatsMap,
settings,
membershipStatus,
assignedAccountMap,
accountBuckets,
filterFreeAccounts,
selectedAccountIds,
taskAccountRoles,
rolesMode,
setRolesMode,
inviteAdminMasterId,
refreshMembership,
refreshIdentity,
formatAccountStatus,
resetCooldown,
deleteAccount,
updateAccountRole,
updateAccountInviteLimit,
setAccountRolesAll,
applyRolePreset,
removeAccountFromTask,
moveAccountToTask
} = accountsTab;
const {
logsTab: logsTabName,
setLogsTab,
exportLogs,
clearLogs,
exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearInvites,
logSearch,
setLogSearch,
logPage,
setLogPage,
logPageCount,
pagedLogs,
inviteSearch,
setInviteSearch,
invitePage,
setInvitePage,
invitePageCount,
inviteFilter,
setInviteFilter,
pagedInvites,
fallbackSearch,
setFallbackSearch,
fallbackPage,
setFallbackPage,
fallbackPageCount,
pagedFallback,
confirmQueue,
confirmSearch,
setConfirmSearch,
confirmPage,
setConfirmPage,
confirmPageCount,
pagedConfirmQueue,
queueItems,
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue,
clearConfirmQueue,
auditSearch,
setAuditSearch,
auditPage,
setAuditPage,
auditPageCount,
pagedAudit,
explainInviteError,
expandedInviteId,
setExpandedInviteId,
inviteStats,
invites,
selectedTask,
accessStatus,
roleSummary,
mutualContactDiagnostics,
accountById,
formatAccountLabel
} = logsTab;
const {
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue
} = queueTab;
const {
accountEvents,
onClearEvents
} = eventsTab;
const {
onSettingsChange,
saveSettings
} = settingsTab;
const taskSettingsProps = {
selectedTaskName,
taskForm,
setTaskForm,
activePreset,
applyTaskPreset,
formatAccountLabel,
accountById,
competitorText,
setCompetitorText,
roleMode,
applyRoleMode,
normalizeIntervals,
taskStatus,
perAccountInviteSum,
hasSelectedTask,
inviteAccessStatus,
inviteAccessCheckedAt,
formatTimestamp,
checkInviteAccess,
accounts,
showNotification,
copyToClipboard,
sanitizeTaskForm,
hasPerAccountInviteLimits,
fileImportResult,
importInviteFile,
fileImportForm,
setFileImportForm,
criticalErrorAccounts
};
const accountsTabProps = {
accounts,
accountStatsMap,
settings,
membershipStatus,
assignedAccountMap,
accountBuckets,
filterFreeAccounts,
selectedAccountIds,
taskAccountRoles,
rolesMode,
setRolesMode,
hasSelectedTask,
inviteAdminMasterId,
refreshMembership,
refreshIdentity,
formatAccountStatus,
formatAccountLabel,
resetCooldown,
deleteAccount,
updateAccountRole,
updateAccountInviteLimit,
setAccountRolesAll,
applyRolePreset,
removeAccountFromTask,
moveAccountToTask
};
const logsTabProps = {
logsTab: logsTabName,
setLogsTab,
hasSelectedTask,
exportLogs,
clearLogs,
exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearInvites,
logSearch,
setLogSearch,
logPage,
setLogPage,
logPageCount,
pagedLogs,
inviteSearch,
setInviteSearch,
invitePage,
setInvitePage,
invitePageCount,
inviteFilter,
setInviteFilter,
pagedInvites,
fallbackSearch,
setFallbackSearch,
fallbackPage,
setFallbackPage,
fallbackPageCount,
pagedFallback,
confirmQueue,
confirmSearch,
setConfirmSearch,
confirmPage,
setConfirmPage,
confirmPageCount,
pagedConfirmQueue,
queueItems,
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue,
clearConfirmQueue,
auditSearch,
setAuditSearch,
auditPage,
setAuditPage,
auditPageCount,
pagedAudit,
formatTimestamp,
explainInviteError,
expandedInviteId,
setExpandedInviteId,
inviteStats,
invites,
selectedTask,
taskAccountRoles,
accessStatus,
inviteAccessStatus,
selectedTaskName,
roleSummary,
mutualContactDiagnostics,
accountById,
formatAccountLabel
};
const queueTabProps = {
hasSelectedTask,
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue,
accountById,
formatAccountLabel,
formatTimestamp
};
const eventsTabProps = {
accountEvents,
formatTimestamp,
onClearEvents,
accountById,
formatAccountLabel
};
const settingsTabProps = {
settings,
onSettingsChange,
saveSettings
};
return {
taskSettingsProps,
accountsTabProps,
logsTabProps,
queueTabProps,
eventsTabProps,
settingsTabProps
};
}

View File

@ -0,0 +1,588 @@
import { useRef } from "react";
export default function useTaskActions({
taskForm,
setTaskForm,
sanitizeTaskForm,
taskAccountRoles,
setTaskAccountRoles,
selectedAccountIds,
setSelectedAccountIds,
accounts,
selectedTaskId,
selectedTaskName,
competitorGroups,
hasSelectedTask,
setTaskNotice,
showNotification,
setAutosaveNote,
autosaveNoteTimer,
loadTasks,
loadAccountAssignments,
loadTaskStatuses,
refreshMembership,
checkInviteAccess,
checkAccess,
setLogs,
setInvites,
setTaskStatus,
setSelectedTaskId,
resetTaskForm,
setCompetitorText,
resetSelectedAccountIds,
resetTaskAccountRoles,
setFallbackList,
setConfirmQueue,
setAccountEvents,
setTaskActionLoading,
taskActionLoading,
loadBase,
createTask,
setActiveTab
}) {
const withTimeout = (promise, ms) => (
Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), ms))
])
);
const saveTask = async (source = "editor", options = {}) => {
const silent = Boolean(options.silent);
if (!window.api) {
if (!silent) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
}
return;
}
try {
if (!silent) {
showNotification("Сохраняем задачу...", "info");
}
const nextForm = sanitizeTaskForm(taskForm);
setTaskForm(nextForm);
const validateLink = (value) => {
const trimmed = String(value || "").trim();
if (!trimmed) return false;
if (trimmed.startsWith("@")) return true;
if (trimmed.startsWith("https://t.me/")) return true;
if (trimmed.startsWith("http://t.me/")) return true;
return false;
};
const invalidCompetitors = competitorGroups.filter((link) => !validateLink(link));
if (!validateLink(nextForm.ourGroup)) {
showNotification("Наша группа должна быть ссылкой t.me или @username.", "error");
return;
}
if (invalidCompetitors.length) {
showNotification(`Некорректные ссылки конкурентов: ${invalidCompetitors.join(", ")}`, "error");
return;
}
let accountRolesMap = { ...taskAccountRoles };
let accountIds = Object.keys(accountRolesMap).map((id) => Number(id));
if (nextForm.requireSameBotInBoth) {
const required = Math.max(1, Number(nextForm.maxCompetitorBots || 1));
const pool = (selectedAccountIds && selectedAccountIds.length ? selectedAccountIds : accounts.map((account) => account.id))
.filter((id) => Number.isFinite(id));
const chosen = pool.slice(0, required);
accountRolesMap = {};
chosen.forEach((accountId) => {
const existing = accountRolesMap[accountId] || {};
accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
});
accountIds = chosen;
setTaskAccountRoles(accountRolesMap);
setSelectedAccountIds(chosen);
}
if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) {
accountIds = accounts.map((account) => account.id);
accountRolesMap = {};
accountIds.forEach((accountId) => {
const existing = accountRolesMap[accountId] || {};
accountRolesMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: existing.inviteLimit || 1 };
});
setTaskAccountRoles(accountRolesMap);
setSelectedAccountIds(accountIds);
if (accountIds.length && !silent) {
setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
}
}
if (!accountIds.length) {
if (!silent) {
showNotification("Нет аккаунтов для этой задачи.", "error");
}
return;
}
const roleEntries = Object.values(accountRolesMap);
if (roleEntries.length) {
const hasMonitor = roleEntries.some((item) => item.monitor);
const hasInvite = roleEntries.some((item) => item.invite);
const hasConfirm = roleEntries.some((item) => item.confirm);
if (!hasMonitor) {
if (!silent) {
showNotification("Нужен хотя бы один аккаунт с ролью мониторинга.", "error");
}
return;
}
if (!hasInvite) {
if (!silent) {
showNotification("Нужен хотя бы один аккаунт с ролью инвайта.", "error");
}
return;
}
if (nextForm.separateConfirmRoles && !hasConfirm) {
if (!silent) {
showNotification("Нужен хотя бы один аккаунт с ролью подтверждения.", "error");
}
return;
}
} else {
const requiredAccounts = nextForm.requireSameBotInBoth
? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
: nextForm.separateBotRoles
? Math.max(1, Number(nextForm.maxCompetitorBots || 1))
+ Math.max(1, Number(nextForm.maxOurBots || 1))
+ (nextForm.separateConfirmRoles ? Math.max(1, Number(nextForm.maxConfirmBots || 1)) : 0)
: 1;
if (accountIds.length < requiredAccounts) {
if (!silent) {
showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error");
}
return;
}
}
const accountRoles = Object.entries(accountRolesMap).map(([id, roles]) => ({
accountId: Number(id),
roleMonitor: Boolean(roles.monitor),
roleInvite: Boolean(roles.invite),
roleConfirm: Boolean(roles.confirm != null ? roles.confirm : roles.invite),
inviteLimit: Number(roles.inviteLimit || 0)
}));
const result = await window.api.saveTask({
task: nextForm,
competitors: competitorGroups,
accountIds,
accountRoles
});
if (result.ok) {
if (!silent) {
setTaskNotice({ text: "Задача сохранена.", tone: "success", source });
} else {
setAutosaveNote("Автосохранено");
if (autosaveNoteTimer.current) {
clearTimeout(autosaveNoteTimer.current);
}
autosaveNoteTimer.current = setTimeout(() => {
setAutosaveNote("");
}, 1500);
}
await loadTasks();
await loadAccountAssignments();
setSelectedTaskId(result.taskId);
} else {
if (!silent) {
showNotification(result.error || "Не удалось сохранить задачу", "error");
}
}
} catch (error) {
if (!silent) {
showNotification(error.message || String(error), "error");
}
}
};
const deleteTask = async () => {
if (!window.api || selectedTaskId == null) {
return;
}
try {
await window.api.deleteTask(selectedTaskId);
setTaskNotice({ text: "Задача удалена.", tone: "success", source: "tasks" });
const tasksData = await loadTasks();
await loadAccountAssignments();
if (!tasksData.length) {
createTask();
setActiveTab("task");
}
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const startTask = async (source = "sidebar") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (taskActionLoading) return;
setTaskActionLoading(true);
showNotification("Запуск...", "info");
try {
const result = await withTimeout(window.api.startTaskById(selectedTaskId), 15000);
if (result && result.ok) {
setTaskNotice({ text: "Запущено.", tone: "success", source });
if (result.warnings && result.warnings.length) {
showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info");
}
await refreshMembership("start_task");
checkInviteAccess("auto", true);
} else {
showNotification(result.error || "Не удалось запустить", "error");
}
} catch (error) {
const message = error.message === "TIMEOUT"
? "Запуск не ответил за 15 секунд. Проверьте логи/события и попробуйте снова."
: (error.message || String(error));
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
} finally {
setTaskActionLoading(false);
}
};
const startAllTasks = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
showNotification("Запускаем все задачи...", "info");
try {
const result = await window.api.startAllTasks();
if (result && result.errors && result.errors.length) {
const errorText = result.errors.map((item) => `${item.id}: ${item.error}`).join(" | ");
showNotification(`Ошибки запуска: ${errorText}`, "error");
}
const tasksData = await loadTasks();
await loadTaskStatuses(tasksData);
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const stopTask = async (source = "sidebar") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (taskActionLoading) return;
if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) {
return;
}
setTaskActionLoading(true);
showNotification("Остановка...", "info");
try {
await withTimeout(window.api.stopTaskById(selectedTaskId), 15000);
setTaskNotice({ text: "Остановлено.", tone: "success", source });
} catch (error) {
const message = error.message === "TIMEOUT"
? "Остановка не ответила за 15 секунд. Проверьте логи/события и попробуйте снова."
: (error.message || String(error));
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
} finally {
setTaskActionLoading(false);
}
};
const stopAllTasks = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (!window.confirm("Остановить все задачи?")) {
return;
}
showNotification("Останавливаем все задачи...", "info");
try {
await window.api.stopAllTasks();
const tasksData = await loadTasks();
await loadTaskStatuses(tasksData);
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const parseHistory = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Собираем историю...", "info");
try {
const result = await window.api.parseHistoryByTask(selectedTaskId);
if (result && result.ok) {
setTaskNotice({ text: "История добавлена в очередь.", tone: "success", source });
if (result.errors && result.errors.length) {
showNotification(`Ошибки истории: ${result.errors.join(" | ")}`, "error");
}
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
return;
}
const message = result.error || "Ошибка при сборе истории";
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
} catch (error) {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
}
};
const checkAll = async (source = "bar") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Проверяем всё: доступ, права, участие...", "info");
await checkAccess(source, true);
await checkInviteAccess(source, true);
await refreshMembership(source, true);
setTaskNotice({ text: "Проверка завершена.", tone: "success", source });
};
const joinGroupsForTask = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
showNotification("Отправляем заявки на вступление...", "info");
const result = await window.api.joinGroupsByTask(selectedTaskId);
if (!result || !result.ok) {
showNotification(result?.error || "Не удалось отправить заявки", "error");
return;
}
setTaskNotice({ text: "Заявки на вступление отправлены.", tone: "success", source });
await refreshMembership("join_groups");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearLogs = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearLogs(selectedTaskId);
setLogs([]);
setTaskNotice({ text: "Логи очищены.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearInvites = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearInvites(selectedTaskId);
setInvites([]);
setTaskNotice({ text: "История инвайтов очищена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearAccountEvents = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.clearAccountEvents();
setAccountEvents([]);
showNotification("События очищены.", "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportLogs = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportLogs(selectedTaskId);
if (result && result.canceled) return;
setTaskNotice({ text: `Логи выгружены: ${result.filePath}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportInvites = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportInvites(selectedTaskId);
if (result && result.canceled) return;
setTaskNotice({ text: `История инвайтов выгружена: ${result.filePath}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportProblemInvites = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportProblemInvites(selectedTaskId);
if (result.canceled) return;
setTaskNotice({ text: `Проблемные инвайты выгружены: ${result.filePath}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const exportFallback = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
const result = await window.api.exportFallback(selectedTaskId);
if (result.canceled) return;
setTaskNotice({ text: `Fallback выгружен: ${result.filePath}`, tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const updateFallbackStatus = async (id, status) => {
if (!window.api) return;
try {
await window.api.updateFallback({ id, status });
if (selectedTaskId != null) {
setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
}
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearFallback = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearFallback(selectedTaskId);
setFallbackList(await window.api.listFallback({ limit: 500, taskId: selectedTaskId }));
setTaskNotice({ text: "Fallback очищен.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearConfirmQueue = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearConfirmQueue(selectedTaskId);
setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId: selectedTaskId }));
setTaskNotice({ text: "Очередь подтверждений очищена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearQueue = async (source = "editor") => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
return;
}
try {
await window.api.clearQueue(selectedTaskId);
const data = await window.api.taskStatus(selectedTaskId);
setTaskStatus(data);
setTaskNotice({ text: "Очередь очищена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const clearDatabase = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
if (!window.confirm("Удалить все данные из базы? Это действие нельзя отменить.")) {
return;
}
try {
await window.api.clearDatabase();
showNotification("База очищена.", "info");
setSelectedTaskId(null);
resetTaskForm();
setCompetitorText("");
resetSelectedAccountIds([]);
resetTaskAccountRoles({});
setLogs([]);
setInvites([]);
setTaskStatus({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" }
});
await loadBase();
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const resetSessions = async () => {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
await window.api.resetSessions();
showNotification("Сессии сброшены.", "info");
resetSelectedAccountIds([]);
resetTaskAccountRoles({});
await loadBase();
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
return {
saveTask,
deleteTask,
startTask,
stopTask,
startAllTasks,
stopAllTasks,
parseHistory,
checkAll,
joinGroupsForTask,
clearLogs,
clearInvites,
clearAccountEvents,
exportLogs,
exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearConfirmQueue,
clearQueue,
clearDatabase,
resetSessions
};
}

View File

@ -0,0 +1,24 @@
import { normalizeIntervals, sanitizeTaskForm } from "../appDefaults.js";
export default function useTaskFormActions({ taskForm, setTaskForm }) {
const updateIntervals = (nextMin, nextMax) => {
const updated = normalizeIntervals({
...taskForm,
minIntervalMinutes: nextMin,
maxIntervalMinutes: nextMax
});
setTaskForm(updated);
};
const applyRoleMode = (mode) => {
if (mode === "same") {
setTaskForm(sanitizeTaskForm({ ...taskForm, requireSameBotInBoth: true, separateBotRoles: false }));
return;
}
if (mode === "split") {
setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: true });
}
};
return { updateIntervals, applyRoleMode };
}

View File

@ -0,0 +1,44 @@
import { useEffect } from "react";
import { buildPresetSignature } from "../utils/presets.js";
export default function useTaskLifecycle({
loadBase,
selectedTaskId,
loadSelectedTask,
setAccessStatus,
setInviteAccessStatus,
setMembershipStatus,
setTaskNotice,
setActivePreset,
checkAccess,
checkInviteAccess,
activePreset,
taskForm,
taskAccountRoles,
presetSignatureRef
}) {
useEffect(() => {
loadBase();
}, []);
useEffect(() => {
loadSelectedTask(selectedTaskId);
setAccessStatus([]);
setInviteAccessStatus([]);
setMembershipStatus({});
setTaskNotice(null);
setActivePreset("");
if (selectedTaskId != null) {
checkAccess("auto", true);
checkInviteAccess("auto", true);
}
}, [selectedTaskId]);
useEffect(() => {
if (!activePreset) return;
const currentSignature = buildPresetSignature(taskForm, taskAccountRoles);
if (currentSignature !== presetSignatureRef.current) {
setActivePreset("");
}
}, [taskForm, taskAccountRoles, activePreset]);
}

View File

@ -0,0 +1,108 @@
import { emptyTaskForm, normalizeTask, sanitizeTaskForm } from "../appDefaults.js";
export default function useTaskLoaders({
taskAutosaveReady,
setTaskForm,
setCompetitorText,
setSelectedAccountIds,
setTaskAccountRoles,
setLogs,
setInvites,
setFallbackList,
setConfirmQueue,
setGroupVisibility,
setTaskStatus,
setMembershipStatus,
showNotification,
selectedTaskId
}) {
async function loadSelectedTask(taskId) {
taskAutosaveReady.current = false;
if (!taskId) {
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setTaskAccountRoles({});
setLogs([]);
setInvites([]);
setGroupVisibility([]);
setConfirmQueue([]);
setTaskStatus({
running: false,
queueCount: 0,
dailyRemaining: 0,
dailyUsed: 0,
dailyLimit: 0,
monitorInfo: { monitoring: false, accountId: 0, accountIds: [], groups: [], lastMessageAt: "", lastSource: "" },
nextRunAt: "",
nextInviteAccountId: 0,
lastInviteAccountId: 0,
pendingStats: { total: 0, withUsername: 0, withAccessHash: 0, withoutData: 0 }
});
taskAutosaveReady.current = true;
return;
}
const details = await window.api.getTask(taskId);
if (!details) {
taskAutosaveReady.current = true;
return;
}
setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
setCompetitorText((details.competitors || []).join("\n"));
const roleMap = {};
if (details.accountRoles && details.accountRoles.length) {
details.accountRoles.forEach((item) => {
const roleConfirm = item.roleConfirm != null ? item.roleConfirm : item.roleInvite;
roleMap[item.accountId] = {
monitor: Boolean(item.roleMonitor),
invite: Boolean(item.roleInvite),
confirm: Boolean(roleConfirm),
inviteLimit: Number(item.inviteLimit || 0)
};
});
} else {
(details.accountIds || []).forEach((accountId) => {
roleMap[accountId] = { monitor: true, invite: true, confirm: true, inviteLimit: 1 };
});
}
setTaskAccountRoles(roleMap);
setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id)));
setLogs(await window.api.listLogs({ limit: 100, taskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId }));
setFallbackList(await window.api.listFallback({ limit: 500, taskId }));
setConfirmQueue(await window.api.listConfirmQueue({ limit: 500, taskId }));
setGroupVisibility([]);
setTaskStatus(await window.api.taskStatus(taskId));
taskAutosaveReady.current = true;
}
async function refreshMembership(source = "manual", showToast = false) {
if (!window.api) {
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
return;
}
try {
const payload = selectedTaskId != null
? await window.api.membershipStatusByTask(selectedTaskId)
: await window.api.getMembershipStatus();
if (payload && payload.ok === false) {
showNotification(payload.error || "Не удалось проверить участие.", "error");
return;
}
const map = {};
(payload || []).forEach((item) => {
if (item && item.accountId != null) {
map[item.accountId] = item;
}
});
setMembershipStatus(map);
if (showToast) {
showNotification("Проверка участия завершена.", "success");
}
} catch (error) {
showNotification(error.message || String(error), "error");
}
}
return { loadSelectedTask, refreshMembership };
}

View File

@ -0,0 +1,132 @@
import { buildPresetSignature } from "../utils/presets.js";
import { sanitizeTaskForm } from "../appDefaults.js";
export default function useTaskPresets({
hasSelectedTask,
accounts,
selectedAccountIds,
taskForm,
setTaskForm,
setTaskAccountRoles,
setSelectedAccountIds,
persistAccountRoles,
showNotification,
setTaskNotice,
setActivePreset,
setActiveTab,
selectedTaskId,
presetSignatureRef
}) {
const applyTaskPreset = (type) => {
if (!hasSelectedTask) {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (!accounts.length) {
showNotification("Нет доступных аккаунтов.", "error");
return;
}
const masterId = accounts[0].id;
const requiredCount = 3;
const baseIds = selectedAccountIds.length >= requiredCount
? selectedAccountIds.slice()
: accounts.map((account) => account.id);
if (baseIds.length < 3) {
showNotification("Для раздельных ролей желательно минимум 3 аккаунта (мониторинг/инвайт/подтверждение).", "info");
}
if (!baseIds.includes(masterId)) {
baseIds.unshift(masterId);
}
const pool = baseIds.filter((id) => id !== masterId);
const roleMap = {};
const addRole = (id, role) => {
if (!id) return;
if (!roleMap[id]) roleMap[id] = { monitor: false, invite: false, confirm: false, inviteLimit: 0 };
roleMap[id][role] = true;
if (role === "invite" && (!roleMap[id].inviteLimit || roleMap[id].inviteLimit === 0)) {
roleMap[id].inviteLimit = 1;
}
};
const takeFromPool = (count, used) => {
const result = [];
for (const id of pool) {
if (result.length >= count) break;
if (used.has(id)) continue;
used.add(id);
result.push(id);
}
return result;
};
const used = new Set();
const isSoft = type === "soft_50" || type === "soft_50_admin" || type === "soft_25" || type === "soft_25_admin";
const isSoft25 = type === "soft_25" || type === "soft_25_admin";
const monitorCount = 1;
const inviteCount = isSoft ? (isSoft25 ? 2 : 5) : 1;
const confirmCount = 1;
const monitorIds = takeFromPool(monitorCount, used);
const confirmIds = takeFromPool(confirmCount, used);
const inviteIds = masterId ? [masterId] : takeFromPool(inviteCount, used);
if (monitorIds.length < monitorCount) addRole(masterId, "monitor");
if (confirmIds.length < confirmCount) addRole(masterId, "confirm");
monitorIds.forEach((id) => addRole(id, "monitor"));
confirmIds.forEach((id) => addRole(id, "confirm"));
inviteIds.forEach((id) => addRole(id, "invite"));
const nextForm = sanitizeTaskForm({
...taskForm,
warmupEnabled: true,
historyLimit: 35,
separateBotRoles: true,
requireSameBotInBoth: false,
maxCompetitorBots: 1,
maxOurBots: isSoft ? (isSoft25 ? 2 : 5) : 1,
separateConfirmRoles: true,
maxConfirmBots: isSoft ? 1 : 1,
inviteViaAdmins: type === "admin" || type === "soft_50_admin" || type === "soft_25_admin",
inviteAdminAnonymous: true,
inviteAdminMasterId: masterId,
rolesMode: "auto",
dailyLimit: isSoft ? (isSoft25 ? 25 : 50) : taskForm.dailyLimit,
maxInvitesPerCycle: isSoft ? (isSoft25 ? 2 : 5) : taskForm.maxInvitesPerCycle,
minIntervalMinutes: isSoft ? 3 : taskForm.minIntervalMinutes,
maxIntervalMinutes: isSoft ? 6 : taskForm.maxIntervalMinutes,
retryOnFail: isSoft ? true : taskForm.retryOnFail,
stopOnBlocked: isSoft ? true : taskForm.stopOnBlocked,
stopBlockedPercent: isSoft ? 25 : taskForm.stopBlockedPercent,
randomAccounts: isSoft ? false : taskForm.randomAccounts,
multiAccountsPerRun: isSoft ? false : taskForm.multiAccountsPerRun,
inviteLinkOnFail: isSoft ? false : taskForm.inviteLinkOnFail,
allowStartWithoutInviteRights: isSoft ? false : taskForm.allowStartWithoutInviteRights
});
const signature = buildPresetSignature(nextForm, roleMap);
presetSignatureRef.current = signature;
const label = type === "admin"
? "Автораспределение + Инвайт через админа"
: type === "no_admin"
? "Автораспределение + Без админки"
: type === "soft_50_admin"
? "Мягкий 50/день + Инвайт через админов"
: type === "soft_25_admin"
? "Мягкий 25/день + Инвайт через админов"
: type === "soft_25"
? "Мягкий 25/день (2 инвайтера)"
: "Мягкий режим 50/день (5 инвайтеров)";
setTaskForm(nextForm);
setTaskAccountRoles(roleMap);
setSelectedAccountIds(Object.keys(roleMap).map((id) => Number(id)));
persistAccountRoles(roleMap);
if (window.api) {
window.api.addAccountEvent({
accountId: 0,
phone: "",
action: "preset_applied",
details: `задача ${selectedTaskId}: ${label}`
});
}
setTaskNotice({ text: `Пресет: ${label}`, tone: "success", source: "task" });
setActivePreset(type);
setActiveTab("events");
};
return { applyTaskPreset };
}

View File

@ -0,0 +1,32 @@
import { emptyTaskForm } from "../appDefaults.js";
export default function useTaskSelection({
taskAutosaveReady,
selectedTaskId,
setSelectedTaskId,
setTaskForm,
setCompetitorText,
setSelectedAccountIds,
setTaskAccountRoles,
setAccessStatus,
setMembershipStatus
}) {
const createTask = () => {
taskAutosaveReady.current = false;
setSelectedTaskId(null);
setTaskForm(emptyTaskForm);
setCompetitorText("");
setSelectedAccountIds([]);
setTaskAccountRoles({});
setAccessStatus([]);
setMembershipStatus({});
taskAutosaveReady.current = true;
};
const selectTask = (taskId) => {
if (taskId === selectedTaskId) return;
setSelectedTaskId(taskId);
};
return { createTask, selectTask };
}

View File

@ -0,0 +1,74 @@
import { useMemo } from "react";
export default function useTaskSelectors({
tasks,
taskStatusMap,
deferredTaskSearch,
taskFilter,
taskSort
}) {
const taskSummary = useMemo(() => {
const totals = {
total: tasks.length,
running: 0,
queue: 0,
dailyUsed: 0,
dailyLimit: 0
};
tasks.forEach((task) => {
const status = taskStatusMap[task.id];
if (status && status.running) totals.running += 1;
if (status) {
totals.queue += Number(status.queueCount || 0);
totals.dailyUsed += Number(status.dailyUsed || 0);
totals.dailyLimit += Number(status.dailyLimit || 0);
}
});
return totals;
}, [tasks, taskStatusMap]);
const filteredTasks = useMemo(() => {
const query = deferredTaskSearch.trim().toLowerCase();
const filtered = tasks.filter((task) => {
const name = (task.name || "").toLowerCase();
const group = (task.our_group || "").toLowerCase();
const matchesQuery = !query || name.includes(query) || group.includes(query) || String(task.id).includes(query);
if (!matchesQuery) return false;
const status = taskStatusMap[task.id];
if (taskFilter === "running") return Boolean(status && status.running);
if (taskFilter === "stopped") return Boolean(status && !status.running);
return true;
});
const sorted = [...filtered].sort((a, b) => {
const statusA = taskStatusMap[a.id];
const statusB = taskStatusMap[b.id];
if (taskSort === "queue") {
return (statusB ? statusB.queueCount : 0) - (statusA ? statusA.queueCount : 0);
}
if (taskSort === "limit") {
return (statusB ? statusB.dailyLimit : 0) - (statusA ? statusA.dailyLimit : 0);
}
if (taskSort === "lastMessage") {
const dateA = statusA && statusA.monitorInfo && statusA.monitorInfo.lastMessageAt
? Date.parse(statusA.monitorInfo.lastMessageAt) || 0
: 0;
const dateB = statusB && statusB.monitorInfo && statusB.monitorInfo.lastMessageAt
? Date.parse(statusB.monitorInfo.lastMessageAt) || 0
: 0;
return dateB - dateA;
}
if (taskSort === "activity") {
const aActive = statusA && statusA.running ? 1 : 0;
const bActive = statusB && statusB.running ? 1 : 0;
if (bActive !== aActive) return bActive - aActive;
}
if (taskSort === "id") {
return b.id - a.id;
}
return b.id - a.id;
});
return sorted;
}, [tasks, deferredTaskSearch, taskFilter, taskSort, taskStatusMap]);
return { taskSummary, filteredTasks };
}

View File

@ -0,0 +1,70 @@
import { useEffect } from "react";
export default function useTaskStatusSync({
selectedTaskId,
taskStatus,
setTaskStatusMap,
checklistStats,
setChecklistOpen,
hasSelectedTask,
taskForm,
roleIntersectionCount,
roleSummary,
setTaskForm,
sanitizeTaskForm
}) {
useEffect(() => {
if (selectedTaskId == null) return;
setTaskStatusMap((prev) => ({
...prev,
[selectedTaskId]: taskStatus
}));
}, [selectedTaskId, taskStatus]);
useEffect(() => {
if (checklistStats.fail > 0) {
setChecklistOpen(true);
}
}, [checklistStats.fail]);
useEffect(() => {
if (!hasSelectedTask) return;
if (taskForm.requireSameBotInBoth) {
const nextValue = Math.max(1, roleIntersectionCount || 0);
if (taskForm.maxCompetitorBots !== nextValue || taskForm.maxOurBots !== nextValue) {
setTaskForm((prev) => sanitizeTaskForm({
...prev,
maxCompetitorBots: nextValue,
maxOurBots: nextValue
}));
}
return;
}
if (taskForm.separateBotRoles) {
const nextCompetitors = Math.max(1, roleSummary.monitor.length || 0);
const nextOur = Math.max(1, roleSummary.invite.length || 0);
const hasConfirmRoles = roleSummary.confirm.length > 0;
const nextConfirm = taskForm.separateConfirmRoles && hasConfirmRoles
? Math.max(1, roleSummary.confirm.length)
: taskForm.maxConfirmBots;
if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur || (taskForm.separateConfirmRoles && hasConfirmRoles && taskForm.maxConfirmBots !== nextConfirm)) {
setTaskForm((prev) => ({
...prev,
maxCompetitorBots: nextCompetitors,
maxOurBots: nextOur,
maxConfirmBots: nextConfirm
}));
}
}
}, [
hasSelectedTask,
roleIntersectionCount,
roleSummary.monitor.length,
roleSummary.invite.length,
roleSummary.confirm.length,
taskForm.requireSameBotInBoth,
taskForm.separateBotRoles,
taskForm.separateConfirmRoles,
taskForm.maxConfirmBots
]);
}

View File

@ -0,0 +1,127 @@
import { useMemo } from "react";
export default function useTaskStatusView({
taskStatus,
taskAccountRoles,
accountById,
formatAccountLabel,
setActiveTab,
checkInviteAccess,
parseHistory,
assignedAccountCount,
roleSummary,
accountEvents,
formatCountdownWithNow,
inviteAccessStatus
}) {
const monitorAccountIds = taskStatus && taskStatus.monitorInfo && taskStatus.monitorInfo.accountIds
? taskStatus.monitorInfo.accountIds
: (taskStatus && taskStatus.monitorInfo && taskStatus.monitorInfo.accountId ? [taskStatus.monitorInfo.accountId] : []);
const monitorLabels = monitorAccountIds
.map((id) => {
const account = accountById.get(id);
return account ? formatAccountLabel(account) : String(id);
})
.filter(Boolean);
const inviteAccountIds = Object.entries(taskAccountRoles || {})
.filter(([, roles]) => roles && roles.invite && Number(roles.inviteLimit || 0) > 0)
.map(([id]) => Number(id));
const inviteLabels = inviteAccountIds
.map((id) => {
const account = accountById.get(id);
return account ? formatAccountLabel(account) : String(id);
})
.filter(Boolean);
const nowLine = [
`Мониторинг: ${taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "вкл." : "выкл."}`,
`Очередь: ${taskStatus.queueCount}`,
`Инвайт: ${taskStatus.running ? "активен" : "остановлен"}`,
`Следующий цикл: ${taskStatus.running ? formatCountdownWithNow(taskStatus.nextRunAt) : "—"}`
].join(" • ");
const primaryIssue = taskStatus.readiness && !taskStatus.readiness.ok && taskStatus.readiness.reasons && taskStatus.readiness.reasons.length
? taskStatus.readiness.reasons[0]
: "";
const openFixTab = () => {
if (!primaryIssue) return;
const text = primaryIssue.toLowerCase();
if (text.includes("аккаунт") || text.includes("роль") || text.includes("инвайт") || text.includes("монитор")) {
setActiveTab("accounts");
return;
}
if (text.includes("групп")) {
setActiveTab("task");
return;
}
if (text.includes("прав")) {
setActiveTab("accounts");
return;
}
setActiveTab("task");
};
const inviteAccessChecked = inviteAccessStatus && inviteAccessStatus.length > 0;
const inviteAccessOk = inviteAccessChecked && inviteAccessStatus.every((item) => item.canInvite);
const inviteAccessWarn = inviteAccessChecked && !inviteAccessOk;
const lastEvents = (accountEvents || []).slice(0, 3);
const checklistItems = [
{
id: "accounts",
label: "Подключены аккаунты",
ok: assignedAccountCount > 0,
hint: assignedAccountCount > 0 ? `Назначено: ${assignedAccountCount}` : "Нет назначенных аккаунтов",
action: () => setActiveTab("accounts"),
actionLabel: "Открыть аккаунты"
},
{
id: "roles",
label: "Назначены роли",
ok: roleSummary.monitor.length > 0 && inviteAccountIds.length > 0,
hint: `Мониторинг: ${roleSummary.monitor.length}, Инвайт: ${inviteAccountIds.length}, Подтверждение: ${roleSummary.confirm.length}`,
action: () => setActiveTab("accounts"),
actionLabel: "Назначить роли"
},
{
id: "access",
label: "Права в цели проверены",
ok: inviteAccessOk,
warn: inviteAccessWarn,
hint: inviteAccessChecked
? (inviteAccessOk ? "Права инвайта подтверждены" : "Есть аккаунты без прав")
: "Проверка прав не запускалась",
action: () => checkInviteAccess("checklist"),
actionLabel: "Проверить права"
},
{
id: "queue",
label: "Очередь готова",
ok: Number(taskStatus.queueCount || 0) > 0,
hint: `В очереди: ${taskStatus.queueCount || 0}`,
action: () => parseHistory("checklist"),
actionLabel: "Собрать историю"
}
];
const checklistStats = useMemo(() => {
let ok = 0;
let warn = 0;
let fail = 0;
checklistItems.forEach((item) => {
if (item.ok) ok += 1;
else if (item.warn) warn += 1;
else fail += 1;
});
return { ok, warn, fail, total: checklistItems.length };
}, [checklistItems]);
return {
monitorLabels,
inviteLabels,
nowLine,
primaryIssue,
openFixTab,
checklistItems,
checklistStats,
inviteAccessChecked,
inviteAccessOk,
inviteAccessWarn,
lastEvents
};
}

View File

@ -0,0 +1,40 @@
import { useMemo } from "react";
export default function useUiComputed({
taskStatus,
assignedAccountCount,
roleSummary,
inviteAccessWarn,
taskAccountRoles,
taskStatusMap,
tasks,
formatCountdown,
now
}) {
const pauseReason = useMemo(() => {
if (!taskStatus || taskStatus.running) return "";
if (taskStatus.lastStopReason) return taskStatus.lastStopReason;
if (taskStatus.dailyRemaining === 0 && taskStatus.dailyLimit > 0) return "Дневной лимит исчерпан";
if (Number(taskStatus.queueCount || 0) === 0) return "Очередь пуста";
if (assignedAccountCount === 0) return "Нет назначенных аккаунтов";
if (roleSummary.invite.length === 0) return "Нет аккаунтов с ролью инвайта";
if (inviteAccessWarn) return "Есть аккаунты без прав на инвайт";
return "";
}, [taskStatus, assignedAccountCount, roleSummary, inviteAccessWarn]);
const hasPerAccountInviteLimits = useMemo(() => {
return Object.values(taskAccountRoles || {}).some(
(roles) => roles && roles.invite && Number(roles.inviteLimit || 0) > 0
);
}, [taskAccountRoles]);
const formatCountdownWithNow = useMemo(() => {
return (target) => formatCountdown(target, now);
}, [formatCountdown, now]);
return {
pauseReason,
hasPerAccountInviteLimits,
formatCountdownWithNow
};
}

View File

@ -0,0 +1,98 @@
import React from "react";
export default function QueueTab({
hasSelectedTask,
queueStats,
queueSearch,
setQueueSearch,
queuePage,
setQueuePage,
queuePageCount,
pagedQueue,
accountById,
formatAccountLabel,
formatTimestamp
}) {
const formatUserWithUsername = (item) => {
const id = item.user_id != null ? String(item.user_id) : "—";
const username = item.username ? String(item.username).replace(/^@/, "") : "";
return username ? `${id} (@${username})` : id;
};
if (!hasSelectedTask) {
return (
<section className="card logs">
<div className="row-header">
<h3>Очередь</h3>
</div>
<div className="hint">Выберите задачу, чтобы посмотреть очередь.</div>
</section>
);
}
return (
<section className="card logs">
<div className="row-header">
<h3>Очередь</h3>
</div>
<div className="row-inline">
<input
type="text"
value={queueSearch}
onChange={(event) => {
setQueueSearch(event.target.value);
setQueuePage(1);
}}
placeholder="Поиск по очереди"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setQueuePage((prev) => Math.max(1, prev - 1))}
disabled={queuePage === 1}
>
Назад
</button>
<span>{queuePage}/{queuePageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setQueuePage((prev) => Math.min(queuePageCount, prev + 1))}
disabled={queuePage === queuePageCount}
>
Вперед
</button>
</div>
</div>
<div className="status-caption">
В очереди: {queueStats?.total ?? 0} · username: {queueStats?.withUsername ?? 0} · access_hash: {queueStats?.withAccessHash ?? 0} · пустые: {queueStats?.withoutData ?? 0}
</div>
<div className="log-table">
<div className="log-head">
<span>Пользователь</span>
<span>Источник</span>
<span>Наблюдатель</span>
<span>Попытки</span>
<span>Добавлен</span>
</div>
{pagedQueue.length === 0 && (
<div className="log-empty">Очередь пуста.</div>
)}
{pagedQueue.map((item) => {
const watcher = item.watcher_account_id ? accountById.get(item.watcher_account_id) : null;
const watcherLabel = watcher ? formatAccountLabel(watcher) : (item.watcher_account_id || "—");
return (
<div className="log-row" key={item.id}>
<div>{formatUserWithUsername(item)}</div>
<div>{item.source_chat || "—"}</div>
<div>{watcherLabel}</div>
<div>{item.attempts ?? 0}</div>
<div>{formatTimestamp(item.created_at)}</div>
</div>
);
})}
</div>
</section>
);
}

View File

@ -0,0 +1,25 @@
export const copyToClipboard = async (text) => {
if (!text) return false;
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (error) {
// ignore and fallback
}
try {
const el = document.createElement("textarea");
el.value = text;
el.setAttribute("readonly", "");
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
el.select();
const ok = document.execCommand("copy");
document.body.removeChild(el);
return ok;
} catch (error) {
return false;
}
};

View File

@ -0,0 +1,63 @@
export const explainInviteError = (error) => {
if (!error) return "";
if (error === "USER_ID_INVALID") {
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
}
if (error === "CHAT_WRITE_FORBIDDEN") {
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
}
if (error === "USER_NOT_MUTUAL_CONTACT") {
return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы.";
}
if (error === "USER_PRIVACY_RESTRICTED") {
return "Приглашение запрещено пользователем: приватность не позволяет добавлять в группы.";
}
if (error === "USER_NOT_PARTICIPANT") {
return "Аккаунт не состоит в целевой группе или канал приватный.";
}
if (error === "USER_BANNED_IN_CHANNEL") {
return "Пользователь заблокирован в группе или канале назначения.";
}
if (error === "USER_BOT") {
return "Бота нельзя приглашать как обычного пользователя.";
}
if (error === "USER_KICKED") {
return "Пользователь был удален из группы ранее.";
}
if (error === "CHAT_ADMIN_REQUIRED") {
return "Для добавления участников нужны права администратора.";
}
if (error === "CHAT_MEMBER_ADD_FAILED") {
return "Telegram отклонил добавление. Обычно это антиспам-ограничение или недостаток прав.";
}
if (error === "USER_BLOCKED") {
return "Пользователь заблокировал аккаунт, который пытается добавить.";
}
if (error === "USER_RESTRICTED") {
return "Пользователь ограничен Telegram или чатами.";
}
if (error === "FLOOD" || error === "PEER_FLOOD") {
return "Слишком много действий. Снизьте лимит или увеличьте интервалы.";
}
return "";
};
export const explainTdataError = (error) => {
if (!error) return "";
if (error.includes("AUTH_KEY_DUPLICATED")) {
return "tdata используется в другом устройстве. Выйдите из аккаунта на других устройствах и пересоберите tdata.";
}
if (error.includes("SESSION_REVOKED")) {
return "Сессия отозвана. Залогиньтесь заново и пересоберите tdata.";
}
if (error.includes("PHONE_CODE_INVALID")) {
return "Неверный код подтверждения.";
}
if (error.includes("PASSWORD_HASH_INVALID")) {
return "Неверный пароль 2FA.";
}
if (error.includes("API_ID_INVALID")) {
return "Неверный API ID/Hash.";
}
return "";
};

View File

@ -0,0 +1,38 @@
export const formatAccountLabel = (account) => {
if (!account) return "—";
const base = account.phone || account.user_id || String(account.id);
const username = account.username ? `@${account.username}` : "";
return username ? `${base} (${username})` : base;
};
export const formatAccountStatus = (status) => {
if (status === "limited") return "В спаме";
if (status === "error") return "Ошибка";
if (status === "ok") return "ОК";
return status || "Неизвестно";
};
export const formatTimestamp = (value) => {
if (!value) return "—";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("ru-RU");
};
export const formatCountdown = (target, now) => {
if (!target) return "—";
const targetTime = new Date(target).getTime();
if (!Number.isFinite(targetTime)) return "—";
const diff = Math.max(0, Math.floor((targetTime - now) / 1000));
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
};
export const formatTargetType = (value) => {
if (!value) return "";
if (value === "channel") return "канал";
if (value === "megagroup") return "супергруппа";
if (value === "group") return "группа";
return value;
};

View File

@ -0,0 +1,27 @@
export const buildPresetSignature = (form, roles) => {
const roleEntries = Object.entries(roles || {})
.map(([id, value]) => ({
id: Number(id),
monitor: Boolean(value && value.monitor),
invite: Boolean(value && value.invite),
confirm: Boolean(value && value.confirm)
}))
.sort((a, b) => a.id - b.id);
const snapshot = {
form: {
warmupEnabled: Boolean(form.warmupEnabled),
historyLimit: Number(form.historyLimit || 0),
separateBotRoles: Boolean(form.separateBotRoles),
requireSameBotInBoth: Boolean(form.requireSameBotInBoth),
maxCompetitorBots: Number(form.maxCompetitorBots || 0),
maxOurBots: Number(form.maxOurBots || 0),
separateConfirmRoles: Boolean(form.separateConfirmRoles),
maxConfirmBots: Number(form.maxConfirmBots || 0),
inviteViaAdmins: Boolean(form.inviteViaAdmins),
inviteAdminAnonymous: Boolean(form.inviteAdminAnonymous),
inviteAdminMasterId: Number(form.inviteAdminMasterId || 0)
},
roles: roleEntries
};
return JSON.stringify(snapshot);
};