This commit is contained in:
Ivan Neplokhov 2026-01-21 14:22:52 +04:00
parent c1c32c7955
commit 58e89e83c1
9 changed files with 307 additions and 44 deletions

View File

@ -454,6 +454,9 @@ ipcMain.handle("tasks:save", (_event, payload) => {
]; ];
} }
if (existing.history_limit !== payload.task.historyLimit) changes.historyLimit = [existing.history_limit, payload.task.historyLimit]; if (existing.history_limit !== payload.task.historyLimit) changes.historyLimit = [existing.history_limit, payload.task.historyLimit];
if (existing.max_invites_per_cycle !== Number(payload.task.maxInvitesPerCycle || 0)) {
changes.maxInvitesPerCycle = [existing.max_invites_per_cycle, Number(payload.task.maxInvitesPerCycle || 0)];
}
if (existing.allow_start_without_invite_rights !== (payload.task.allowStartWithoutInviteRights ? 1 : 0)) { if (existing.allow_start_without_invite_rights !== (payload.task.allowStartWithoutInviteRights ? 1 : 0)) {
changes.allowStartWithoutInviteRights = [Boolean(existing.allow_start_without_invite_rights), Boolean(payload.task.allowStartWithoutInviteRights)]; changes.allowStartWithoutInviteRights = [Boolean(existing.allow_start_without_invite_rights), Boolean(payload.task.allowStartWithoutInviteRights)];
} }
@ -805,10 +808,13 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
startedAt: log.startedAt, startedAt: log.startedAt,
finishedAt: log.finishedAt, finishedAt: log.finishedAt,
invitedCount: log.invitedCount, invitedCount: log.invitedCount,
cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "",
queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "",
batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "",
successIds: JSON.stringify(log.successIds || []), successIds: JSON.stringify(log.successIds || []),
errors: JSON.stringify(log.errors || []) errors: JSON.stringify(log.errors || [])
})); }));
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "successIds", "errors"]); const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors"]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });

View File

