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