This commit is contained in:
Ivan Neplokhov 2026-01-20 01:02:42 +04:00
parent a5d55012a8
commit d299eb36be
7 changed files with 391 additions and 51 deletions

View File

@ -13,6 +13,97 @@ let telegram;
let scheduler;
const taskRunners = new Map();
const filterTaskRolesByAccounts = (taskId, roles, accounts) => {
const accountMap = new Map(accounts.map((acc) => [acc.id, acc]));
const filtered = [];
let removedMissing = 0;
let removedError = 0;
roles.forEach((row) => {
const account = accountMap.get(row.account_id);
if (!account) {
removedMissing += 1;
return;
}
if (account.status && account.status !== "ok") {
removedError += 1;
return;
}
filtered.push({
accountId: row.account_id,
roleMonitor: Boolean(row.role_monitor),
roleInvite: Boolean(row.role_invite)
});
});
if (removedMissing || removedError) {
store.setTaskAccountRoles(taskId, filtered);
}
return { filtered, removedMissing, removedError };
};
const startTaskWithChecks = async (id) => {
const task = store.getTask(id);
if (!task) return { ok: false, error: "Task not found" };
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
const taskAccounts = store.listTaskAccounts(id);
const existingAccounts = store.listAccounts();
const filteredResult = filterTaskRolesByAccounts(id, taskAccounts, existingAccounts);
const filteredRoles = filteredResult.filtered;
const inviteIds = filteredRoles.filter((row) => row.roleInvite).map((row) => row.accountId);
const monitorIds = filteredRoles.filter((row) => row.roleMonitor).map((row) => row.accountId);
if (!inviteIds.length) {
return { ok: false, error: "Нет аккаунтов с ролью инвайта." };
}
if (!monitorIds.length) {
return { ok: false, error: "Нет аккаунтов с ролью мониторинга." };
}
const accessCheck = await telegram.checkGroupAccess(competitors, task.our_group);
if (accessCheck && accessCheck.ok) {
const ourAccess = accessCheck.result.find((item) => item.type === "our");
if (ourAccess && !ourAccess.ok) {
return { ok: false, error: `Нет доступа к нашей группе: ${ourAccess.details || ourAccess.value}` };
}
}
const inviteAccess = await telegram.checkInvitePermissions(task, inviteIds);
if (inviteAccess && inviteAccess.ok) {
store.setTaskInviteAccess(id, inviteAccess.result || []);
} else if (inviteAccess && inviteAccess.error) {
return { ok: false, error: inviteAccess.error };
}
let runner = taskRunners.get(id);
if (!runner) {
runner = new TaskRunner(store, telegram, task);
taskRunners.set(id, runner);
} else {
runner.task = task;
}
await runner.start();
const warnings = [];
if (accessCheck && accessCheck.ok) {
const competitorIssues = accessCheck.result.filter((item) => item.type === "competitor" && !item.ok);
if (competitorIssues.length) {
warnings.push(`Нет доступа к ${competitorIssues.length} группе(ам) конкурентов.`);
}
}
if (inviteAccess && inviteAccess.ok) {
const missingSessions = (inviteAccess.result || []).filter((row) => !row.ok || row.reason === "Сессия не подключена");
if (missingSessions.length) {
warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`);
}
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
if (noRights.length) {
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
}
}
if (filteredResult.removedError) {
warnings.push(`Отключены аккаунты с ошибкой: ${filteredResult.removedError}.`);
}
if (filteredResult.removedMissing) {
warnings.push(`Удалены отсутствующие аккаунты: ${filteredResult.removedMissing}.`);
}
return { ok: true, warnings };
};
function createWindow() {
const iconPath = path.join(__dirname, "..", "..", "resources", "icon.png");
mainWindow = new BrowserWindow({
@ -236,17 +327,7 @@ ipcMain.handle("tasks:delete", (_event, id) => {
return { ok: true };
});
ipcMain.handle("tasks:start", async (_event, id) => {
const task = store.getTask(id);
if (!task) return { ok: false, error: "Task not found" };
let runner = taskRunners.get(id);
if (!runner) {
runner = new TaskRunner(store, telegram, task);
taskRunners.set(id, runner);
} else {
runner.task = task;
}
await runner.start();
return { ok: true };
return startTaskWithChecks(id);
});
ipcMain.handle("tasks:stop", (_event, id) => {
const runner = taskRunners.get(id);
@ -310,15 +391,12 @@ ipcMain.handle("tasks:startAll", async () => {
skipped += 1;
continue;
}
let runner = taskRunners.get(task.id);
if (!runner) {
runner = new TaskRunner(store, telegram, task);
taskRunners.set(task.id, runner);
} else {
runner.task = task;
}
try {
await runner.start();
const result = await startTaskWithChecks(task.id);
if (!result.ok) {
errors.push({ id: task.id, error: result.error || "start failed" });
continue;
}
started += 1;
} catch (error) {
errors.push({ id: task.id, error: error.message || String(error) });
@ -342,24 +420,48 @@ ipcMain.handle("tasks:status", (_event, id) => {
const task = store.getTask(id);
const monitorInfo = telegram.getTaskMonitorInfo(id);
const warnings = [];
const readiness = { ok: true, reasons: [] };
if (task) {
const accountRows = store.listTaskAccounts(id);
const accounts = store.listAccounts();
const accountsById = new Map(accounts.map((acc) => [acc.id, acc]));
if (runner && runner.isRunning()) {
const sanitized = filterTaskRolesByAccounts(id, accountRows, accounts);
if (sanitized.removedError || sanitized.removedMissing) {
warnings.push(`Авто-синхронизация ролей: удалено ${sanitized.removedMissing + sanitized.removedError} аккаунт(ов).`);
}
if (!sanitized.filtered.length) {
warnings.push("Задача остановлена: нет доступных аккаунтов.");
runner.stop();
}
}
const inviteRows = accountRows.filter((row) => row.role_invite);
const monitorRows = accountRows.filter((row) => row.role_monitor);
if (!inviteRows.length) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью инвайта.");
}
if (!monitorRows.length) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с ролью мониторинга.");
}
if (task.require_same_bot_in_both) {
const hasSame = accountRows.some((row) => row.role_monitor && row.role_invite);
if (!hasSame) {
warnings.push("Режим “один бот в обоих группах” включен, но нет аккаунта с обеими ролями.");
readiness.ok = false;
readiness.reasons.push("Нет аккаунта с двумя ролями в режиме “один бот”.");
}
}
const allAssignments = store.listAllTaskAccounts();
const accountMap = new Map();
const accountTaskMap = new Map();
allAssignments.forEach((row) => {
if (!accountMap.has(row.account_id)) accountMap.set(row.account_id, new Set());
accountMap.get(row.account_id).add(row.task_id);
if (!accountTaskMap.has(row.account_id)) accountTaskMap.set(row.account_id, new Set());
accountTaskMap.get(row.account_id).add(row.task_id);
});
const accountsById = new Map(store.listAccounts().map((acc) => [acc.id, acc]));
const seen = new Set();
accountRows.forEach((row) => {
const tasksForAccount = accountMap.get(row.account_id);
const tasksForAccount = accountTaskMap.get(row.account_id);
if (tasksForAccount && tasksForAccount.size > 1 && !seen.has(row.account_id)) {
seen.add(row.account_id);
const account = accountsById.get(row.account_id);
@ -367,6 +469,57 @@ ipcMain.handle("tasks:status", (_event, id) => {
warnings.push(`Аккаунт ${label} используется в ${tasksForAccount.size} задачах. Лимиты действий/групп общие.`);
}
});
if (inviteRows.length) {
const inviteAccounts = inviteRows.map((row) => accountsById.get(row.account_id)).filter(Boolean);
const badSessions = inviteAccounts.filter((acc) => acc.status && acc.status !== "ok");
if (badSessions.length) {
readiness.ok = false;
readiness.reasons.push(`Есть аккаунты с ошибкой сессии: ${badSessions.length}.`);
}
}
if (runner && runner.isRunning() && queueCount === 0) {
if (!monitorInfo || !monitorInfo.monitoring) {
warnings.push("Очередь пуста: мониторинг не активен.");
} else if (!monitorInfo.groups || monitorInfo.groups.length === 0) {
warnings.push("Очередь пуста: нет групп в мониторинге.");
} else if (!monitorInfo.lastMessageAt) {
warnings.push("Очередь пуста: новых сообщений пока нет.");
} else {
warnings.push(`Очередь пуста: последнее сообщение ${monitorInfo.lastMessageAt}.`);
}
}
if (task.our_group && (task.our_group.includes("joinchat/") || task.our_group.includes("t.me/+"))) {
warnings.push("Целевая группа указана по инвайт-ссылке — доступ может быть ограничен.");
}
if (task.task_invite_access) {
try {
const parsed = JSON.parse(task.task_invite_access);
if (Array.isArray(parsed) && parsed.length) {
const total = parsed.length;
const canInvite = parsed.filter((row) => row.canInvite).length;
const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length;
const isChannel = parsed.some((row) => row.targetType === "channel");
const checkedAt = task.task_invite_access_at || "";
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${checkedAt}.` : ""}`);
if (disconnected) {
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
}
if (isChannel) {
warnings.push("Цель — канал: добавлять участников могут только админы.");
}
if (canInvite === 0) {
readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с правами инвайта.");
}
}
} catch (error) {
// ignore parsing errors
}
}
}
return {
running: runner ? runner.isRunning() : false,
@ -379,7 +532,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
nextInviteAccountId: runner ? runner.getNextInviteAccountId() : 0,
lastInviteAccountId: runner ? runner.getLastInviteAccountId() : 0,
pendingStats: store.getPendingStats(id),
warnings
warnings,
readiness
};
});
@ -425,7 +579,11 @@ ipcMain.handle("tasks:checkInviteAccess", async (_event, id) => {
const accountIds = accountRows
.filter((row) => existingIds.has(row.account_id))
.map((row) => row.account_id);
return telegram.checkInvitePermissions(task, accountIds);
const result = await telegram.checkInvitePermissions(task, accountIds);
if (result && result.ok) {
store.setTaskInviteAccess(id, result.result || []);
}
return result;
});
ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
@ -538,6 +696,7 @@ ipcMain.handle("status:get", () => {
const dailyRemaining = Math.max(0, Number(settings.dailyLimit || 0) - dailyUsed);
const queueCount = store.getPendingCount();
const accounts = store.listAccounts();
const connectedSessions = telegram.getConnectedCount();
const accountStats = accounts.map((account) => {
const used = store.countInvitesTodayByAccount(account.id);
const limit = Number(account.daily_limit || settings.accountDailyLimit || 0);
@ -556,6 +715,8 @@ ipcMain.handle("status:get", () => {
dailyRemaining,
dailyUsed,
dailyLimit: Number(settings.dailyLimit || 0),
connectedSessions,
totalAccounts: accounts.length,
accountStats,
monitorInfo
};

View File

@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
accountMaxGroups: 10,
accountDailyLimit: 50,
floodCooldownMinutes: 1440,
queueTtlHours: 24,
autoJoinCompetitors: false,
autoJoinOurGroup: false
};
@ -125,6 +126,8 @@ function initStore(userDataPath) {
stop_blocked_percent INTEGER NOT NULL DEFAULT 25,
notes TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
task_invite_access TEXT NOT NULL DEFAULT '',
task_invite_access_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@ -183,6 +186,8 @@ function initStore(userDataPath) {
ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tasks", "task_invite_access", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "task_invite_access_at", "TEXT NOT NULL DEFAULT ''");
ensureColumn("task_accounts", "role_monitor", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("task_accounts", "role_invite", "INTEGER NOT NULL DEFAULT 1");
@ -399,6 +404,18 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0);
}
function clearQueueOlderThan(taskId, hours) {
const limit = Number(hours || 0);
if (!Number.isFinite(limit) || limit <= 0) return 0;
const cutoff = dayjs().subtract(limit, "hour").toISOString();
if (taskId == null) {
const result = db.prepare("DELETE FROM invite_queue WHERE created_at < ?").run(cutoff);
return result.changes || 0;
}
const result = db.prepare("DELETE FROM invite_queue WHERE task_id = ? AND created_at < ?").run(taskId || 0, cutoff);
return result.changes || 0;
}
function listTasks() {
return db.prepare("SELECT * FROM tasks ORDER BY id DESC").all();
}
@ -474,6 +491,13 @@ function initStore(userDataPath) {
return result.lastInsertRowid;
}
function setTaskInviteAccess(taskId, payload) {
const now = dayjs().toISOString();
const value = payload ? JSON.stringify(payload) : "";
db.prepare("UPDATE tasks SET task_invite_access = ?, task_invite_access_at = ?, updated_at = ? WHERE id = ?")
.run(value, value ? now : "", now, taskId);
}
function deleteTask(id) {
db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
db.prepare("DELETE FROM task_competitors WHERE task_id = ?").run(id);
@ -721,6 +745,7 @@ function initStore(userDataPath) {
listTasks,
getTask,
saveTask,
setTaskInviteAccess,
deleteTask,
listTaskCompetitors,
setTaskCompetitors,
@ -746,6 +771,7 @@ function initStore(userDataPath) {
getPendingCount,
getPendingStats,
clearQueue,
clearQueueOlderThan,
markInviteStatus,
incrementInviteAttempt,
recordInvite,

View File

@ -75,6 +75,11 @@ class TaskRunner {
this.nextInviteAccountId = 0;
try {
const settings = this.store.getSettings();
const ttlHours = Number(settings.queueTtlHours || 0);
if (ttlHours > 0) {
this.store.clearQueueOlderThan(this.task.id, ttlHours);
}
const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id);
let inviteAccounts = accounts;
const roles = this.telegram.getTaskRoleAssignments(this.task.id);

View File

@ -2085,6 +2085,10 @@ class TelegramManager {
}
return "";
}
getConnectedCount() {
return this.clients.size;
}
}
module.exports = { TelegramManager };

View File

@ -14,7 +14,8 @@ const emptySettings = {
historyLimit: 200,
accountMaxGroups: 10,
accountDailyLimit: 50,
floodCooldownMinutes: 1440
floodCooldownMinutes: 1440,
queueTtlHours: 24
};
const emptyTaskForm = {
@ -89,6 +90,7 @@ export default function App() {
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 [tasks, setTasks] = useState([]);
@ -129,6 +131,7 @@ export default function App() {
});
const [tdataResult, setTdataResult] = useState(null);
const [tdataLoading, setTdataLoading] = useState(false);
const [taskActionLoading, setTaskActionLoading] = useState(false);
const [loginId, setLoginId] = useState("");
const [loginStatus, setLoginStatus] = useState("");
const [taskNotice, setTaskNotice] = useState(null);
@ -203,6 +206,17 @@ export default function App() {
});
return { monitor, invite };
}, [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]);
return ids.size;
}, [roleSummary]);
const assignedAccountMap = useMemo(() => {
const map = new Map();
accountAssignments.forEach((row) => {
@ -334,6 +348,10 @@ export default function App() {
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);
@ -349,6 +367,10 @@ export default function App() {
setInviteAccessStatus([]);
setMembershipStatus({});
setTaskNotice(null);
if (selectedTaskId != null) {
checkAccess("auto", true);
checkInviteAccess("auto", true);
}
}, [selectedTaskId]);
const taskSummary = useMemo(() => {
@ -447,6 +469,10 @@ export default function App() {
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;
}
@ -516,6 +542,39 @@ export default function App() {
}));
}, [selectedTaskId, taskStatus]);
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);
if (taskForm.maxCompetitorBots !== nextCompetitors || taskForm.maxOurBots !== nextOur) {
setTaskForm((prev) => ({
...prev,
maxCompetitorBots: nextCompetitors,
maxOurBots: nextOur
}));
}
}
}, [
hasSelectedTask,
roleIntersectionCount,
roleSummary.monitor.length,
roleSummary.invite.length,
taskForm.requireSameBotInBoth,
taskForm.separateBotRoles
]);
const formatAccountStatus = (status) => {
if (status === "limited") return "В спаме";
if (status === "error") return "Ошибка";
@ -830,6 +889,23 @@ export default function App() {
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) {
@ -931,11 +1007,16 @@ export default function App() {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (taskActionLoading) return;
setTaskActionLoading(true);
showNotification("Запуск...", "info");
try {
const result = await window.api.startTaskById(selectedTaskId);
if (result && result.ok) {
setTaskNotice({ text: "Запущено.", tone: "success", source });
if (result.warnings && result.warnings.length) {
showNotification(`Предупреждения: ${result.warnings.join(" | ")}`, "info");
}
} else {
showNotification(result.error || "Не удалось запустить", "error");
}
@ -943,6 +1024,8 @@ export default function App() {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
} finally {
setTaskActionLoading(false);
}
};
@ -974,9 +1057,11 @@ export default function App() {
showNotification("Сначала выберите задачу.", "error");
return;
}
if (taskActionLoading) return;
if (!window.confirm(`Остановить задачу: ${selectedTaskName}?`)) {
return;
}
setTaskActionLoading(true);
showNotification("Остановка...", "info");
try {
await window.api.stopTaskById(selectedTaskId);
@ -985,6 +1070,8 @@ export default function App() {
const message = error.message || String(error);
setTaskNotice({ text: message, tone: "error", source });
showNotification(message, "error");
} finally {
setTaskActionLoading(false);
}
};
@ -1060,42 +1147,42 @@ export default function App() {
}
};
const checkAccess = async (source = "editor") => {
const checkAccess = async (source = "editor", silent = false) => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
if (!silent) showNotification("Сначала выберите задачу.", "error");
return;
}
showNotification("Проверяем доступ к группам...", "info");
if (!silent) showNotification("Проверяем доступ к группам...", "info");
try {
const result = await window.api.checkAccessByTask(selectedTaskId);
if (!result.ok) {
showNotification(result.error || "Не удалось проверить доступ", "error");
if (!silent) showNotification(result.error || "Не удалось проверить доступ", "error");
return;
}
setAccessStatus(result.result || []);
setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
if (!silent) setTaskNotice({ text: "Проверка доступа завершена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
if (!silent) showNotification(error.message || String(error), "error");
}
};
const checkInviteAccess = async (source = "editor") => {
const checkInviteAccess = async (source = "editor", silent = false) => {
if (!window.api || selectedTaskId == null) {
showNotification("Сначала выберите задачу.", "error");
if (!silent) showNotification("Сначала выберите задачу.", "error");
return;
}
setInviteAccessStatus([]);
showNotification("Проверяем права инвайта...", "info");
if (!silent) showNotification("Проверяем права инвайта...", "info");
try {
const result = await window.api.checkInviteAccessByTask(selectedTaskId);
if (!result.ok) {
showNotification(result.error || "Не удалось проверить права", "error");
if (!silent) showNotification(result.error || "Не удалось проверить права", "error");
return;
}
setInviteAccessStatus(result.result || []);
setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source });
if (!silent) setTaskNotice({ text: "Проверка прав инвайта завершена.", tone: "success", source });
} catch (error) {
showNotification(error.message || String(error), "error");
if (!silent) showNotification(error.message || String(error), "error");
}
};
@ -1728,6 +1815,12 @@ export default function App() {
<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">
{globalStatus.connectedSessions}/{globalStatus.totalAccounts}
</div>
</div>
<div className="summary-card">
<div className="live-label">Очередь</div>
<div className="summary-value">{taskSummary.queue}</div>
@ -1974,13 +2067,37 @@ export default function App() {
</div>
<div className="status-actions">
{taskStatus.running ? (
<button className="danger" onClick={() => stopTask("status")} disabled={!hasSelectedTask}>Остановить</button>
<button className="danger" onClick={() => stopTask("status")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Остановка..." : "Остановить"}
</button>
) : (
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask}>Запустить</button>
<button className="primary" onClick={() => startTask("status")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Запуск..." : "Запустить"}
</button>
)}
</div>
{hasSelectedTask && (
<div className="status-text">
{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>
)}
{roleSummary.invite.length > 0 && (
<div className="status-text compact">
Лимиты аккаунтов: {roleSummary.invite.map((id) => {
const account = accountById.get(id);
const stats = accountStatsMap.get(id);
if (!account || !stats) return null;
const label = account.phone || account.user_id || id;
const remaining = stats.remainingToday != null ? stats.remainingToday : "—";
return `${label} (${remaining})`;
}).filter(Boolean).join(", ")}
</div>
)}
{taskStatus.warnings && taskStatus.warnings.length > 0 && (
<div className="notice inline warn">
{taskStatus.warnings.map((warning, index) => (
@ -2369,7 +2486,7 @@ export default function App() {
</div>
<div className="side-stat">
<span>Аккаунты</span>
<strong>{selectedAccountIds.length}</strong>
<strong>{assignedAccountCount}</strong>
</div>
<div className="side-stat">
<span>Очередь</span>
@ -2397,6 +2514,15 @@ export default function App() {
<button className="secondary" onClick={() => checkInviteAccess("sidebar")} disabled={!hasSelectedTask}>Проверить права инвайта</button>
<button className="secondary" onClick={() => refreshMembership("sidebar")} disabled={!hasSelectedTask}>Проверить участие</button>
<button className="secondary" onClick={() => clearQueue("sidebar")} disabled={!hasSelectedTask}>Очистить очередь</button>
{taskStatus.running ? (
<button className="danger" onClick={() => stopTask("sidebar")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Остановка..." : "Остановить"}
</button>
) : (
<button className="primary" onClick={() => startTask("sidebar")} disabled={!hasSelectedTask || taskActionLoading}>
{taskActionLoading ? "Запуск..." : "Запустить"}
</button>
)}
{accessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Доступ к группам</div>
@ -2418,6 +2544,9 @@ export default function App() {
{inviteAccessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Права инвайта</div>
<div className="access-subtitle">
Проверяются аккаунты с ролью инвайта: {roleSummary.invite.length}
</div>
<div className="access-list">
{inviteAccessStatus.map((item, index) => (
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>

View File

@ -188,6 +188,12 @@ body {
font-weight: 600;
}
.access-subtitle {
font-size: 11px;
color: #64748b;
margin-bottom: 8px;
}
.access-status {
color: #334155;
}

View File

@ -32,6 +32,15 @@ function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings
onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))}
/>
</label>
<label>
<span className="label-line">Хранить очередь (часы)</span>
<input
type="number"
min="1"
value={settings.queueTtlHours}
onChange={(event) => onSettingsChange("queueTtlHours", Number(event.target.value))}
/>
</label>
</div>
<div className="row-inline">
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button>