@ -73,7 +73,8 @@ function initStore(userDataPath) {
finished_at TEXT NOT NULL, finished_at TEXT NOT NULL,
invited_count INTEGER NOT NULL, invited_count INTEGER NOT NULL,
success_ids TEXT NOT NULL, success_ids TEXT NOT NULL,
error_summary TEXT NOT NULL error_summary TEXT NOT NULL,
meta TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS account_events ( CREATE TABLE IF NOT EXISTS account_events (
@ -140,6 +141,7 @@ function initStore(userDataPath) {
max_interval_minutes INTEGER NOT NULL, max_interval_minutes INTEGER NOT NULL,
daily_limit INTEGER NOT NULL, daily_limit INTEGER NOT NULL,
history_limit INTEGER NOT NULL, history_limit INTEGER NOT NULL,
max_invites_per_cycle INTEGER NOT NULL DEFAULT 20,
max_competitor_bots INTEGER NOT NULL, max_competitor_bots INTEGER NOT NULL,
max_our_bots INTEGER NOT NULL, max_our_bots INTEGER NOT NULL,
random_accounts INTEGER NOT NULL DEFAULT 0, random_accounts INTEGER NOT NULL DEFAULT 0,
@ -208,6 +210,8 @@ function initStore(userDataPath) {
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''"); ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''"); ensureColumn("invites", "confirm_error", "TEXT NOT NULL DEFAULT ''");
ensureColumn("logs", "meta", "TEXT NOT NULL DEFAULT ''");
ensureColumn("tasks", "max_invites_per_cycle", "INTEGER NOT NULL DEFAULT 20");
ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''"); ensureColumn("fallback_queue", "username", "TEXT DEFAULT ''");
ensureColumn("fallback_queue", "source_chat", "TEXT DEFAULT ''"); ensureColumn("fallback_queue", "source_chat", "TEXT DEFAULT ''");
ensureColumn("fallback_queue", "target_chat", "TEXT DEFAULT ''"); ensureColumn("fallback_queue", "target_chat", "TEXT DEFAULT ''");
@ -566,7 +570,7 @@ function initStore(userDataPath) {
db.prepare(` db.prepare(`
UPDATE tasks UPDATE tasks
SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?, SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?,
history_limit = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?, history_limit = ?, max_invites_per_cycle = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?,
retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?, retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?,
require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?,
allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?, allow_start_without_invite_rights = ?, parse_participants = ?, invite_via_admins = ?, invite_admin_master_id = ?,
@ -580,6 +584,7 @@ function initStore(userDataPath) {
task.maxIntervalMinutes, task.maxIntervalMinutes,
task.dailyLimit, task.dailyLimit,
task.historyLimit, task.historyLimit,
task.maxInvitesPerCycle || 20,
task.maxCompetitorBots, task.maxCompetitorBots,
task.maxOurBots, task.maxOurBots,
task.randomAccounts ? 1 : 0, task.randomAccounts ? 1 : 0,
@ -612,12 +617,12 @@ function initStore(userDataPath) {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit, INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit,
max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors, max_invites_per_cycle, max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors,
auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled, auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled,
allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id, allow_start_without_invite_rights, parse_participants, invite_via_admins, invite_admin_master_id,
invite_admin_allow_flood, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors, invite_admin_allow_flood, warmup_enabled, warmup_start_limit, warmup_daily_increase, cycle_competitors,
competitor_cursor, invite_link_on_fail, created_at, updated_at) competitor_cursor, invite_link_on_fail, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
task.name, task.name,
task.ourGroup, task.ourGroup,
@ -625,6 +630,7 @@ function initStore(userDataPath) {
task.maxIntervalMinutes, task.maxIntervalMinutes,
task.dailyLimit, task.dailyLimit,
task.historyLimit, task.historyLimit,
task.maxInvitesPerCycle || 20,
task.maxCompetitorBots, task.maxCompetitorBots,
task.maxOurBots, task.maxOurBots,
task.randomAccounts ? 1 : 0, task.randomAccounts ? 1 : 0,
@ -894,15 +900,16 @@ function initStore(userDataPath) {
function addLog(entry) { function addLog(entry) {
db.prepare(` db.prepare(`
INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary) INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary, meta)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
entry.taskId || 0, entry.taskId || 0,
entry.startedAt, entry.startedAt,
entry.finishedAt, entry.finishedAt,
entry.invitedCount, entry.invitedCount,
JSON.stringify(entry.successIds || []), JSON.stringify(entry.successIds || []),
JSON.stringify(entry.errors || []) JSON.stringify(entry.errors || []),
JSON.stringify(entry.meta || {})
); );
} }
@ -920,7 +927,8 @@ function initStore(userDataPath) {
finishedAt: row.finished_at, finishedAt: row.finished_at,
invitedCount: row.invited_count, invitedCount: row.invited_count,
successIds: JSON.parse(row.success_ids || "[]"), successIds: JSON.parse(row.success_ids || "[]"),
errors: JSON.parse(row.error_summary || "[]") errors: JSON.parse(row.error_summary || "[]"),
meta: JSON.parse(row.meta || "{}")
})); }));
} }

View File

