some
This commit is contained in:
parent
f4a0711ac3
commit
f48cc365e1
118
src/renderer/appDefaults.js
Normal file
118
src/renderer/appDefaults.js
Normal 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
|
||||
});
|
||||
76
src/renderer/components/AppMain.jsx
Normal file
76
src/renderer/components/AppMain.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/renderer/components/AppOverlays.jsx
Normal file
100
src/renderer/components/AppOverlays.jsx
Normal 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‑прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые).\nДополнительно он делает один реальный инвайт из очереди для проверки фактической работы и ошибок Telegram.\n\nПользователь: ${liveConfirmContext.userId || "—"}${liveConfirmContext.username ? ` (@${liveConfirmContext.username})` : ""}\nИсточник: ${liveConfirmContext.sourceChat || "—"}`
|
||||
: "Live‑прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые). Дополнительно он делает один реальный инвайт из очереди."
|
||||
}
|
||||
confirmLabel="Сделать инвайт"
|
||||
cancelLabel="Отмена"
|
||||
onConfirm={onConfirmLiveInvite}
|
||||
onCancel={onCancelLiveInvite}
|
||||
/>
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={dismissToast}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
src/renderer/components/AppSidebar.jsx
Normal file
154
src/renderer/components/AppSidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/renderer/components/ChecklistCard.jsx
Normal file
50
src/renderer/components/ChecklistCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/renderer/components/ConfirmModal.jsx
Normal file
29
src/renderer/components/ConfirmModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/renderer/components/ImportAccountsModal.jsx
Normal file
146
src/renderer/components/ImportAccountsModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/renderer/components/InfoModal.jsx
Normal file
96
src/renderer/components/InfoModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/renderer/components/MainTabContent.jsx
Normal file
55
src/renderer/components/MainTabContent.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/renderer/components/MainTabs.jsx
Normal file
50
src/renderer/components/MainTabs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/renderer/components/NotificationsModal.jsx
Normal file
56
src/renderer/components/NotificationsModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/renderer/components/NowStatusCard.jsx
Normal file
95
src/renderer/components/NowStatusCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/renderer/components/QuickActionsBar.jsx
Normal file
142
src/renderer/components/QuickActionsBar.jsx
Normal 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">[>]</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>
|
||||
);
|
||||
}
|
||||
26
src/renderer/components/SidebarAccounts.jsx
Normal file
26
src/renderer/components/SidebarAccounts.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/renderer/components/SidebarOverview.jsx
Normal file
75
src/renderer/components/SidebarOverview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
740
src/renderer/components/TaskSettingsTab.jsx
Normal file
740
src/renderer/components/TaskSettingsTab.jsx
Normal 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="График: дни 1–3 — 1/д; 4–7 — 2/д; 8–12 — 3/д; 13–18 — 4/д; 19–25 — 5/д; 26–33 — 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>
|
||||
);
|
||||
}
|
||||
147
src/renderer/components/TasksSidebar.jsx
Normal file
147
src/renderer/components/TasksSidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/renderer/components/TestRunCard.jsx
Normal file
51
src/renderer/components/TestRunCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/renderer/components/ToastStack.jsx
Normal file
18
src/renderer/components/ToastStack.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/renderer/hooks/useAccessChecks.js
Normal file
51
src/renderer/hooks/useAccessChecks.js
Normal 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 };
|
||||
}
|
||||
110
src/renderer/hooks/useAccountComputed.js
Normal file
110
src/renderer/hooks/useAccountComputed.js
Normal 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
|
||||
};
|
||||
}
|
||||
165
src/renderer/hooks/useAccountImport.js
Normal file
165
src/renderer/hooks/useAccountImport.js
Normal 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
|
||||
};
|
||||
}
|
||||
243
src/renderer/hooks/useAccountManagement.js
Normal file
243
src/renderer/hooks/useAccountManagement.js
Normal 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
|
||||
};
|
||||
}
|
||||
141
src/renderer/hooks/useAppDataState.js
Normal file
141
src/renderer/hooks/useAppDataState.js
Normal 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
|
||||
};
|
||||
}
|
||||
88
src/renderer/hooks/useAppLoaders.js
Normal file
88
src/renderer/hooks/useAppLoaders.js
Normal 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
|
||||
};
|
||||
}
|
||||
152
src/renderer/hooks/useAppOrchestration.js
Normal file
152
src/renderer/hooks/useAppOrchestration.js
Normal 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
|
||||
});
|
||||
}
|
||||
22
src/renderer/hooks/useAppOutsideClicks.js
Normal file
22
src/renderer/hooks/useAppOutsideClicks.js
Normal 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)
|
||||
});
|
||||
}
|
||||
132
src/renderer/hooks/useAppPolling.js
Normal file
132
src/renderer/hooks/useAppPolling.js
Normal 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]);
|
||||
}
|
||||
9
src/renderer/hooks/useAppState.js
Normal file
9
src/renderer/hooks/useAppState.js
Normal file
@ -0,0 +1,9 @@
|
||||
import useAppDataState from "./useAppDataState.js";
|
||||
import useAppUiState from "./useAppUiState.js";
|
||||
|
||||
export default function useAppState() {
|
||||
return {
|
||||
...useAppDataState(),
|
||||
...useAppUiState()
|
||||
};
|
||||
}
|
||||
283
src/renderer/hooks/useAppTabGroups.js
Normal file
283
src/renderer/hooks/useAppTabGroups.js
Normal 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
|
||||
};
|
||||
}
|
||||
32
src/renderer/hooks/useAppTaskDerived.js
Normal file
32
src/renderer/hooks/useAppTaskDerived.js
Normal 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
|
||||
};
|
||||
}
|
||||
83
src/renderer/hooks/useAppUiState.js
Normal file
83
src/renderer/hooks/useAppUiState.js
Normal 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
|
||||
};
|
||||
}
|
||||
45
src/renderer/hooks/useAutosave.js
Normal file
45
src/renderer/hooks/useAutosave.js
Normal 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]);
|
||||
}
|
||||
25
src/renderer/hooks/useCriticalEvents.js
Normal file
25
src/renderer/hooks/useCriticalEvents.js
Normal 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 };
|
||||
}
|
||||
50
src/renderer/hooks/useInviteImport.js
Normal file
50
src/renderer/hooks/useInviteImport.js
Normal 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 };
|
||||
}
|
||||
262
src/renderer/hooks/useLogsView.js
Normal file
262
src/renderer/hooks/useLogsView.js
Normal 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
|
||||
};
|
||||
}
|
||||
100
src/renderer/hooks/useMainUiProps.js
Normal file
100
src/renderer/hooks/useMainUiProps.js
Normal 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 };
|
||||
}
|
||||
29
src/renderer/hooks/useNotices.js
Normal file
29
src/renderer/hooks/useNotices.js
Normal 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]);
|
||||
}
|
||||
66
src/renderer/hooks/useNotifications.js
Normal file
66
src/renderer/hooks/useNotifications.js
Normal 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
|
||||
};
|
||||
}
|
||||
9
src/renderer/hooks/useOpenLogsTabListener.js
Normal file
9
src/renderer/hooks/useOpenLogsTabListener.js
Normal 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]);
|
||||
}
|
||||
19
src/renderer/hooks/useOutsideClick.js
Normal file
19
src/renderer/hooks/useOutsideClick.js
Normal 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]);
|
||||
}
|
||||
33
src/renderer/hooks/useSettingsActions.js
Normal file
33
src/renderer/hooks/useSettingsActions.js
Normal 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 };
|
||||
}
|
||||
312
src/renderer/hooks/useTabProps.js
Normal file
312
src/renderer/hooks/useTabProps.js
Normal 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
|
||||
};
|
||||
}
|
||||
588
src/renderer/hooks/useTaskActions.js
Normal file
588
src/renderer/hooks/useTaskActions.js
Normal 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
|
||||
};
|
||||
}
|
||||
24
src/renderer/hooks/useTaskFormActions.js
Normal file
24
src/renderer/hooks/useTaskFormActions.js
Normal 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 };
|
||||
}
|
||||
44
src/renderer/hooks/useTaskLifecycle.js
Normal file
44
src/renderer/hooks/useTaskLifecycle.js
Normal 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]);
|
||||
}
|
||||
108
src/renderer/hooks/useTaskLoaders.js
Normal file
108
src/renderer/hooks/useTaskLoaders.js
Normal 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 };
|
||||
}
|
||||
132
src/renderer/hooks/useTaskPresets.js
Normal file
132
src/renderer/hooks/useTaskPresets.js
Normal 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 };
|
||||
}
|
||||
32
src/renderer/hooks/useTaskSelection.js
Normal file
32
src/renderer/hooks/useTaskSelection.js
Normal 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 };
|
||||
}
|
||||
74
src/renderer/hooks/useTaskSelectors.js
Normal file
74
src/renderer/hooks/useTaskSelectors.js
Normal 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 };
|
||||
}
|
||||
70
src/renderer/hooks/useTaskStatusSync.js
Normal file
70
src/renderer/hooks/useTaskStatusSync.js
Normal 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
|
||||
]);
|
||||
}
|
||||
127
src/renderer/hooks/useTaskStatusView.js
Normal file
127
src/renderer/hooks/useTaskStatusView.js
Normal 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
|
||||
};
|
||||
}
|
||||
40
src/renderer/hooks/useUiComputed.js
Normal file
40
src/renderer/hooks/useUiComputed.js
Normal 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
|
||||
};
|
||||
}
|
||||
98
src/renderer/tabs/QueueTab.jsx
Normal file
98
src/renderer/tabs/QueueTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/renderer/utils/clipboard.js
Normal file
25
src/renderer/utils/clipboard.js
Normal 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;
|
||||
}
|
||||
};
|
||||
63
src/renderer/utils/errorHints.js
Normal file
63
src/renderer/utils/errorHints.js
Normal 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 "";
|
||||
};
|
||||
38
src/renderer/utils/formatters.js
Normal file
38
src/renderer/utils/formatters.js
Normal 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;
|
||||
};
|
||||
27
src/renderer/utils/presets.js
Normal file
27
src/renderer/utils/presets.js
Normal 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);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user