2074 lines
86 KiB
JavaScript
2074 lines
86 KiB
JavaScript
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
const AccountsTab = React.lazy(() => import("./tabs/AccountsTab.jsx"));
|
||
const LogsTab = React.lazy(() => import("./tabs/LogsTab.jsx"));
|
||
const EventsTab = React.lazy(() => import("./tabs/EventsTab.jsx"));
|
||
const SettingsTab = React.lazy(() => import("./tabs/SettingsTab.jsx"));
|
||
|
||
const emptySettings = {
|
||
competitorGroups: [""],
|
||
ourGroup: "",
|
||
minIntervalMinutes: 5,
|
||
maxIntervalMinutes: 10,
|
||
dailyLimit: 100,
|
||
historyLimit: 200,
|
||
accountMaxGroups: 10,
|
||
accountDailyLimit: 50,
|
||
floodCooldownMinutes: 1440
|
||
};
|
||
|
||
const emptyTaskForm = {
|
||
id: null,
|
||
name: "",
|
||
ourGroup: "",
|
||
minIntervalMinutes: 5,
|
||
maxIntervalMinutes: 10,
|
||
dailyLimit: 100,
|
||
historyLimit: 100,
|
||
maxCompetitorBots: 1,
|
||
maxOurBots: 1,
|
||
randomAccounts: false,
|
||
multiAccountsPerRun: false,
|
||
retryOnFail: true,
|
||
autoJoinCompetitors: true,
|
||
autoJoinOurGroup: true,
|
||
separateBotRoles: false,
|
||
requireSameBotInBoth: true,
|
||
stopOnBlocked: true,
|
||
stopBlockedPercent: 25,
|
||
notes: "",
|
||
enabled: true,
|
||
autoAssignAccounts: true
|
||
};
|
||
|
||
const normalizeTask = (row) => ({
|
||
id: row.id,
|
||
name: row.name || "",
|
||
ourGroup: row.our_group || "",
|
||
minIntervalMinutes: Number(row.min_interval_minutes || 5),
|
||
maxIntervalMinutes: Number(row.max_interval_minutes || 10),
|
||
dailyLimit: Number(row.daily_limit || 100),
|
||
historyLimit: Number(row.history_limit || 200),
|
||
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),
|
||
stopOnBlocked: Boolean(row.stop_on_blocked),
|
||
stopBlockedPercent: Number(row.stop_blocked_percent || 25),
|
||
notes: row.notes || "",
|
||
enabled: Boolean(row.enabled),
|
||
autoAssignAccounts: true
|
||
});
|
||
|
||
const normalizeIntervals = (form) => {
|
||
const min = Math.max(1, Number(form.minIntervalMinutes || 1));
|
||
let max = Math.max(1, Number(form.maxIntervalMinutes || 1));
|
||
if (max < min) max = min;
|
||
return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max };
|
||
};
|
||
|
||
const sanitizeTaskForm = (form) => {
|
||
let normalized = { ...form };
|
||
normalized = normalizeIntervals(normalized);
|
||
if (normalized.requireSameBotInBoth) {
|
||
normalized.separateBotRoles = false;
|
||
normalized.maxOurBots = normalized.maxCompetitorBots;
|
||
}
|
||
return normalized;
|
||
};
|
||
|
||
export default function App() {
|
||
const [settings, setSettings] = useState(emptySettings);
|
||
const [accounts, setAccounts] = useState([]);
|
||
const [accountStats, setAccountStats] = useState([]);
|
||
const [accountAssignments, setAccountAssignments] = useState([]);
|
||
const [logs, setLogs] = useState([]);
|
||
const [invites, setInvites] = useState([]);
|
||
const [tasks, setTasks] = useState([]);
|
||
const [selectedTaskId, setSelectedTaskId] = useState(null);
|
||
const [taskForm, setTaskForm] = useState(emptyTaskForm);
|
||
const [competitorText, setCompetitorText] = useState("");
|
||
const [selectedAccountIds, setSelectedAccountIds] = useState([]);
|
||
const [taskStatus, setTaskStatus] = useState({
|
||
running: false,
|
||
queueCount: 0,
|
||
dailyRemaining: 0,
|
||
dailyUsed: 0,
|
||
dailyLimit: 0,
|
||
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" },
|
||
nextRunAt: ""
|
||
});
|
||
const [taskStatusMap, setTaskStatusMap] = useState({});
|
||
const [membershipStatus, setMembershipStatus] = useState({});
|
||
const [groupVisibility, setGroupVisibility] = useState([]);
|
||
const [accessStatus, setAccessStatus] = useState([]);
|
||
const [accountEvents, setAccountEvents] = useState([]);
|
||
const [loginForm, setLoginForm] = useState({
|
||
apiId: "",
|
||
apiHash: "",
|
||
phone: "",
|
||
code: "",
|
||
password: ""
|
||
});
|
||
const [tdataForm, setTdataForm] = useState({
|
||
apiId: "2040",
|
||
apiHash: "b18441a1ff607e10a989891a5462e627"
|
||
});
|
||
const [tdataResult, setTdataResult] = useState(null);
|
||
const [tdataLoading, setTdataLoading] = useState(false);
|
||
const [loginId, setLoginId] = useState("");
|
||
const [loginStatus, setLoginStatus] = useState("");
|
||
const [taskNotice, setTaskNotice] = useState(null);
|
||
const [settingsNotice, setSettingsNotice] = useState(null);
|
||
const [tdataNotice, setTdataNotice] = useState(null);
|
||
const [notification, setNotification] = useState(null);
|
||
const [notifications, setNotifications] = useState([]);
|
||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||
const [manualLoginOpen, setManualLoginOpen] = useState(false);
|
||
const [taskSearch, setTaskSearch] = useState("");
|
||
const [taskFilter, setTaskFilter] = useState("all");
|
||
const [notificationFilter, setNotificationFilter] = useState("all");
|
||
const [infoOpen, setInfoOpen] = useState(false);
|
||
const [activeTab, setActiveTab] = useState("task");
|
||
const [logsTab, setLogsTab] = useState("logs");
|
||
const [logSearch, setLogSearch] = useState("");
|
||
const [inviteSearch, setInviteSearch] = useState("");
|
||
const [logPage, setLogPage] = useState(1);
|
||
const [invitePage, setInvitePage] = useState(1);
|
||
const [inviteFilter, setInviteFilter] = useState("all");
|
||
const [taskSort, setTaskSort] = useState("activity");
|
||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
||
const [expandedInviteId, setExpandedInviteId] = useState(null);
|
||
const [now, setNow] = useState(Date.now());
|
||
const bellRef = useRef(null);
|
||
const settingsAutosaveReady = useRef(false);
|
||
|
||
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 roleMode = taskForm.requireSameBotInBoth ? "same" : taskForm.separateBotRoles ? "split" : "shared";
|
||
const canSaveTask = Boolean(
|
||
taskForm.name.trim() &&
|
||
taskForm.ourGroup.trim() &&
|
||
competitorGroups.length > 0
|
||
);
|
||
const accountById = useMemo(() => {
|
||
const map = new Map();
|
||
accounts.forEach((account) => {
|
||
map.set(account.id, account);
|
||
});
|
||
return map;
|
||
}, [accounts]);
|
||
const assignedAccountMap = useMemo(() => {
|
||
const map = new Map();
|
||
accountAssignments.forEach((row) => {
|
||
const list = map.get(row.account_id) || [];
|
||
list.push(row.task_id);
|
||
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.includes(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 loadTasks = 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;
|
||
};
|
||
|
||
const loadAccountAssignments = async () => {
|
||
if (!window.api) return;
|
||
const assignments = await window.api.listAccountAssignments();
|
||
setAccountAssignments(assignments || []);
|
||
};
|
||
|
||
const loadTaskStatuses = 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);
|
||
};
|
||
|
||
const loadSelectedTask = async (taskId) => {
|
||
if (!taskId) {
|
||
setTaskForm(emptyTaskForm);
|
||
setCompetitorText("");
|
||
setSelectedAccountIds([]);
|
||
setLogs([]);
|
||
setInvites([]);
|
||
setGroupVisibility([]);
|
||
setTaskStatus({
|
||
running: false,
|
||
queueCount: 0,
|
||
dailyRemaining: 0,
|
||
dailyUsed: 0,
|
||
dailyLimit: 0,
|
||
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" },
|
||
nextRunAt: ""
|
||
});
|
||
return;
|
||
}
|
||
const details = await window.api.getTask(taskId);
|
||
if (!details) return;
|
||
setTaskForm(sanitizeTaskForm({ ...emptyTaskForm, ...normalizeTask(details.task) }));
|
||
setCompetitorText((details.competitors || []).join("\n"));
|
||
setSelectedAccountIds(details.accountIds || []);
|
||
setLogs(await window.api.listLogs({ limit: 100, taskId }));
|
||
setInvites(await window.api.listInvites({ limit: 200, taskId }));
|
||
setGroupVisibility([]);
|
||
setTaskStatus(await window.api.taskStatus(taskId));
|
||
};
|
||
|
||
const loadBase = async () => {
|
||
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 || []);
|
||
const tasksData = await loadTasks();
|
||
await loadAccountAssignments();
|
||
await loadTaskStatuses(tasksData);
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadBase();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSelectedTask(selectedTaskId);
|
||
setAccessStatus([]);
|
||
setMembershipStatus({});
|
||
setTaskNotice(null);
|
||
}, [selectedTaskId]);
|
||
|
||
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 = taskSearch.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, taskSearch, taskFilter, taskSort, taskStatusMap]);
|
||
|
||
useEffect(() => {
|
||
if (!window.api) return undefined;
|
||
const interval = setInterval(async () => {
|
||
const tasksData = await window.api.listTasks();
|
||
setTasks(tasksData);
|
||
await loadTaskStatuses(tasksData);
|
||
setAccounts(await window.api.listAccounts());
|
||
setAccountAssignments(await window.api.listAccountAssignments());
|
||
const statusData = await window.api.getStatus();
|
||
setAccountStats(statusData.accountStats || []);
|
||
if (selectedTaskId != null) {
|
||
setTaskStatus(await window.api.taskStatus(selectedTaskId));
|
||
}
|
||
}, 5000);
|
||
return () => clearInterval(interval);
|
||
}, [selectedTaskId]);
|
||
|
||
useEffect(() => {
|
||
if (!window.api || activeTab !== "logs" || selectedTaskId == null) return undefined;
|
||
const load = async () => {
|
||
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
|
||
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
|
||
};
|
||
load();
|
||
const interval = setInterval(load, 5000);
|
||
return () => clearInterval(interval);
|
||
}, [activeTab, selectedTaskId]);
|
||
|
||
useEffect(() => {
|
||
if (!window.api || activeTab !== "events") return undefined;
|
||
const load = async () => {
|
||
setAccountEvents(await window.api.listAccountEvents(200));
|
||
};
|
||
load();
|
||
const interval = setInterval(load, 10000);
|
||
return () => clearInterval(interval);
|
||
}, [activeTab]);
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedTaskId == null) return;
|
||
setTaskStatusMap((prev) => ({
|
||
...prev,
|
||
[selectedTaskId]: taskStatus
|
||
}));
|
||
}, [selectedTaskId, taskStatus]);
|
||
|
||
const formatAccountStatus = (status) => {
|
||
if (status === "limited") return "В спаме";
|
||
if (status === "ok") return "ОК";
|
||
return status || "Неизвестно";
|
||
};
|
||
|
||
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");
|
||
};
|
||
|
||
const formatCountdown = (target) => {
|
||
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")}`;
|
||
};
|
||
|
||
const explainInviteError = (error) => {
|
||
if (!error) return "";
|
||
if (error === "USER_ID_INVALID") {
|
||
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
|
||
}
|
||
if (error === "CHAT_WRITE_FORBIDDEN") {
|
||
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
|
||
}
|
||
if (error === "AUTH_KEY_DUPLICATED") {
|
||
return "Сессия используется в другом месте, Telegram отозвал ключ.";
|
||
}
|
||
if (error.includes("FLOOD") || error.includes("PEER_FLOOD")) {
|
||
return "Ограничение Telegram по частоте действий.";
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const showNotification = (text, tone) => {
|
||
if (tone === "success") return;
|
||
const entry = { text, tone, id: Date.now() };
|
||
setNotification(entry);
|
||
setNotifications((prev) => [entry, ...prev].slice(0, 6));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!notification) return undefined;
|
||
const timer = setTimeout(() => {
|
||
setNotification(null);
|
||
}, 6000);
|
||
return () => clearTimeout(timer);
|
||
}, [notification]);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (!notificationsOpen) return;
|
||
if (!bellRef.current) return;
|
||
if (!bellRef.current.contains(event.target)) {
|
||
setNotificationsOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [notificationsOpen]);
|
||
|
||
const filteredNotifications = useMemo(() => {
|
||
if (notificationFilter === "all") return notifications;
|
||
return notifications.filter((item) => item.tone === notificationFilter);
|
||
}, [notifications, notificationFilter]);
|
||
|
||
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 === "success") return false;
|
||
if (inviteFilter === "skipped" && !invite.skippedReason) return false;
|
||
const text = [
|
||
invite.invitedAt,
|
||
invite.userId,
|
||
invite.username,
|
||
invite.sourceChat,
|
||
invite.accountPhone,
|
||
invite.watcherPhone,
|
||
invite.strategy,
|
||
invite.strategyMeta,
|
||
invite.error,
|
||
invite.skippedReason
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
if (!query) return true;
|
||
return text.includes(query);
|
||
});
|
||
}, [invites, inviteSearch, inviteFilter]);
|
||
|
||
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 logPageSize = 20;
|
||
const invitePageSize = 20;
|
||
const logPageCount = Math.max(1, Math.ceil(filteredLogs.length / logPageSize));
|
||
const invitePageCount = Math.max(1, Math.ceil(filteredInvites.length / invitePageSize));
|
||
const pagedLogs = filteredLogs.slice((logPage - 1) * logPageSize, logPage * logPageSize);
|
||
const pagedInvites = filteredInvites.slice((invitePage - 1) * invitePageSize, invitePage * invitePageSize);
|
||
|
||
const onSettingsChange = (field, value) => {
|
||
setSettings((prev) => ({
|
||
...prev,
|
||
[field]: value
|
||
}));
|
||
};
|
||
|
||
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;
|
||
}
|
||
setTaskForm({ ...taskForm, requireSameBotInBoth: false, separateBotRoles: false });
|
||
};
|
||
|
||
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");
|
||
}
|
||
};
|
||
|
||
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");
|
||
}
|
||
};
|
||
|
||
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]);
|
||
|
||
const createTask = () => {
|
||
setSelectedTaskId(null);
|
||
setTaskForm(emptyTaskForm);
|
||
setCompetitorText("");
|
||
setSelectedAccountIds([]);
|
||
setAccessStatus([]);
|
||
setMembershipStatus({});
|
||
};
|
||
|
||
const selectTask = (taskId) => {
|
||
if (taskId === selectedTaskId) return;
|
||
setSelectedTaskId(taskId);
|
||
};
|
||
|
||
const saveTask = async (source = "editor") => {
|
||
if (!window.api) {
|
||
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
|
||
return;
|
||
}
|
||
try {
|
||
showNotification("Сохраняем задачу...", "info");
|
||
const nextForm = sanitizeTaskForm(taskForm);
|
||
setTaskForm(nextForm);
|
||
let accountIds = selectedAccountIds;
|
||
if (nextForm.autoAssignAccounts && (!accountIds || accountIds.length === 0)) {
|
||
accountIds = accounts.map((account) => account.id);
|
||
setSelectedAccountIds(accountIds);
|
||
if (accountIds.length) {
|
||
setTaskNotice({ text: `Автоназначены аккаунты: ${accountIds.length}`, tone: "success", source });
|
||
}
|
||
}
|
||
if (!accountIds.length) {
|
||
showNotification("Нет аккаунтов для этой задачи.", "error");
|
||
return;
|
||
}
|
||
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))
|
||
: 1;
|
||
if (accountIds.length < requiredAccounts) {
|
||
showNotification(`Нужно минимум ${requiredAccounts} аккаунтов для выбранного режима.`, "error");
|
||
return;
|
||
}
|
||
const result = await window.api.saveTask({
|
||
task: nextForm,
|
||
competitors: competitorGroups,
|
||
accountIds
|
||
});
|
||
if (result.ok) {
|
||
setTaskNotice({ text: "Задача сохранена.", tone: "success", source });
|
||
await loadTasks();
|
||
await loadAccountAssignments();
|
||
setSelectedTaskId(result.taskId);
|
||
} else {
|
||
showNotification(result.error || "Не удалось сохранить задачу", "error");
|
||
}
|
||
} catch (error) {
|
||
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" });
|
||
await loadTasks();
|
||
await loadAccountAssignments();
|
||
} catch (error) {
|
||
showNotification(error.message || String(error), "error");
|
||
}
|
||
};
|
||
|
||
const startTask = async (source = "sidebar") => {
|
||
if (!window.api || selectedTaskId == null) {
|
||
showNotification("Сначала выберите задачу.", "error");
|
||
return;
|
||
}
|
||
showNotification("Запуск...", "info");
|
||
try {
|
||
const result = await window.api.startTaskById(selectedTaskId);
|
||
if (result && result.ok) {
|
||
setTaskNotice({ text: "Запущено.", tone: "success", source });
|
||
} else {
|
||
showNotification(result.error || "Не удалось запустить", "error");
|
||
}
|
||
} catch (error) {
|
||
const message = error.message || String(error);
|
||
setTaskNotice({ text: message, tone: "error", source });
|
||
showNotification(message, "error");
|
||
}
|
||
};
|
||
|
||
const startAllTasks = async () => {
|
||
if (!window.api) {
|
||
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
|
||
return;
|
||
}
|
||
if (!tasks.length) {
|
||
showNotification("Нет задач для запуска.", "info");
|
||
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 (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) {
|
||
return;
|
||
}
|
||
showNotification("Остановка...", "info");
|
||
try {
|
||
await window.api.stopTaskById(selectedTaskId);
|
||
setTaskNotice({ text: "Остановлено.", tone: "success", source });
|
||
} catch (error) {
|
||
const message = error.message || String(error);
|
||
setTaskNotice({ text: message, tone: "error", source });
|
||
showNotification(message, "error");
|
||
}
|
||
};
|
||
|
||
const stopAllTasks = async () => {
|
||
if (!window.api) {
|
||
showNotification("Electron API недоступен. Откройте приложение в Electron.", "error");
|
||
return;
|
||
}
|
||
if (!tasks.length) {
|
||
showNotification("Нет задач для остановки.", "info");
|
||
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 refreshMembership = async (source = "editor") => {
|
||
if (!window.api || selectedTaskId == null) {
|
||
showNotification("Сначала выберите задачу.", "error");
|
||
return;
|
||
}
|
||
showNotification("Проверяем участие аккаунтов...", "info");
|
||
try {
|
||
const status = await window.api.membershipStatusByTask(selectedTaskId);
|
||
const visibility = await window.api.groupVisibilityByTask(selectedTaskId);
|
||
const map = {};
|
||
status.forEach((item) => {
|
||
map[item.accountId] = item;
|
||
});
|
||
setMembershipStatus(map);
|
||
setGroupVisibility(visibility && visibility.result ? visibility.result : []);
|
||
setTaskNotice({ text: "Статус участия обновлен.", tone: "success", source });
|
||
} catch (error) {
|
||
const message = error.message || String(error);
|
||
setTaskNotice({ text: message, tone: "error", source });
|
||
showNotification(message, "error");
|
||
}
|
||
};
|
||
|
||
const checkAccess = async (source = "editor") => {
|
||
if (!window.api || selectedTaskId == null) {
|
||
showNotification("Сначала выберите задачу.", "error");
|
||
return;
|
||
}
|
||
showNotification("Проверяем доступ к группам...", "info");
|
||
try {
|
||
const result = await window.api.checkAccessByTask(selectedTaskId);
|
||
if (!result.ok) {
|
||
showNotification(result.error || "Не удалось проверить доступ", "error");
|
||
return;
|
||
}
|
||
setAccessStatus(result.result || []);
|
||
setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
|
||
} 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 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 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);
|
||
setTaskForm(emptyTaskForm);
|
||
setCompetitorText("");
|
||
setSelectedAccountIds([]);
|
||
setLogs([]);
|
||
setInvites([]);
|
||
setTaskStatus({
|
||
running: false,
|
||
queueCount: 0,
|
||
dailyRemaining: 0,
|
||
dailyUsed: 0,
|
||
dailyLimit: 0,
|
||
monitorInfo: { monitoring: false, groups: [], lastMessageAt: "", lastSource: "" }
|
||
});
|
||
await loadBase();
|
||
} catch (error) {
|
||
showNotification(error.message || String(error), "error");
|
||
}
|
||
};
|
||
|
||
const toggleAccountSelection = (accountId) => {
|
||
setSelectedAccountIds((prev) => {
|
||
if (prev.includes(accountId)) {
|
||
return prev.filter((id) => id !== accountId);
|
||
}
|
||
return [...prev, accountId];
|
||
});
|
||
};
|
||
|
||
const assignAccountsToTask = async (accountIds) => {
|
||
if (!window.api || selectedTaskId == null) return;
|
||
if (!accountIds.length) return;
|
||
const result = await window.api.appendTaskAccounts({
|
||
taskId: selectedTaskId,
|
||
accountIds
|
||
});
|
||
if (result && result.ok) {
|
||
setSelectedAccountIds(result.accountIds || []);
|
||
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) {
|
||
setSelectedAccountIds(result.accountIds || []);
|
||
await loadAccountAssignments();
|
||
setTaskNotice({ text: "Аккаунт удален из задачи.", tone: "success", source: "accounts" });
|
||
}
|
||
};
|
||
|
||
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) {
|
||
showNotification(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 (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 (
|
||
<div className="app">
|
||
<header className="header">
|
||
<div>
|
||
<h1>Автоматизация инвайтов</h1>
|
||
<p>Парсинг сообщений и приглашения в целевые группы.</p>
|
||
</div>
|
||
<div className="header-actions">
|
||
<div className="task-switcher">
|
||
<div className="task-switcher-label">Текущая задача</div>
|
||
<select
|
||
value={selectedTaskId || ""}
|
||
onChange={(event) => selectTask(event.target.value ? Number(event.target.value) : null)}
|
||
>
|
||
<option value="">Задача не выбрана</option>
|
||
{tasks.map((task) => (
|
||
<option key={task.id} value={task.id}>
|
||
{task.name || `Задача #${task.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="notification-bell" ref={bellRef}>
|
||
<button
|
||
type="button"
|
||
className={`icon-btn secondary ${notificationsOpen ? "active" : ""}`}
|
||
onClick={() => setNotificationsOpen((prev) => !prev)}
|
||
>
|
||
🔔
|
||
</button>
|
||
{notifications.length > 0 && (
|
||
<span className="bell-badge">{notifications.length}</span>
|
||
)}
|
||
{notificationsOpen && (
|
||
<div className="bell-panel">
|
||
<div className="bell-header">
|
||
<span>Уведомления</span>
|
||
<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}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`icon-btn secondary ${infoOpen ? "active" : ""}`}
|
||
onClick={() => setInfoOpen(true)}
|
||
>
|
||
ℹ️
|
||
</button>
|
||
</div>
|
||
</header>
|
||
{infoOpen && (
|
||
<div className="modal-overlay" onClick={() => setInfoOpen(false)}>
|
||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||
<div className="row-header">
|
||
<h2>Как пользоваться</h2>
|
||
<button className="ghost" type="button" onClick={() => setInfoOpen(false)}>Закрыть</button>
|
||
</div>
|
||
<ol className="help-list">
|
||
<li>Создайте задачу: название, наша группа и группы конкурентов.</li>
|
||
<li>Выберите аккаунты для задачи и сохраните.</li>
|
||
<li>Нажмите “Собрать историю”, чтобы добавить авторов из последних сообщений.</li>
|
||
<li>Нажмите “Запустить”, чтобы отслеживать новые сообщения и приглашать по расписанию.</li>
|
||
<li>Создавайте несколько задач для разных групп и контролируйте их по списку.</li>
|
||
</ol>
|
||
<p className="help-note">
|
||
“Собрать историю” добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="top-row">
|
||
<section className="card task-accounts">
|
||
<div className="row-header">
|
||
<h2>Аккаунты задачи</h2>
|
||
</div>
|
||
<div className="login-box">
|
||
<div className="row-header">
|
||
<h3>Добавить аккаунт по коду</h3>
|
||
<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">
|
||
<h3>Импорт из tdata</h3>
|
||
<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>}
|
||
{tdataNotice && (
|
||
<div className={`notice inline ${tdataNotice.tone}`}>{tdataNotice.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>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card overview">
|
||
<div className="row-header">
|
||
<div className="row-header-main">
|
||
<h2>Общий обзор</h2>
|
||
<div className="status-caption">Все задачи</div>
|
||
</div>
|
||
<div className="row-inline">
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={startAllTasks}
|
||
disabled={!tasks.length}
|
||
>
|
||
Запустить все
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={stopAllTasks}
|
||
disabled={!tasks.length}
|
||
>
|
||
Остановить все
|
||
</button>
|
||
<button type="button" className="danger" onClick={clearDatabase}>
|
||
Очистить БД
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<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">{taskSummary.running}</div>
|
||
</div>
|
||
<div className="summary-card">
|
||
<div className="live-label">Очередь</div>
|
||
<div className="summary-value">{taskSummary.queue}</div>
|
||
</div>
|
||
<div className="summary-card">
|
||
<div className="live-label">Лимит в день</div>
|
||
<div className="summary-value">{taskSummary.dailyUsed}/{taskSummary.dailyLimit}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{notification && (
|
||
<div className={`notice ${notification.tone}`}>
|
||
{notification.text}
|
||
</div>
|
||
)}
|
||
|
||
<div className="layout">
|
||
<aside className="sidebar left">
|
||
<section className="card sticky">
|
||
<div className="row-header">
|
||
<h2>Задачи</h2>
|
||
<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>
|
||
{hasSelectedTask && (
|
||
<div className="task-summary">
|
||
<div className="task-summary-title">Выбрано: {selectedTaskName}</div>
|
||
<div className="task-summary-row">
|
||
<span>Очередь: {taskStatus.queueCount}</span>
|
||
<span>Лимит: {taskStatus.dailyUsed}/{taskStatus.dailyLimit}</span>
|
||
<span>Статус: {taskStatus.running ? "Запущено" : "Остановлено"}</span>
|
||
</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 queueLabel = status ? `Очередь: ${status.queueCount}` : "Очередь: —";
|
||
const dailyLabel = status ? `Лимит: ${status.dailyUsed}/${status.dailyLimit}` : "Лимит: —";
|
||
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 monitorAccountId = status && status.monitorInfo ? status.monitorInfo.accountId : 0;
|
||
const monitorAccount = monitorAccountId ? accountById.get(monitorAccountId) : null;
|
||
const monitorLabel = monitorAccount
|
||
? (monitorAccount.phone || monitorAccount.user_id || String(monitorAccountId))
|
||
: (monitorAccountId ? String(monitorAccountId) : "—");
|
||
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 ${statusClass}`}>{statusLabel}</div>
|
||
</div>
|
||
<div className="task-meta-row">
|
||
<span className="task-meta">{queueLabel}</span>
|
||
<span className="task-meta">{dailyLabel}</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="sidebar-actions">
|
||
<button className="danger" type="button" onClick={deleteTask} disabled={!hasSelectedTask}>Удалить задачу</button>
|
||
</div>
|
||
{taskNotice && taskNotice.source === "tasks" && (
|
||
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
|
||
)}
|
||
</section>
|
||
</aside>
|
||
|
||
<div className="main">
|
||
<div className="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 === "logs" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("logs")}
|
||
>
|
||
Логи
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`tab ${activeTab === "events" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("events")}
|
||
>
|
||
События
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`tab ${activeTab === "settings" ? "active" : ""}`}
|
||
onClick={() => setActiveTab("settings")}
|
||
>
|
||
Настройки
|
||
</button>
|
||
</div>
|
||
|
||
{activeTab === "task" && (
|
||
<>
|
||
<section className="card live">
|
||
<div className="row-header">
|
||
<h2>Статус задачи</h2>
|
||
<div className="status-caption">Для: {selectedTaskName}</div>
|
||
</div>
|
||
<div className="status-text compact">Отображаются данные только для выбранной задачи.</div>
|
||
<div className="live-grid">
|
||
<div>
|
||
<div className="live-label">Состояние</div>
|
||
<div className="live-value">{taskStatus.running ? "Запущено" : "Остановлено"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Наблюдение</div>
|
||
<div className="live-value">{taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "Активно" : "Нет"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Группы в мониторинге</div>
|
||
<div className="live-value">{taskStatus.monitorInfo && taskStatus.monitorInfo.groups ? taskStatus.monitorInfo.groups.length : 0}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Мониторит</div>
|
||
<div className="live-value">
|
||
{(() => {
|
||
const monitorId = taskStatus.monitorInfo ? taskStatus.monitorInfo.accountId : 0;
|
||
const account = monitorId ? accountById.get(monitorId) : null;
|
||
if (!monitorId) return "—";
|
||
return account ? (account.phone || account.user_id || String(monitorId)) : String(monitorId);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Последнее сообщение</div>
|
||
<div className="live-value">{formatTimestamp(taskStatus.monitorInfo ? taskStatus.monitorInfo.lastMessageAt : "")}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Источник</div>
|
||
<div className="live-value wrap">{taskStatus.monitorInfo && taskStatus.monitorInfo.lastSource ? taskStatus.monitorInfo.lastSource : "—"}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Очередь инвайтов</div>
|
||
<div className="live-value">{taskStatus.queueCount}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Лимит в день</div>
|
||
<div className="live-value">{taskStatus.dailyUsed}/{taskStatus.dailyLimit}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Осталось сегодня</div>
|
||
<div className="live-value">{taskStatus.dailyRemaining}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Следующий цикл</div>
|
||
<div className="live-value">{formatCountdown(taskStatus.nextRunAt)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="live-label">Стратегии OK/Fail</div>
|
||
<div className="live-value">{inviteStrategyStats.success}/{inviteStrategyStats.failed}</div>
|
||
</div>
|
||
</div>
|
||
<div className="status-actions">
|
||
{taskStatus.running ? (
|
||
<button className="danger" onClick={() => stopTask("status")} disabled={!hasSelectedTask}>Остановить</button>
|
||
) : (
|
||
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button>
|
||
)}
|
||
</div>
|
||
{groupVisibility.length > 0 && (
|
||
<div className="status-text">
|
||
{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>
|
||
)}
|
||
</section>
|
||
|
||
<section className="card task-editor">
|
||
<div className="row-header">
|
||
<h2>Настройки задачи</h2>
|
||
<div className="status-caption">Для: {selectedTaskName}</div>
|
||
</div>
|
||
<div className="row-inline">
|
||
<button className="secondary task-toolbar" onClick={() => saveTask("editor")} disabled={!canSaveTask}>💾 Сохранить</button>
|
||
<button className="secondary task-toolbar" onClick={() => parseHistory("editor")} disabled={!hasSelectedTask}>📥 История</button>
|
||
<button className="secondary task-toolbar" onClick={() => checkAccess("editor")} disabled={!hasSelectedTask}>🔎 Доступ</button>
|
||
<button className="secondary task-toolbar" onClick={() => refreshMembership("editor")} disabled={!hasSelectedTask}>👥 Участие</button>
|
||
</div>
|
||
{taskNotice && taskNotice.source === "editor" && (
|
||
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
|
||
)}
|
||
<div className="section-title">Основное</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>
|
||
<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 === "shared"}
|
||
onChange={() => applyRoleMode("shared")}
|
||
/>
|
||
Без разделения (любые аккаунты могут приглашать)
|
||
</label>
|
||
<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) => setTaskForm({ ...taskForm, minIntervalMinutes: Number(event.target.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) => setTaskForm({ ...taskForm, maxIntervalMinutes: Number(event.target.value) })}
|
||
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">Лимит в день <span className="required">*</span></span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.dailyLimit}
|
||
onChange={(event) => setTaskForm({ ...taskForm, dailyLimit: Number(event.target.value) })}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">История сообщений (шт) <span className="required">*</span></span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.historyLimit}
|
||
onChange={(event) => setTaskForm({ ...taskForm, historyLimit: Number(event.target.value) })}
|
||
/>
|
||
</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}
|
||
onChange={(event) => {
|
||
const nextValue = Number(event.target.value);
|
||
const nextForm = { ...taskForm, maxCompetitorBots: nextValue, maxOurBots: nextValue };
|
||
setTaskForm(sanitizeTaskForm(nextForm));
|
||
}}
|
||
/>
|
||
<span className="hint">Одинаковое количество для конкурентов и нашей группы.</span>
|
||
</label>
|
||
) : (
|
||
<>
|
||
<label>
|
||
<span className="label-line">Ботов в конкурентах</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.maxCompetitorBots}
|
||
onChange={(event) => {
|
||
const nextValue = Number(event.target.value);
|
||
const nextForm = { ...taskForm, maxCompetitorBots: nextValue };
|
||
setTaskForm(nextForm);
|
||
}}
|
||
/>
|
||
<span className="hint">Используется для авто-вступления в группы конкурентов.</span>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">Ботов в нашей группе</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.maxOurBots}
|
||
onChange={(event) => setTaskForm({ ...taskForm, maxOurBots: Number(event.target.value) })}
|
||
/>
|
||
<span className="hint">
|
||
{roleMode === "split" ? "Ограничивает аккаунты, которые будут приглашать." : "Ограничивает количество инвайтящих аккаунтов."}
|
||
</span>
|
||
</label>
|
||
</>
|
||
)}
|
||
</div>
|
||
</details>
|
||
<details className="section">
|
||
<summary className="section-title">Безопасность</summary>
|
||
<div className="toggle-row">
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.randomAccounts)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
|
||
/>
|
||
Случайный выбор аккаунтов
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.multiAccountsPerRun)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
|
||
/>
|
||
Несколько аккаунтов за цикл
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.retryOnFail)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
|
||
/>
|
||
Повторять при ошибке
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.stopOnBlocked)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
|
||
/>
|
||
Останавливать при блокировках
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label>
|
||
<span className="label-line">Остановить при блоке, %</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.stopBlockedPercent}
|
||
onChange={(event) => setTaskForm({ ...taskForm, stopBlockedPercent: Number(event.target.value) })}
|
||
disabled={!taskForm.stopOnBlocked}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span className="label-line">Заметки</span>
|
||
<textarea
|
||
rows="2"
|
||
value={taskForm.notes}
|
||
onChange={(event) => setTaskForm({ ...taskForm, notes: event.target.value })}
|
||
/>
|
||
</label>
|
||
</details>
|
||
</section>
|
||
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "accounts" && (
|
||
<Suspense fallback={<div className="card">Загрузка...</div>}>
|
||
<AccountsTab
|
||
accounts={accounts}
|
||
accountStats={accountStats}
|
||
settings={settings}
|
||
membershipStatus={membershipStatus}
|
||
assignedAccountMap={assignedAccountMap}
|
||
accountBuckets={accountBuckets}
|
||
filterFreeAccounts={filterFreeAccounts}
|
||
selectedAccountIds={selectedAccountIds}
|
||
hasSelectedTask={hasSelectedTask}
|
||
taskNotice={taskNotice}
|
||
refreshMembership={refreshMembership}
|
||
refreshIdentity={refreshIdentity}
|
||
formatAccountStatus={formatAccountStatus}
|
||
resetCooldown={resetCooldown}
|
||
deleteAccount={deleteAccount}
|
||
toggleAccountSelection={toggleAccountSelection}
|
||
removeAccountFromTask={removeAccountFromTask}
|
||
moveAccountToTask={moveAccountToTask}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
{activeTab === "logs" && (
|
||
<Suspense fallback={<div className="card">Загрузка...</div>}>
|
||
<LogsTab
|
||
logsTab={logsTab}
|
||
setLogsTab={setLogsTab}
|
||
taskNotice={taskNotice}
|
||
hasSelectedTask={hasSelectedTask}
|
||
exportLogs={exportLogs}
|
||
clearLogs={clearLogs}
|
||
exportInvites={exportInvites}
|
||
clearInvites={clearInvites}
|
||
logSearch={logSearch}
|
||
setLogSearch={setLogSearch}
|
||
logPage={logPage}
|
||
setLogPage={setLogPage}
|
||
logPageCount={logPageCount}
|
||
pagedLogs={pagedLogs}
|
||
inviteSearch={inviteSearch}
|
||
setInviteSearch={setInviteSearch}
|
||
invitePage={invitePage}
|
||
setInvitePage={setInvitePage}
|
||
invitePageCount={invitePageCount}
|
||
inviteFilter={inviteFilter}
|
||
setInviteFilter={setInviteFilter}
|
||
pagedInvites={pagedInvites}
|
||
formatTimestamp={formatTimestamp}
|
||
explainInviteError={explainInviteError}
|
||
expandedInviteId={expandedInviteId}
|
||
setExpandedInviteId={setExpandedInviteId}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
|
||
{activeTab === "events" && (
|
||
<Suspense fallback={<div className="card">Загрузка...</div>}>
|
||
<EventsTab accountEvents={accountEvents} formatTimestamp={formatTimestamp} />
|
||
</Suspense>
|
||
)}
|
||
|
||
{activeTab === "settings" && (
|
||
<Suspense fallback={<div className="card">Загрузка...</div>}>
|
||
<SettingsTab
|
||
settings={settings}
|
||
onSettingsChange={onSettingsChange}
|
||
settingsNotice={settingsNotice}
|
||
saveSettings={saveSettings}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</div><aside className="sidebar right">
|
||
<section className="card sticky">
|
||
<div className="row-header">
|
||
<h2>Управление задачей</h2>
|
||
<div className="status-caption">{selectedTaskName}</div>
|
||
</div>
|
||
<div className="side-stats">
|
||
<div className="side-stat">
|
||
<span>Группы конкурентов</span>
|
||
<strong>{competitorGroups.length}</strong>
|
||
</div>
|
||
<div className="side-stat">
|
||
<span>Аккаунты</span>
|
||
<strong>{selectedAccountIds.length}</strong>
|
||
</div>
|
||
<div className="side-stat">
|
||
<span>Очередь</span>
|
||
<strong>{taskStatus.queueCount}</strong>
|
||
</div>
|
||
<div className="side-stat">
|
||
<span>Лимит сегодня</span>
|
||
<strong>{taskStatus.dailyUsed}/{taskStatus.dailyLimit}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="sidebar-actions">
|
||
<button
|
||
className="ghost"
|
||
type="button"
|
||
onClick={() => setSidebarExpanded((prev) => !prev)}
|
||
>
|
||
{sidebarExpanded ? "Свернуть действия" : "Показать действия"}
|
||
</button>
|
||
</div>
|
||
{sidebarExpanded && (
|
||
<div className="sidebar-actions expanded">
|
||
<button className="secondary" onClick={() => saveTask("sidebar")} disabled={!canSaveTask}>Сохранить задачу</button>
|
||
<button className="secondary" onClick={() => parseHistory("sidebar")} disabled={!hasSelectedTask}>Собрать историю</button>
|
||
<button className="secondary" onClick={() => checkAccess("sidebar")} disabled={!hasSelectedTask}>Проверить доступ</button>
|
||
<button className="secondary" onClick={() => refreshMembership("sidebar")} disabled={!hasSelectedTask}>Проверить участие</button>
|
||
<button className="secondary" onClick={() => clearQueue("sidebar")} disabled={!hasSelectedTask}>Очистить очередь</button>
|
||
{accessStatus.length > 0 && (
|
||
<div className="access-block">
|
||
<div className="access-title">Доступ к группам</div>
|
||
<div className="access-list">
|
||
{accessStatus.map((item, index) => (
|
||
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
|
||
<div className="access-title">
|
||
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
|
||
</div>
|
||
<div className="access-status">
|
||
{item.ok ? "Доступ есть" : "Нет доступа"}
|
||
</div>
|
||
{!item.ok && <div className="access-error">{item.details}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{taskNotice && taskNotice.source === "sidebar" && (
|
||
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
|
||
)}
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|