@ -86,6 +86,8 @@ class TaskRunner {
const accountMap = new Map( const accountMap = new Map(
this.store.listAccounts().map((account) => [account.id, account]) this.store.listAccounts().map((account) => [account.id, account])
); );
const perCycleLimit = Math.max(1, Number(this.task.max_invites_per_cycle || this.task.maxInvitesPerCycle || 20));
this.cycleMeta = { cycleLimit: perCycleLimit, queueCount: 0, batchSize: 0 };
try { try {
const settings = this.store.getSettings(); const settings = this.store.getSettings();
@ -143,8 +145,22 @@ class TaskRunner {
errors.push("Daily limit reached"); errors.push("Daily limit reached");
} else { } else {
const remaining = dailyLimit - alreadyInvited; const remaining = dailyLimit - alreadyInvited;
const batchSize = Math.min(20, remaining); const perCycle = perCycleLimit;
const batchSize = Math.min(perCycle, remaining);
const queueCount = this.store.getPendingCount(this.task.id);
const pending = this.store.getPendingInvites(this.task.id, batchSize); const pending = this.store.getPendingInvites(this.task.id, batchSize);
this.cycleMeta = { cycleLimit: perCycle, queueCount, batchSize };
if (inviteAccounts.length) {
const eventAccountId = inviteAccounts[0] || 0;
const eventAccount = accountMap.get(eventAccountId);
const phone = eventAccount ? eventAccount.phone : "";
this.store.addAccountEvent(
eventAccountId,
phone,
"cycle_batch",
`лимит цикла: ${perCycle}, очередь: ${queueCount}, взято: ${pending.length}`
);
}
const accountMap = new Map(this.store.listAccounts().map((account) => [account.id, account])); const accountMap = new Map(this.store.listAccounts().map((account) => [account.id, account]));
if (!inviteAccounts.length && pending.length) { if (!inviteAccounts.length && pending.length) {
errors.push("No available accounts under limits"); errors.push("No available accounts under limits");
@ -347,7 +363,8 @@ class TaskRunner {
finishedAt, finishedAt,
invitedCount, invitedCount,
successIds, successIds,
errors errors,
meta: { cycleLimit: perCycleLimit, ...(this.cycleMeta || {}) }
}); });
this._scheduleNext(); this._scheduleNext();

View File

@ -26,6 +26,7 @@ const emptySettings = {
maxIntervalMinutes: 10, maxIntervalMinutes: 10,
dailyLimit: 100, dailyLimit: 100,
historyLimit: 100, historyLimit: 100,
maxInvitesPerCycle: 20,
maxCompetitorBots: 1, maxCompetitorBots: 1,
maxOurBots: 1, maxOurBots: 1,
randomAccounts: false, randomAccounts: false,
@ -61,6 +62,7 @@ const emptySettings = {
maxIntervalMinutes: Number(row.max_interval_minutes || 10), maxIntervalMinutes: Number(row.max_interval_minutes || 10),
dailyLimit: Number(row.daily_limit || 100), dailyLimit: Number(row.daily_limit || 100),
historyLimit: Number(row.history_limit || 200), historyLimit: Number(row.history_limit || 200),
maxInvitesPerCycle: Number(row.max_invites_per_cycle || 20),
maxCompetitorBots: Number(row.max_competitor_bots || 1), maxCompetitorBots: Number(row.max_competitor_bots || 1),
maxOurBots: Number(row.max_our_bots || 1), maxOurBots: Number(row.max_our_bots || 1),
randomAccounts: Boolean(row.random_accounts), randomAccounts: Boolean(row.random_accounts),
@ -179,6 +181,7 @@ export default function App() {
const [taskFilter, setTaskFilter] = useState("all"); const [taskFilter, setTaskFilter] = useState("all");
const [notificationFilter, setNotificationFilter] = useState("all"); const [notificationFilter, setNotificationFilter] = useState("all");
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
const [infoTab, setInfoTab] = useState("usage");
const [activeTab, setActiveTab] = useState("task"); const [activeTab, setActiveTab] = useState("task");
const [logsTab, setLogsTab] = useState("logs"); const [logsTab, setLogsTab] = useState("logs");
const [logSearch, setLogSearch] = useState(""); const [logSearch, setLogSearch] = useState("");
@ -233,6 +236,31 @@ export default function App() {
const username = account.username ? `@${account.username}` : ""; const username = account.username ? `@${account.username}` : "";
return username ? `${base} (${username})` : base; return username ? `${base} (${username})` : base;
}; };
const copyToClipboard = async (text) => {
if (!text) return false;
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (error) {
// ignore and fallback
}
try {
const el = document.createElement("textarea");
el.value = text;
el.setAttribute("readonly", "");
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
el.select();
const ok = document.execCommand("copy");
document.body.removeChild(el);
return ok;
} catch (error) {
return false;
}
};
const accountStatsMap = useMemo(() => { const accountStatsMap = useMemo(() => {
const map = new Map(); const map = new Map();
(accountStats || []).forEach((item) => { (accountStats || []).forEach((item) => {
@ -1905,27 +1933,86 @@ export default function App() {
<h2>Как пользоваться</h2> <h2>Как пользоваться</h2>
<button className="ghost" type="button" onClick={() => setInfoOpen(false)}>Закрыть</button> <button className="ghost" type="button" onClick={() => setInfoOpen(false)}>Закрыть</button>
</div> </div>
<div className="info-tabs">
<button
type="button"
className={`tab ${infoTab === "usage" ? "active" : ""}`}
onClick={() => setInfoTab("usage")}
>
Быстрый старт
</button>
<button
type="button"
className={`tab ${infoTab === "features" ? "active" : ""}`}
onClick={() => setInfoTab("features")}
>
Функции
</button>
<button
type="button"
className={`tab ${infoTab === "strategies" ? "active" : ""}`}
onClick={() => setInfoTab("strategies")}
>
Стратегии
</button>
<button
type="button"
className={`tab ${infoTab === "limits" ? "active" : ""}`}
onClick={() => setInfoTab("limits")}
>
Ограничения Telegram
</button>
</div>
{infoTab === "usage" && (
<>
<ol className="help-list"> <ol className="help-list">
<li>Создайте задачу: название, наша группа и группы конкурентов.</li> <li>Создайте задачу: название, наша группа и группы конкурентов.</li>
<li>Выберите аккаунты для задачи и сохраните.</li> <li>Импортируйте аккаунты (tdata) и назначьте роли для задачи.</li>
<li>Нажмите Собрать историю, чтобы добавить авторов из последних сообщений.</li> <li>Нажмите Собрать историю, чтобы добавить авторов из последних сообщений.</li>
<li>Нажмите Запустить, чтобы отслеживать новые сообщения и приглашать по расписанию.</li> <li>Нажмите Запустить, чтобы отслеживать новые сообщения и приглашать по расписанию.</li>
<li>Создавайте несколько задач для разных групп и контролируйте их по списку.</li> <li>Следите за статусом, логами, событиями и очередью.</li>
</ol> </ol>
<p className="help-note"> <p className="help-note">
Собрать историю добавляет в очередь пользователей, которые писали ранее. Без этого будут учитываться только новые сообщения. Собрать историю добавляет в очередь авторов старых сообщений. Без этого учитываются только новые сообщения.
</p> </p>
</>
)}
{infoTab === "features" && (
<div className="help-note"> <div className="help-note">
<strong>Важные ограничения и решения:</strong> <strong>Функции и режимы:</strong>
<div>1) AUTH_KEY_DUPLICATED: выйти из аккаунта на других устройствах и пересоздать tdata.</div> <div>1) Мониторинг: отслеживает новые сообщения в чатах конкурентов и добавляет авторов в очередь.</div>
<div>2) CHAT_ADMIN_REQUIRED: приглашающий аккаунт должен быть админом с правом добавлять участников.</div> <div>2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.</div>
<div>3) USER_ID_INVALID: скрытые участники, анонимы, каналы инвайт возможен только по username.</div> <div>3) Инвайт через админов: временно выдает право Приглашать, затем снимает.</div>
<div>4) USER_NOT_MUTUAL_CONTACT: часто это спамзащита или повторное добавление после выхода; помогает взаимный контакт или инвайтссылка.</div> <div>4) Инвайт в чаты с флудом: распределяет инвайт через цепочку выдачи прав.</div>
<div>5) Инвайт через админов: можно обходить часть лимитов, временно выдавая права Приглашать.</div> <div>5) Циклический обход конкурентов: переключает мониторинг по списку групп.</div>
<div>6) Инвайт в чат с флудом: используется временная выдача прав между аккаунтами.</div> <div>6) Парсинг участников: пытается получить список участников для закрытых чатов.</div>
<div>7) Нет доступа к конкурентам: используйте валидные inviteссылки или публичные группы.</div> <div>7) Прогрев лимита: плавно увеличивает дневной лимит по дням.</div>
<div>8) FLOOD/PEER_FLOOD: снижайте лимиты, увеличивайте интервалы, распределяйте нагрузку.</div> <div>8) Fallbackлист: собирает проблемные инвайты и предлагает маршруты.</div>
</div> </div>
)}
{infoTab === "strategies" && (
<div className="help-note">
<strong>Стратегии инвайта:</strong>
<div>1) access_hash из сообщения.</div>
<div>2) Резолв через участников/источник.</div>
<div>3) Инвайт по username (если доступен).</div>
<div>4) Инвайт через админов (если включен).</div>
<div>5) Отправка инвайтссылки (если включено).</div>
<div>После успешного инвайта выполняется проверка фактического вступления.</div>
</div>
)}
{infoTab === "limits" && (
<div className="help-note">
<strong>Особенности Telegram и ошибки:</strong>
<div>1) AUTH_KEY_DUPLICATED: tdata уже используется выйдите из аккаунта на других устройствах и пересоберите tdata.</div>
<div>2) CHAT_ADMIN_REQUIRED: аккаунт должен быть админом с правом добавлять участников.</div>
<div>3) USER_ID_INVALID: скрытые/анонимные авторы, удаленные аккаунты инвайт возможен только по username.</div>
<div>4) USER_NOT_MUTUAL_CONTACT: ограничения Telegram/приватность пользователя помогает инвайтссылка или другой аккаунт.</div>
<div>5) USER_PRIVACY_RESTRICTED: пользователь запретил инвайты в чаты.</div>
<div>6) FLOOD/PEER_FLOOD: снизить лимиты, увеличить интервалы, распределить нагрузку.</div>
<div>7) CHANNEL_PRIVATE/INVITE_HASH_INVALID: ссылка недействительна или чат приватный.</div>
</div>
)}
</div> </div>
</div> </div>
)} )}
@ -2390,6 +2477,7 @@ export default function App() {
<div className="row"> <div className="row">
<label> <label>
<span className="label-line">Главный аккаунт</span> <span className="label-line">Главный аккаунт</span>
<div className="input-row">
<select <select
value={taskForm.inviteAdminMasterId || ""} value={taskForm.inviteAdminMasterId || ""}
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })} onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
@ -2402,6 +2490,24 @@ export default function App() {
</option> </option>
))} ))}
</select> </select>
<button
type="button"
className="secondary"
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
onClick={async () => {
const account = accountById.get(taskForm.inviteAdminMasterId);
const username = account && account.username ? `@${account.username}` : "";
if (!username) {
showNotification("У выбранного аккаунта нет username.", "error");
return;
}
const ok = await copyToClipboard(username);
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
}}
>
Копировать username
</button>
</div>
<span className="hint"> <span className="hint">
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим. Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
</span> </span>
@ -2481,6 +2587,22 @@ export default function App() {
}} }}
/> />
</label> </label>
<label>
<span className="label-line">Инвайтов за цикл <span className="required">*</span></span>
<input
type="number"
min="1"
value={taskForm.maxInvitesPerCycle === "" ? "" : taskForm.maxInvitesPerCycle}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxInvitesPerCycle: value === "" ? "" : Number(value) });
}}
onBlur={() => {
const value = Number(taskForm.maxInvitesPerCycle);
setTaskForm({ ...taskForm, maxInvitesPerCycle: Number.isFinite(value) && value > 0 ? value : 1 });
}}
/>
</label>
<label className="checkbox"> <label className="checkbox">
<input <input
type="checkbox" type="checkbox"

