some
This commit is contained in:
parent
c1c32c7955
commit
58e89e83c1
@ -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.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)) {
|
||||
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,
|
||||
finishedAt: log.finishedAt,
|
||||
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 || []),
|
||||
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");
|
||||
return { ok: true, filePath };
|
||||
});
|
||||
|
||||
@ -73,7 +73,8 @@ function initStore(userDataPath) {
|
||||
finished_at TEXT NOT NULL,
|
||||
invited_count INTEGER 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 (
|
||||
@ -140,6 +141,7 @@ function initStore(userDataPath) {
|
||||
max_interval_minutes INTEGER NOT NULL,
|
||||
daily_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_our_bots INTEGER NOT NULL,
|
||||
random_accounts INTEGER NOT NULL DEFAULT 0,
|
||||
@ -208,6 +210,8 @@ function initStore(userDataPath) {
|
||||
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "confirmed", "INTEGER NOT NULL DEFAULT 1");
|
||||
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", "source_chat", "TEXT DEFAULT ''");
|
||||
ensureColumn("fallback_queue", "target_chat", "TEXT DEFAULT ''");
|
||||
@ -566,7 +570,7 @@ function initStore(userDataPath) {
|
||||
db.prepare(`
|
||||
UPDATE tasks
|
||||
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 = ?,
|
||||
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 = ?,
|
||||
@ -580,6 +584,7 @@ function initStore(userDataPath) {
|
||||
task.maxIntervalMinutes,
|
||||
task.dailyLimit,
|
||||
task.historyLimit,
|
||||
task.maxInvitesPerCycle || 20,
|
||||
task.maxCompetitorBots,
|
||||
task.maxOurBots,
|
||||
task.randomAccounts ? 1 : 0,
|
||||
@ -612,12 +617,12 @@ function initStore(userDataPath) {
|
||||
|
||||
const result = db.prepare(`
|
||||
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,
|
||||
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,
|
||||
competitor_cursor, invite_link_on_fail, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
task.name,
|
||||
task.ourGroup,
|
||||
@ -625,6 +630,7 @@ function initStore(userDataPath) {
|
||||
task.maxIntervalMinutes,
|
||||
task.dailyLimit,
|
||||
task.historyLimit,
|
||||
task.maxInvitesPerCycle || 20,
|
||||
task.maxCompetitorBots,
|
||||
task.maxOurBots,
|
||||
task.randomAccounts ? 1 : 0,
|
||||
@ -894,15 +900,16 @@ function initStore(userDataPath) {
|
||||
|
||||
function addLog(entry) {
|
||||
db.prepare(`
|
||||
INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary, meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entry.taskId || 0,
|
||||
entry.startedAt,
|
||||
entry.finishedAt,
|
||||
entry.invitedCount,
|
||||
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,
|
||||
invitedCount: row.invited_count,
|
||||
successIds: JSON.parse(row.success_ids || "[]"),
|
||||
errors: JSON.parse(row.error_summary || "[]")
|
||||
errors: JSON.parse(row.error_summary || "[]"),
|
||||
meta: JSON.parse(row.meta || "{}")
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -86,6 +86,8 @@ class TaskRunner {
|
||||
const accountMap = new Map(
|
||||
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 {
|
||||
const settings = this.store.getSettings();
|
||||
@ -143,8 +145,22 @@ class TaskRunner {
|
||||
errors.push("Daily limit reached");
|
||||
} else {
|
||||
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);
|
||||
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]));
|
||||
if (!inviteAccounts.length && pending.length) {
|
||||
errors.push("No available accounts under limits");
|
||||
@ -347,7 +363,8 @@ class TaskRunner {
|
||||
finishedAt,
|
||||
invitedCount,
|
||||
successIds,
|
||||
errors
|
||||
errors,
|
||||
meta: { cycleLimit: perCycleLimit, ...(this.cycleMeta || {}) }
|
||||
});
|
||||
|
||||
this._scheduleNext();
|
||||
|
||||
@ -26,6 +26,7 @@ const emptySettings = {
|
||||
maxIntervalMinutes: 10,
|
||||
dailyLimit: 100,
|
||||
historyLimit: 100,
|
||||
maxInvitesPerCycle: 20,
|
||||
maxCompetitorBots: 1,
|
||||
maxOurBots: 1,
|
||||
randomAccounts: false,
|
||||
@ -61,6 +62,7 @@ const emptySettings = {
|
||||
maxIntervalMinutes: Number(row.max_interval_minutes || 10),
|
||||
dailyLimit: Number(row.daily_limit || 100),
|
||||
historyLimit: Number(row.history_limit || 200),
|
||||
maxInvitesPerCycle: Number(row.max_invites_per_cycle || 20),
|
||||
maxCompetitorBots: Number(row.max_competitor_bots || 1),
|
||||
maxOurBots: Number(row.max_our_bots || 1),
|
||||
randomAccounts: Boolean(row.random_accounts),
|
||||
@ -179,6 +181,7 @@ export default function App() {
|
||||
const [taskFilter, setTaskFilter] = useState("all");
|
||||
const [notificationFilter, setNotificationFilter] = useState("all");
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [infoTab, setInfoTab] = useState("usage");
|
||||
const [activeTab, setActiveTab] = useState("task");
|
||||
const [logsTab, setLogsTab] = useState("logs");
|
||||
const [logSearch, setLogSearch] = useState("");
|
||||
@ -233,6 +236,31 @@ export default function App() {
|
||||
const username = account.username ? `@${account.username}` : "";
|
||||
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 map = new Map();
|
||||
(accountStats || []).forEach((item) => {
|
||||
@ -1905,27 +1933,86 @@ export default function App() {
|
||||
<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 className="help-note">
|
||||
<strong>Важные ограничения и решения:</strong>
|
||||
<div>1) AUTH_KEY_DUPLICATED: выйти из аккаунта на других устройствах и пересоздать tdata.</div>
|
||||
<div>2) CHAT_ADMIN_REQUIRED: приглашающий аккаунт должен быть админом с правом “добавлять участников”.</div>
|
||||
<div>3) USER_ID_INVALID: скрытые участники, анонимы, каналы — инвайт возможен только по username.</div>
|
||||
<div>4) USER_NOT_MUTUAL_CONTACT: часто это спам‑защита или повторное добавление после выхода; помогает взаимный контакт или инвайт‑ссылка.</div>
|
||||
<div>5) Инвайт через админов: можно обходить часть лимитов, временно выдавая права “Приглашать”.</div>
|
||||
<div>6) Инвайт в чат с флудом: используется временная выдача прав между аккаунтами.</div>
|
||||
<div>7) Нет доступа к конкурентам: используйте валидные invite‑ссылки или публичные группы.</div>
|
||||
<div>8) FLOOD/PEER_FLOOD: снижайте лимиты, увеличивайте интервалы, распределяйте нагрузку.</div>
|
||||
<div className="info-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "usage" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("usage")}
|
||||
>
|
||||
Быстрый старт
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "features" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("features")}
|
||||
>
|
||||
Функции
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "strategies" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("strategies")}
|
||||
>
|
||||
Стратегии
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "limits" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("limits")}
|
||||
>
|
||||
Ограничения Telegram
|
||||
</button>
|
||||
</div>
|
||||
{infoTab === "usage" && (
|
||||
<>
|
||||
<ol className="help-list">
|
||||
<li>Создайте задачу: название, наша группа и группы конкурентов.</li>
|
||||
<li>Импортируйте аккаунты (tdata) и назначьте роли для задачи.</li>
|
||||
<li>Нажмите “Собрать историю”, чтобы добавить авторов из последних сообщений.</li>
|
||||
<li>Нажмите “Запустить”, чтобы отслеживать новые сообщения и приглашать по расписанию.</li>
|
||||
<li>Следите за статусом, логами, событиями и очередью.</li>
|
||||
</ol>
|
||||
<p className="help-note">
|
||||
“Собрать историю” добавляет в очередь авторов старых сообщений. Без этого учитываются только новые сообщения.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{infoTab === "features" && (
|
||||
<div className="help-note">
|
||||
<strong>Функции и режимы:</strong>
|
||||
<div>1) Мониторинг: отслеживает новые сообщения в чатах конкурентов и добавляет авторов в очередь.</div>
|
||||
<div>2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.</div>
|
||||
<div>3) Инвайт через админов: временно выдает право “Приглашать”, затем снимает.</div>
|
||||
<div>4) Инвайт в чаты с флудом: распределяет инвайт через цепочку выдачи прав.</div>
|
||||
<div>5) Циклический обход конкурентов: переключает мониторинг по списку групп.</div>
|
||||
<div>6) Парсинг участников: пытается получить список участников для закрытых чатов.</div>
|
||||
<div>7) Прогрев лимита: плавно увеличивает дневной лимит по дням.</div>
|
||||
<div>8) Fallback‑лист: собирает проблемные инвайты и предлагает маршруты.</div>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "strategies" && (
|
||||
<div className="help-note">
|
||||
<strong>Стратегии инвайта:</strong>
|
||||
<div>1) access_hash из сообщения.</div>
|
||||
<div>2) Резолв через участников/источник.</div>
|
||||
<div>3) Инвайт по username (если доступен).</div>
|
||||
<div>4) Инвайт через админов (если включен).</div>
|
||||
<div>5) Отправка инвайт‑ссылки (если включено).</div>
|
||||
<div>После успешного инвайта выполняется проверка фактического вступления.</div>
|
||||
</div>
|
||||
)}
|
||||
{infoTab === "limits" && (
|
||||
<div className="help-note">
|
||||
<strong>Особенности Telegram и ошибки:</strong>
|
||||
<div>1) AUTH_KEY_DUPLICATED: tdata уже используется — выйдите из аккаунта на других устройствах и пересоберите tdata.</div>
|
||||
<div>2) CHAT_ADMIN_REQUIRED: аккаунт должен быть админом с правом “добавлять участников”.</div>
|
||||
<div>3) USER_ID_INVALID: скрытые/анонимные авторы, удаленные аккаунты — инвайт возможен только по username.</div>
|
||||
<div>4) USER_NOT_MUTUAL_CONTACT: ограничения Telegram/приватность пользователя — помогает инвайт‑ссылка или другой аккаунт.</div>
|
||||
<div>5) USER_PRIVACY_RESTRICTED: пользователь запретил инвайты в чаты.</div>
|
||||
<div>6) FLOOD/PEER_FLOOD: снизить лимиты, увеличить интервалы, распределить нагрузку.</div>
|
||||
<div>7) CHANNEL_PRIVATE/INVITE_HASH_INVALID: ссылка недействительна или чат приватный.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -2390,18 +2477,37 @@ export default function App() {
|
||||
<div className="row">
|
||||
<label>
|
||||
<span className="label-line">Главный аккаунт</span>
|
||||
<select
|
||||
value={taskForm.inviteAdminMasterId || ""}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
|
||||
disabled={!taskForm.inviteViaAdmins}
|
||||
>
|
||||
<option value="">Не выбран</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={`master-${account.id}`} value={account.id}>
|
||||
{formatAccountLabel(account)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="input-row">
|
||||
<select
|
||||
value={taskForm.inviteAdminMasterId || ""}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
|
||||
disabled={!taskForm.inviteViaAdmins}
|
||||
>
|
||||
<option value="">Не выбран</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={`master-${account.id}`} value={account.id}>
|
||||
{formatAccountLabel(account)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
|
||||
onClick={async () => {
|
||||
const account = accountById.get(taskForm.inviteAdminMasterId);
|
||||
const username = account && account.username ? `@${account.username}` : "";
|
||||
if (!username) {
|
||||
showNotification("У выбранного аккаунта нет username.", "error");
|
||||
return;
|
||||
}
|
||||
const ok = await copyToClipboard(username);
|
||||
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
|
||||
}}
|
||||
>
|
||||
Копировать username
|
||||
</button>
|
||||
</div>
|
||||
<span className="hint">
|
||||
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
|
||||
</span>
|
||||
@ -2481,6 +2587,22 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
40
src/renderer/components/ErrorBoundary.jsx
Normal file
40
src/renderer/components/ErrorBoundary.jsx
Normal 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;
|
||||
@ -1,7 +1,12 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import "./styles/app.css";
|
||||
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
root.render(<App />);
|
||||
root.render(
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@ -94,6 +94,27 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -612,6 +633,13 @@ input {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
@ -1353,6 +1381,34 @@ button:disabled {
|
||||
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 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@ -224,6 +224,10 @@ function AccountsTab({
|
||||
<div className="account-list">
|
||||
{accountBuckets.busy.map((account) => {
|
||||
const assignedTasks = assignedAccountMap.get(account.id) || [];
|
||||
const roles = {
|
||||
monitor: assignedTasks.some((item) => item.roleMonitor),
|
||||
invite: assignedTasks.some((item) => item.roleInvite)
|
||||
};
|
||||
const taskNames = assignedTasks
|
||||
.map((item) => {
|
||||
const name = accountBuckets.taskNameMap.get(item.taskId) || `Задача #${item.taskId}`;
|
||||
|
||||
@ -223,6 +223,11 @@ function LogsTab({
|
||||
</div>
|
||||
<div className="log-details">
|
||||
<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">
|
||||
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user