View File

@ -0,0 +1,40 @@
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error) {
return { hasError: true, message: error?.message || "Неизвестная ошибка." };
}
componentDidCatch(error, info) {
// eslint-disable-next-line no-console
console.error("UI error boundary:", error, info);
}
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<div className="error-boundary-card">
<h2>Интерфейс временно недоступен</h2>
<p>{this.state.message}</p>
<button className="primary" onClick={this.handleReload}>
Перезагрузить
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -1,7 +1,12 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles/app.css"; import "./styles/app.css";
const root = createRoot(document.getElementById("root")); const root = createRoot(document.getElementById("root"));
root.render(<App />); root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);

View File

@ -94,6 +94,27 @@ body {
color: #475569; color: #475569;
} }
.info-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.info-tabs .tab {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #d1d7e0;
background: #f8fafc;
font-size: 12px;
}
.info-tabs .tab.active {
background: #1d4ed8;
border-color: #1d4ed8;
color: #ffffff;
}
.notifications .notification-list { .notifications .notification-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -612,6 +633,13 @@ input {
font-size: 13px; font-size: 13px;
} }
.input-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
}
textarea { textarea {
padding: 8px 10px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
@ -1353,6 +1381,34 @@ button:disabled {
border-left: 4px solid #f59e0b; border-left: 4px solid #f59e0b;
} }
.error-boundary {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #f8fafc;
}
.error-boundary-card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
border: 1px solid #e2e8f0;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
max-width: 420px;
text-align: center;
}
.error-boundary-card h2 {
margin: 0 0 8px;
}
.error-boundary-card p {
margin: 0 0 16px;
color: #475569;
font-size: 14px;
}
.account-details { .account-details {
margin-top: 8px; margin-top: 8px;
} }

View File

@ -224,6 +224,10 @@ function AccountsTab({
<div className="account-list"> <div className="account-list">
{accountBuckets.busy.map((account) => { {accountBuckets.busy.map((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || []; const assignedTasks = assignedAccountMap.get(account.id) || [];
const roles = {
monitor: assignedTasks.some((item) => item.roleMonitor),
invite: assignedTasks.some((item) => item.roleInvite)
};
const taskNames = assignedTasks const taskNames = assignedTasks
.map((item) => { .map((item) => {
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`; const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;

View File

@ -223,6 +223,11 @@ function LogsTab({
</div> </div>
<div className="log-details"> <div className="log-details">
<div>Добавлено: {log.invitedCount}</div> <div>Добавлено: {log.invitedCount}</div>
{log.meta && (log.meta.batchSize || log.meta.cycleLimit || log.meta.queueCount) && (
<div className="log-users">
Цикл: лимит {log.meta.cycleLimit ?? "—"}, очередь {log.meta.queueCount ?? "—"}, взято {log.meta.batchSize ?? "—"}
</div>
)}
<div className="log-users wrap"> <div className="log-users wrap">
Пользователи: {successIds.length ? successIds.join(", ") : "—"} Пользователи: {successIds.length ? successIds.join(", ") : "—"}
</div> </div>