This commit is contained in:
Ivan Neplokhov 2026-02-02 23:51:24 +04:00
parent 3728863b63
commit 27bac2ef1b
21 changed files with 607 additions and 149 deletions

View File

@ -445,6 +445,12 @@ ipcMain.handle("queue:clear", (_event, taskId) => {
store.clearQueue(taskId); store.clearQueue(taskId);
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("queue:clearItems", (_event, payload) => {
const taskId = payload && payload.taskId != null ? Number(payload.taskId) : null;
const ids = payload && Array.isArray(payload.ids) ? payload.ids : [];
const removed = store.clearQueueItems(taskId, ids);
return { ok: true, removed };
});
ipcMain.handle("queue:list", (_event, payload) => { ipcMain.handle("queue:list", (_event, payload) => {
const taskId = payload && payload.taskId != null ? payload.taskId : 0; const taskId = payload && payload.taskId != null ? payload.taskId : 0;
const limit = payload && payload.limit != null ? payload.limit : 200; const limit = payload && payload.limit != null ? payload.limit : 200;
@ -467,8 +473,11 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
accounts.forEach((account) => accountMap.set(account.id, account)); accounts.forEach((account) => accountMap.set(account.id, account));
let accountsForInvite = accountRows.map((row) => row.account_id); let accountsForInvite = accountRows.map((row) => row.account_id);
if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) { if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) {
const watcherCanInvite = accountRows.some((row) => Number(row.account_id) === Number(item.watcher_account_id));
if (watcherCanInvite) {
accountsForInvite = [item.watcher_account_id]; accountsForInvite = [item.watcher_account_id];
} }
}
const watcherAccount = accountMap.get(item.watcher_account_id || 0); const watcherAccount = accountMap.get(item.watcher_account_id || 0);
store.addAccountEvent( store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0, watcherAccount ? watcherAccount.id : 0,

View File

@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld("api", {
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId), exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId), exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId),
clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId), clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId),
clearQueueItems: (payload) => ipcRenderer.invoke("queue:clearItems", payload),
listQueue: (payload) => ipcRenderer.invoke("queue:list", payload), listQueue: (payload) => ipcRenderer.invoke("queue:list", payload),
testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload), testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload),
startTask: () => ipcRenderer.invoke("task:start"), startTask: () => ipcRenderer.invoke("task:start"),

View File

@ -599,6 +599,22 @@ function initStore(userDataPath) {
db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0); db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0);
} }
function clearQueueItems(taskId, ids) {
const list = Array.isArray(ids)
? ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: [];
if (!list.length) return 0;
const placeholders = list.map(() => "?").join(",");
if (taskId == null) {
const result = db.prepare(`DELETE FROM invite_queue WHERE id IN (${placeholders})`).run(...list);
return result.changes || 0;
}
const result = db
.prepare(`DELETE FROM invite_queue WHERE task_id = ? AND id IN (${placeholders})`)
.run(taskId || 0, ...list);
return result.changes || 0;
}
function clearQueueOlderThan(taskId, hours) { function clearQueueOlderThan(taskId, hours) {
const limit = Number(hours || 0); const limit = Number(hours || 0);
if (!Number.isFinite(limit) || limit <= 0) return 0; if (!Number.isFinite(limit) || limit <= 0) return 0;
@ -1237,6 +1253,7 @@ function initStore(userDataPath) {
getPendingCount, getPendingCount,
getPendingStats, getPendingStats,
clearQueue, clearQueue,
clearQueueItems,
clearQueueOlderThan, clearQueueOlderThan,
markInviteStatus, markInviteStatus,
incrementInviteAttempt, incrementInviteAttempt,

View File

@ -275,7 +275,18 @@ class TaskRunner {
} }
} }
if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) { if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) {
const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id));
if (watcherCanInvite) {
accountsForInvite = [item.watcher_account_id]; accountsForInvite = [item.watcher_account_id];
} else {
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
this.store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone || "" : "",
"invite_watcher_fallback_skipped",
`наблюдатель не имеет роли инвайта в задаче: ${item.watcher_account_id}`
);
}
} }
const watcherAccount = accountMap.get(item.watcher_account_id || 0); const watcherAccount = accountMap.get(item.watcher_account_id || 0);
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, { const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, {

View File

@ -665,6 +665,16 @@ export default function App() {
formatCountdown, formatCountdown,
now now
}); });
const refreshQueue = React.useCallback(async () => {
if (!window.api || selectedTaskId == null) return;
try {
const queueData = await window.api.listQueue({ limit: 200, taskId: selectedTaskId });
if (queueData && Array.isArray(queueData.items)) setQueueItems(queueData.items);
if (queueData && queueData.stats) setQueueStats(queueData.stats);
} catch (_error) {
// noop: queue refresh errors are non-blocking for UI actions
}
}, [selectedTaskId, setQueueItems, setQueueStats]);
useAppOrchestration({ useAppOrchestration({
activeTab, activeTab,
selectedTaskId, selectedTaskId,
@ -778,6 +788,8 @@ export default function App() {
setLogsTab setLogsTab
}); });
const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({ const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({
selectedTaskId,
refreshQueue,
selectedTaskName, selectedTaskName,
taskForm, taskForm,
setTaskForm, setTaskForm,

View File

@ -80,13 +80,13 @@ export default function AppOverlays({
/> />
<ConfirmModal <ConfirmModal
open={liveConfirmOpen} open={liveConfirmOpen}
title="Liveинвайт" title="Реальная проверка инвайта"
message={ message={
liveConfirmContext liveConfirmContext
? `Liveпрогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые).\ополнительно он делает один реальный инвайт из очереди для проверки фактической работы и ошибок Telegram.\n\ользователь: ${liveConfirmContext.userId || "—"}${liveConfirmContext.username ? ` (@${liveConfirmContext.username})` : ""}\сточник: ${liveConfirmContext.sourceChat || "—"}` ? `Этот прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые).\ополнительно он делает один реальный инвайт из очереди, чтобы проверить фактический результат и возможные ошибки Telegram.\n\ользователь: ${liveConfirmContext.userId || "—"}${liveConfirmContext.username ? ` (@${liveConfirmContext.username})` : ""}\сточник: ${liveConfirmContext.sourceChat || "—"}`
: "Liveпрогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые). Дополнительно он делает один реальный инвайт из очереди." : "Этот прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые). Дополнительно он делает один реальный инвайт из очереди."
} }
confirmLabel="Сделать инвайт" confirmLabel="Запустить реальную проверку"
cancelLabel="Отмена" cancelLabel="Отмена"
onConfirm={onConfirmLiveInvite} onConfirm={onConfirmLiveInvite}
onCancel={onCancelLiveInvite} onCancel={onCancelLiveInvite}

View File

@ -25,6 +25,13 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
> >
Функции Функции
</button> </button>
<button
type="button"
className={`tab ${infoTab === "terms" ? "active" : ""}`}
onClick={() => setInfoTab("terms")}
>
Термины
</button>
<button <button
type="button" type="button"
className={`tab ${infoTab === "strategies" ? "active" : ""}`} className={`tab ${infoTab === "strategies" ? "active" : ""}`}
@ -57,14 +64,24 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
{infoTab === "features" && ( {infoTab === "features" && (
<div className="help-note"> <div className="help-note">
<strong>Функции и режимы:</strong> <strong>Функции и режимы:</strong>
<div>1) Мониторинг: отслеживает новые сообщения в чатах конкурентов и добавляет авторов в очередь.</div> <div>1) Мониторинг: отслеживает новые сообщения в группах конкурентов и ставит авторов в очередь.</div>
<div>2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.</div> <div>2) Инвайт по расписанию: приглашает с интервалом и дневным лимитом.</div>
<div>3) Инвайт через админов: временно выдает право Приглашать, затем снимает.</div> <div>3) Инвайт через админов: временно выдает право Приглашать, затем снимает.</div>
<div>4) Инвайт в чаты с флудом: распределяет инвайт через цепочку выдачи прав.</div> <div>4) Прогрев лимита: плавно увеличивает нагрузку, чтобы снизить риск ограничений.</div>
<div>5) Циклический обход конкурентов: переключает мониторинг по списку групп.</div> <div>5) История/Очередь/События: показывает, что произошло и почему.</div>
<div>6) Парсинг участников: пытается получить список участников для закрытых чатов.</div> </div>
<div>7) Прогрев лимита: плавно увеличивает дневной лимит по дням.</div> )}
<div>8) Fallbackлист: собирает проблемные инвайты и предлагает маршруты.</div> {infoTab === "terms" && (
<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) Стратегия способ инвайта (username, access_hash, админ-путь и т.д.).</div>
<div>8) Не подтверждено инвайт был, но вступление пока не видно на проверке.</div>
</div> </div>
)} )}
{infoTab === "strategies" && ( {infoTab === "strategies" && (

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { getPresetLabel } from "../utils/presetLabels.js";
export default function TaskSettingsTab({ export default function TaskSettingsTab({
selectedTaskName, selectedTaskName,
@ -31,6 +32,128 @@ export default function TaskSettingsTab({
setFileImportForm, setFileImportForm,
criticalErrorAccounts criticalErrorAccounts
}) { }) {
const [customPresetName, setCustomPresetName] = React.useState("");
const [customPresets, setCustomPresets] = React.useState(() => {
try {
const raw = localStorage.getItem("tia_custom_presets_v1");
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
});
const changedSections = (nextForm) => {
const sections = [];
const hasAny = (keys) => keys.some((k) => JSON.stringify(taskForm[k]) !== JSON.stringify(nextForm[k]));
if (hasAny(["name", "ourGroup"])) sections.push("Основное");
if (hasAny(["maxCompetitorBots", "maxOurBots", "separateConfirmRoles", "maxConfirmBots", "rolesMode", "requireSameBotInBoth"])) sections.push("Роли ботов и вступление");
if (hasAny(["inviteViaAdmins", "inviteAdminMasterId", "inviteAdminAnonymous", "inviteAdminAllowFlood", "inviteLinkOnFail"])) sections.push("Инвайт через админов");
if (hasAny(["minIntervalMinutes", "maxIntervalMinutes", "dailyLimit", "maxInvitesPerCycle", "warmupEnabled", "historyLimit"])) sections.push("Интервалы и лимиты");
return sections;
};
const confirmApply = (nextForm, label, applyFn, sectionsOverride = null) => {
const sections = Array.isArray(sectionsOverride) ? sectionsOverride : changedSections(nextForm);
const msg = sections.length
? `Пресет "${label}" изменит текущие настройки.\n\удут изменены разделы:\n- ${sections.join("\n- ")}\n\рименить?`
: `Пресет "${label}" не меняет текущие настройки.\рименить?`;
if (!window.confirm(msg)) return;
applyFn();
};
const saveCustomPreset = () => {
const name = customPresetName.trim();
if (!name) {
showNotification("Введите имя пресета.", "error");
return;
}
const next = [...customPresets.filter((p) => p.name !== name), { name, form: taskForm }];
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
setCustomPresetName("");
showNotification(`Сохранен пользовательский пресет: ${name}`, "success");
};
const applyCustomPreset = (preset) => {
const nextForm = sanitizeTaskForm({ ...taskForm, ...preset.form });
confirmApply(nextForm, preset.name, () => {
setTaskForm(nextForm);
setActivePreset(`custom:${preset.name}`);
showNotification(`Применен пользовательский пресет: ${preset.name}`, "success");
});
};
const deleteCustomPreset = (name) => {
const next = customPresets.filter((preset) => preset.name !== name);
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
if (activePreset === `custom:${name}`) {
setActivePreset("");
}
showNotification(`Удален пользовательский пресет: ${name}`, "success");
};
const renameCustomPreset = (name) => {
const nextName = window.prompt("Новое имя пресета:", name);
if (!nextName) return;
const trimmed = nextName.trim();
if (!trimmed) return;
const preset = customPresets.find((p) => p.name === name);
if (!preset) return;
const next = [
...customPresets.filter((p) => p.name !== name && p.name !== trimmed),
{ ...preset, name: trimmed }
];
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
if (activePreset === `custom:${name}`) {
setActivePreset(`custom:${trimmed}`);
}
showNotification(`Переименован пресет: ${name}${trimmed}`, "success");
};
const exportCustomPresets = () => {
try {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
presets: customPresets
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "custom-presets.json";
link.click();
URL.revokeObjectURL(url);
showNotification("Пользовательские пресеты экспортированы.", "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
const importCustomPresets = async (event) => {
const file = event.target.files && event.target.files[0];
event.target.value = "";
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.presets) ? parsed.presets : [];
const valid = list
.filter((item) => item && typeof item.name === "string" && item.name.trim() && item.form && typeof item.form === "object")
.map((item) => ({ name: item.name.trim(), form: item.form }));
if (!valid.length) {
showNotification("Файл не содержит корректных пресетов.", "error");
return;
}
const replaceAll = window.confirm("Заменить все текущие пользовательские пресеты?\n\nОК — заменить полностью\nОтмена — объединить с текущими");
const map = new Map();
if (!replaceAll) {
customPresets.forEach((preset) => map.set(preset.name, preset));
}
valid.forEach((preset) => map.set(preset.name, preset));
const next = Array.from(map.values());
setCustomPresets(next);
localStorage.setItem("tia_custom_presets_v1", JSON.stringify(next));
showNotification(`Импортировано пресетов: ${valid.length}. Режим: ${replaceAll ? "замена" : "объединение"}.`, "success");
} catch (error) {
showNotification(error.message || String(error), "error");
}
};
return ( return (
<div className="task-columns"> <div className="task-columns">
<details className="card collapsible task-editor" open> <details className="card collapsible task-editor" open>
@ -43,57 +166,82 @@ export default function TaskSettingsTab({
<div className="section-title">Основное</div> <div className="section-title">Основное</div>
<div className="row-inline"> <div className="row-inline">
<div className="status-caption">Пресеты:</div> <div className="status-caption">Пресеты:</div>
{taskForm.rolesMode === "auto" ? (
<>
<button <button
className={`secondary ${activePreset === "admin" ? "active" : ""}`} className={`secondary ${activePreset === "admin" ? "active" : ""}`}
type="button" type="button"
onClick={() => applyTaskPreset("admin")} onClick={() => confirmApply(taskForm, "Автораспределение + Инвайт через админа", () => applyTaskPreset("admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
> >
Автораспределение + Инвайт через админа Автораспределение + Инвайт через админа
</button> </button>
<button <button
className={`secondary ${activePreset === "no_admin" ? "active" : ""}`} className={`secondary ${activePreset === "no_admin" ? "active" : ""}`}
type="button" type="button"
onClick={() => applyTaskPreset("no_admin")} onClick={() => confirmApply(taskForm, "Автораспределение + Без админки", () => applyTaskPreset("no_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
> >
Автораспределение + Без админки Автораспределение + Без админки
</button> </button>
<button <button
className={`secondary ${activePreset === "soft_50" ? "active" : ""}`} className={`secondary ${activePreset === "soft_50" ? "active" : ""}`}
type="button" type="button"
onClick={() => applyTaskPreset("soft_50")} onClick={() => confirmApply(taskForm, "Мягкий 50/день (5 инвайтеров)", () => applyTaskPreset("soft_50"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
> >
Мягкий 50/день (5 инвайтеров) Мягкий 50/день (5 инвайтеров)
</button> </button>
<button <button
className={`secondary ${activePreset === "soft_25" ? "active" : ""}`} className={`secondary ${activePreset === "soft_25" ? "active" : ""}`}
type="button" type="button"
onClick={() => applyTaskPreset("soft_25")} onClick={() => confirmApply(taskForm, "Мягкий 25/день (2 инвайтера)", () => applyTaskPreset("soft_25"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
> >
Мягкий 25/день (2 инвайтера) Мягкий 25/день (2 инвайтера)
</button> </button>
<button <button
className={`secondary ${activePreset === "soft_50_admin" ? "active" : ""}`} className={`secondary ${activePreset === "soft_50_admin" ? "active" : ""}`}
type="button" type="button"
onClick={() => applyTaskPreset("soft_50_admin")} onClick={() => confirmApply(taskForm, "Мягкий 50/день + админы", () => applyTaskPreset("soft_50_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
> >
Мягкий 50/день + админы Мягкий 50/день + админы
</button> </button>
<button <button
className={`secondary ${activePreset === "soft_25_admin" ? "active" : ""}`} className={`secondary ${activePreset === "soft_25_admin" ? "active" : ""}`}
type="button" type="button"
onClick={() => applyTaskPreset("soft_25_admin")} onClick={() => confirmApply(taskForm, "Мягкий 25/день + админы", () => applyTaskPreset("soft_25_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
> >
Мягкий 25/день + админы Мягкий 25/день + админы
</button> </button>
<span className="status-caption"> <span className="status-caption">
Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"} Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
</span> </span>
</> </div>
) : ( <div className="status-caption">
<span className="status-caption">В ручном режиме пресеты недоступны.</span> Выбранный пресет: {getPresetLabel(activePreset)}.
)} При применении пресета будут изменены: «Роли ботов и вступление», «Инвайт через админов», «Интервалы и лимиты».
</div>
<div className="row-inline">
<input
type="text"
value={customPresetName}
onChange={(event) => setCustomPresetName(event.target.value)}
placeholder="Имя пользовательского пресета"
/>
<button className="secondary" type="button" onClick={saveCustomPreset}>Сохранить пресет</button>
<button className="secondary" type="button" onClick={exportCustomPresets}>Экспорт пресетов</button>
<label className="secondary" style={{ cursor: "pointer" }}>
Импорт пресетов
<input type="file" accept="application/json" onChange={importCustomPresets} style={{ display: "none" }} />
</label>
{customPresets.map((preset) => (
<span key={preset.name} className="row-inline">
<button
className={`secondary ${activePreset === `custom:${preset.name}` ? "active" : ""}`}
type="button"
onClick={() => applyCustomPreset(preset)}
>
{preset.name}
</button>
<button className="ghost tiny" type="button" onClick={() => renameCustomPreset(preset.name)}>Переименовать</button>
<button className="danger ghost tiny" type="button" onClick={() => deleteCustomPreset(preset.name)}>Удалить</button>
</span>
))}
</div> </div>
<div className="row"> <div className="row">
<label> <label>
@ -316,7 +464,7 @@ export default function TaskSettingsTab({
</div> </div>
</details> </details>
</details> </details>
<details className="section" open> <details className="section">
<summary className="section-title">Расширенные настройки</summary> <summary className="section-title">Расширенные настройки</summary>
<details className="section"> <details className="section">
<summary className="section-title">Инвайт через админов</summary> <summary className="section-title">Инвайт через админов</summary>
@ -588,8 +736,6 @@ export default function TaskSettingsTab({
<span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span> <span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span>
</label> </label>
</div> </div>
<details className="section">
<summary className="section-title">Очень редко</summary>
<div className="toggle-row"> <div className="toggle-row">
<label className="checkbox"> <label className="checkbox">
<input <input
@ -615,7 +761,6 @@ export default function TaskSettingsTab({
</span> </span>
</label> </label>
</div> </div>
</details>
<div className="row"> <div className="row">
<label> <label>
<span className="label-line">Остановить при блоке, %</span> <span className="label-line">Остановить при блоке, %</span>

View File

@ -78,7 +78,14 @@ export default function TasksSidebar({
const statusClass = status ? (status.running ? "ok" : "off") : "off"; const statusClass = status ? (status.running ? "ok" : "off") : "off";
const unconfirmedCount = status ? Number(status.unconfirmedCount || 0) : 0; const unconfirmedCount = status ? Number(status.unconfirmedCount || 0) : 0;
const queueLabel = status ? `Очередь: ${status.queueCount}` : "Очередь: —"; const queueLabel = status ? `Очередь: ${status.queueCount}` : "Очередь: —";
const dailyLabel = status ? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}` : "Лимит сегодня: —"; const warmupStart = Number(task.warmupStartLimit || 0);
const warmupEnabled = Boolean(task.warmupEnabled);
const warmupDelta = status && warmupEnabled && warmupStart > 0 && Number(status.dailyLimit || 0) > warmupStart
? Number(status.dailyLimit || 0) - warmupStart
: 0;
const dailyLabel = status
? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}`
: "Лимит сегодня: —";
const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —"; const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —";
const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
? status.monitorInfo.lastMessageAt ? status.monitorInfo.lastMessageAt
@ -103,7 +110,7 @@ export default function TasksSidebar({
const tooltip = [ const tooltip = [
`Статус: ${statusLabel}`, `Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`, `Очередь: ${status ? status.queueCount : "—"}`,
`Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}` : "—"}`, `Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` : "—"}`,
`Мониторинг: ${monitoring ? "активен" : "нет"}`, `Мониторинг: ${monitoring ? "активен" : "нет"}`,
`Мониторит: ${monitorLabel}`, `Мониторит: ${monitorLabel}`,
`Последнее: ${lastMessage}`, `Последнее: ${lastMessage}`,

View File

@ -1,8 +1,10 @@
import React from "react"; import React from "react";
export default function TestRunCard({ testRun, onRunSafe, onRunLive }) { export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
const [collapsed, setCollapsed] = React.useState(false);
if (!testRun) return null; if (!testRun) return null;
const { status, mode, steps, startedAt, finishedAt, summary } = testRun; const { status, mode, steps, startedAt, finishedAt, summary } = testRun;
const modeLabel = mode === "live" ? "с реальной проверкой" : mode === "safe" ? "безопасный" : "—";
const statusLabel = status === "running" const statusLabel = status === "running"
? "В процессе" ? "В процессе"
: status === "ok" : status === "ok"
@ -12,17 +14,29 @@ export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
: status === "error" : status === "error"
? "Ошибки" ? "Ошибки"
: "Не запускался"; : "Не запускался";
const stepStatusLabel = (value) => {
if (value === "ok") return "Успех";
if (value === "warn") return "Предупреждение";
if (value === "error") return "Ошибка";
return value || "—";
};
return ( return (
<section className="card"> <section className="card">
<div className="row-header"> <div className="row-header">
<h3>Тестовый прогон</h3> <h3>Тестовый прогон</h3>
<div className="row-inline"> <div className="row-inline">
<button className="secondary" type="button" onClick={onRunSafe}>Safe</button> <button className="secondary" type="button" onClick={onRunSafe}>Безопасный</button>
<button className="secondary" type="button" onClick={onRunLive}>Live</button> <button className="secondary" type="button" onClick={onRunLive}>С реальной проверкой</button>
<button className="ghost" type="button" onClick={() => setCollapsed((prev) => !prev)}>
{collapsed ? "Развернуть" : "Свернуть"}
</button>
</div> </div>
</div> </div>
{!collapsed && (
<>
<div className="status-banner"> <div className="status-banner">
Статус: <strong>{statusLabel}</strong>{mode ? ` · режим: ${mode}` : ""}{summary ? ` · ${summary}` : ""} Статус: <strong>{statusLabel}</strong>{mode ? ` · режим: ${modeLabel}` : ""}{summary ? ` · ${summary}` : ""}
</div> </div>
{(startedAt || finishedAt) && ( {(startedAt || finishedAt) && (
<div className="status-caption"> <div className="status-caption">
@ -41,11 +55,18 @@ export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
{steps.map((step, index) => ( {steps.map((step, index) => (
<div className="log-row" key={`${step.key || step.title}-${index}`}> <div className="log-row" key={`${step.key || step.title}-${index}`}>
<div>{step.title}</div> <div>{step.title}</div>
<div>{step.status}</div> <div>{stepStatusLabel(step.status)}</div>
<div>{step.details || "—"}</div> <div>{step.details || "—"}</div>
</div> </div>
))} ))}
</div> </div>
</>
)}
{collapsed && (
<div className="status-caption">
Свернуто. Статус: {statusLabel}
</div>
)}
</section> </section>
); );
} }

View File

@ -1,4 +1,6 @@
export default function useAppTabGroups({ export default function useAppTabGroups({
selectedTaskId,
refreshQueue,
selectedTaskName, selectedTaskName,
taskForm, taskForm,
setTaskForm, setTaskForm,
@ -247,6 +249,8 @@ export default function useAppTabGroups({
formatAccountLabel formatAccountLabel
}; };
const queueTabGroup = { const queueTabGroup = {
selectedTaskId,
refreshQueue,
queueStats, queueStats,
queueSearch, queueSearch,
setQueueSearch, setQueueSearch,

View File

@ -117,6 +117,8 @@ export default function useTabProps(
mutualContactDiagnostics mutualContactDiagnostics
} = logsTab; } = logsTab;
const { const {
selectedTaskId,
refreshQueue,
queueStats, queueStats,
queueSearch, queueSearch,
setQueueSearch, setQueueSearch,
@ -257,6 +259,8 @@ export default function useTabProps(
}; };
const queueTabProps = { const queueTabProps = {
hasSelectedTask, hasSelectedTask,
selectedTaskId,
refreshQueue,
queueStats, queueStats,
queueSearch, queueSearch,
setQueueSearch, setQueueSearch,

View File

@ -335,10 +335,27 @@ export default function useTaskActions({
return; return;
} }
showNotification("Проверяем всё: доступ, права, участие...", "info"); showNotification("Проверяем всё: доступ, права, участие...", "info");
const warnings = [];
try {
await checkAccess(source, true); await checkAccess(source, true);
} catch (error) {
warnings.push(`Доступ: ${error.message || String(error)}`);
}
try {
await checkInviteAccess(source, true); await checkInviteAccess(source, true);
} catch (error) {
warnings.push(`Права инвайта: ${error.message || String(error)}`);
}
try {
await refreshMembership(source, true); await refreshMembership(source, true);
} catch (error) {
warnings.push(`Участие: ${error.message || String(error)}`);
}
if (warnings.length) {
setTaskNotice({ text: `Проверка завершена с предупреждениями: ${warnings.join(" | ")}`, tone: "warn", source });
} else {
setTaskNotice({ text: "Проверка завершена.", tone: "success", source }); setTaskNotice({ text: "Проверка завершена.", tone: "success", source });
}
}; };
const joinGroupsForTask = async (source = "editor") => { const joinGroupsForTask = async (source = "editor") => {

View File

@ -32,12 +32,16 @@ export default function useTaskStatusView({
return account ? formatAccountLabel(account) : String(id); return account ? formatAccountLabel(account) : String(id);
}) })
.filter(Boolean); .filter(Boolean);
const pauseReasonText = taskStatus.running
? ""
: (taskStatus.pauseReason || taskStatus.lastStopReason || "");
const nowLine = [ const nowLine = [
`Мониторинг: ${taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "вкл." : "выкл."}`, `Мониторинг: ${taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "вкл." : "выкл."}`,
`Очередь: ${taskStatus.queueCount}`, `Очередь: ${taskStatus.queueCount}`,
`Инвайт: ${taskStatus.running ? "активен" : "остановлен"}`, `Инвайт: ${taskStatus.running ? "идет" : "остановлен"}`,
`Следующий цикл: ${taskStatus.running ? formatCountdownWithNow(taskStatus.nextRunAt) : "—"}` `Следующий цикл: ${taskStatus.running ? formatCountdownWithNow(taskStatus.nextRunAt) : "—"}`,
].join(" • "); pauseReasonText ? `Причина паузы: ${pauseReasonText}` : null
].filter(Boolean).join(" • ");
const primaryIssue = taskStatus.readiness && !taskStatus.readiness.ok && taskStatus.readiness.reasons && taskStatus.readiness.reasons.length const primaryIssue = taskStatus.readiness && !taskStatus.readiness.ok && taskStatus.readiness.reasons && taskStatus.readiness.reasons.length
? taskStatus.readiness.reasons[0] ? taskStatus.readiness.reasons[0]
: ""; : "";

View File

@ -1432,7 +1432,7 @@ button.danger {
.role-badges { .role-badges {
display: flex; display: flex;
gap: 6px; gap: 6px;
margin: 6px 0; margin: 4px 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.busy-accounts { .busy-accounts {
@ -1443,15 +1443,18 @@ button.danger {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
padding: 12px 16px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
background: #f8fafc; background: #f8fafc;
gap: 12px;
} }
.account-main { .account-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
min-width: 0;
flex: 1 1 auto;
} }
.account-header { .account-header {
@ -1473,7 +1476,7 @@ button.danger {
.account-meta { .account-meta {
font-size: 12px; font-size: 12px;
color: #64748b; color: #64748b;
margin-top: 4px; margin-top: 2px;
} }
.status { .status {
@ -1500,8 +1503,18 @@ button.danger {
.account-actions { .account-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
align-items: flex-end; align-items: flex-end;
min-width: 180px;
align-self: stretch;
justify-content: flex-start;
}
.account-actions .secondary,
.account-actions .danger,
.account-actions .ghost {
min-height: 34px;
line-height: 1.1;
} }
.account-section { .account-section {
@ -1538,6 +1551,11 @@ button.danger {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
} }
.account-row.free .account-actions,
.account-row.busy .account-actions {
padding-top: 2px;
}
.role-toggle { .role-toggle {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -1932,6 +1950,8 @@ label .hint {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
align-items: flex-start;
text-align: left;
} }
.log-time { .log-time {
@ -1947,6 +1967,8 @@ label .hint {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
font-size: 14px; font-size: 14px;
align-items: flex-start;
text-align: left;
} }
.log-users, .log-users,
@ -1954,12 +1976,14 @@ label .hint {
font-size: 12px; font-size: 12px;
color: #475569; color: #475569;
white-space: pre-line; white-space: pre-line;
text-align: left;
} }
.log-result-list { .log-result-list {
display: grid; display: grid;
gap: 6px; gap: 6px;
margin-top: 6px; margin-top: 6px;
justify-items: start;
} }
.log-table { .log-table {
@ -1988,6 +2012,17 @@ label .hint {
font-size: 13px; font-size: 13px;
} }
.queue-row-warn {
background: #fff8ed;
border-radius: 8px;
padding: 8px;
}
.queue-row-issue {
color: #b45309;
margin-top: 2px;
}
.log-empty { .log-empty {
font-size: 13px; font-size: 13px;
color: #64748b; color: #64748b;

View File

@ -148,8 +148,10 @@ function AccountsTab({
<div> <div>
<div className="account-phone">{formatAccountLabel(account)}</div> <div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div> <div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className={`account-status-pill ${selected ? "busy" : "free"}`}>
{selected ? "В задаче" : "Свободен"}
</div>
</div> </div>
{filterFreeAccounts && <div className="account-status-pill free">Свободен</div>}
</div> </div>
<div className="role-badges"> <div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>} {roles.monitor && <span className="role-pill">Мониторинг</span>}
@ -159,12 +161,8 @@ function AccountsTab({
</div> </div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta membership-row compact"> <div className="account-meta membership-row compact">
<strong>Участие:</strong> <strong>{competitorInfo}</strong>
<span>{competitorInfo}</span>
<span>·</span>
<span>{ourInfo}</span>
{membership && ( {membership && (
<>
<button <button
className="ghost tiny" className="ghost tiny"
type="button" type="button"
@ -172,6 +170,11 @@ function AccountsTab({
> >
Список Список
</button> </button>
)}
</div>
<div className="account-meta membership-row compact">
<strong>{ourInfo}</strong>
{membership && (
<button <button
className="ghost tiny" className="ghost tiny"
type="button" type="button"
@ -179,7 +182,6 @@ function AccountsTab({
> >
Подробнее Подробнее
</button> </button>
</>
)} )}
</div> </div>
{hasSelectedTask && rolesMode !== "auto" && ( {hasSelectedTask && rolesMode !== "auto" && (
@ -330,17 +332,20 @@ function AccountsTab({
return ( return (
<div key={account.id} className="account-row busy"> <div key={account.id} className="account-row busy">
<div className="account-main">
<div className="account-header">
<div> <div>
<div className="account-phone">{formatAccountLabel(account)}</div> <div className="account-phone">{formatAccountLabel(account)}</div>
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div> <div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
<div className="account-status-pill busy">Занят</div> <div className="account-status-pill busy">Занят</div>
</div>
</div>
<div className="role-badges"> <div className="role-badges">
{roles.monitor && <span className="role-pill">Мониторинг</span>} {roles.monitor && <span className="role-pill">Мониторинг</span>}
{roles.invite && <span className="role-pill">Инвайт</span>} {roles.invite && <span className="role-pill">Инвайт</span>}
{roles.confirm && <span className="role-pill">Подтверждение</span>} {roles.confirm && <span className="role-pill">Подтверждение</span>}
</div> </div>
<div className="account-meta">User ID: {account.user_id || "—"}</div> <div className="account-meta">User ID: {account.user_id || "—"}</div>
<div className="account-meta status-caption">Группы для выбранной задачи</div>
<div className="account-meta membership-row"> <div className="account-meta membership-row">
<strong>{competitorInfo}</strong> <strong>{competitorInfo}</strong>
{membership && ( {membership && (
@ -365,6 +370,8 @@ function AccountsTab({
</button> </button>
)} )}
</div> </div>
<details className="account-details">
<summary>Детали и лимиты</summary>
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div> <div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
<div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div> <div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
<div className="account-meta"> <div className="account-meta">
@ -376,6 +383,7 @@ function AccountsTab({
Таймер FLOOD: {cooldownMinutes} мин Таймер FLOOD: {cooldownMinutes} мин
</div> </div>
)} )}
</details>
</div> </div>
{account.status !== "ok" && account.last_error && ( {account.status !== "ok" && account.last_error && (
<div className="account-error">{account.last_error}</div> <div className="account-error">{account.last_error}</div>

View File

@ -40,11 +40,11 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
case "monitor_polling_started": case "monitor_polling_started":
return `Мониторинг: старт опроса.${firstLine ? ` ${firstLine}` : ""}`; return `Мониторинг: старт опроса.${firstLine ? ` ${firstLine}` : ""}`;
case "new_message": case "new_message":
return `Новый пользователь добавлен в очередь.${firstLine ? ` ${firstLine}` : ""}`; return `Добавлен в очередь на инвайт.${firstLine ? ` ${firstLine}` : ""}`;
case "new_message_duplicate": case "new_message_duplicate":
return firstLine return firstLine
? `Пользователь уже был обработан: ${firstLine}` ? `Повторный автор сообщения пропущен (уже обрабатывался): ${firstLine}`
: "Пользователь уже был обработан (повтор не выполнялся)."; : "Повторный автор сообщения пропущен (уже обрабатывался).";
case "invite_attempt": case "invite_attempt":
return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`; return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
case "invite_sent": case "invite_sent":
@ -58,7 +58,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
case "confirm_ok": case "confirm_ok":
return `Участие подтверждено.${userLabel ? ` ${userLabel}` : ""}`; return `Участие подтверждено.${userLabel ? ` ${userLabel}` : ""}`;
case "confirm_unconfirmed": case "confirm_unconfirmed":
return `Участие не подтверждено.${userLabel ? ` ${userLabel}` : ""}${firstLine && firstLine.includes("USER_NOT_PARTICIPANT") ? " (инвайт отправлен, но пользователь ещё не вступил)" : ""}`; return `Инвайт отправлен, но вступление не подтверждено.${userLabel ? ` ${userLabel}` : ""}${firstLine && firstLine.includes("USER_NOT_PARTICIPANT") ? " (пользователь пока не вступил)" : ""}`;
case "confirm_retry_scheduled": case "confirm_retry_scheduled":
return `Повторная проверка запланирована.${firstLine ? ` ${firstLine}` : ""}`; return `Повторная проверка запланирована.${firstLine ? ` ${firstLine}` : ""}`;
case "confirm_retry_ok": case "confirm_retry_ok":
@ -78,10 +78,27 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
case "preset_applied": case "preset_applied":
return `Применен пресет настроек.${firstLine ? ` ${firstLine}` : ""}`; return `Применен пресет настроек.${firstLine ? ` ${firstLine}` : ""}`;
default: default:
return firstLine ? `Событие: ${firstLine}` : "Событие: подробности в «Подробнее»."; return firstLine ? `Кратко: ${firstLine}` : "Кратко: подробности в «Подробнее».";
} }
}; };
const buildEventWhy = (event) => {
const firstLine = event.message ? String(event.message).split("\n")[0] : "";
if (event.eventType === "invite_failed") {
if (firstLine.includes("USER_NOT_MUTUAL_CONTACT")) return "Почему: Telegram разрешает инвайт только для взаимных контактов или по ссылке.";
if (firstLine.includes("USER_PRIVACY_RESTRICTED")) return "Почему: пользователь ограничил приглашения настройками приватности.";
if (firstLine.includes("CHAT_WRITE_FORBIDDEN")) return "Почему: у инвайтера нет прав приглашать в целевой группе.";
return "Почему: Telegram отклонил приглашение (точная причина в «Подробнее»).";
}
if (event.eventType === "confirm_unconfirmed") {
return "Почему: пользователь еще не появился в составе участников на момент проверки.";
}
if (event.eventType === "new_message_duplicate") {
return "Почему: защита от повторных инвайтов одного и того же пользователя.";
}
return "";
};
const eventTypes = useMemo(() => { const eventTypes = useMemo(() => {
const types = new Set(accountEvents.map((event) => event.eventType)); const types = new Set(accountEvents.map((event) => event.eventType));
return ["all", ...Array.from(types)]; return ["all", ...Array.from(types)];
@ -135,7 +152,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
<div key={event.id} className="log-row"> <div key={event.id} className="log-row">
<div className="log-time"> <div className="log-time">
<div>{formatTimestamp(event.createdAt)}</div> <div>{formatTimestamp(event.createdAt)}</div>
<div>{event.eventType}</div> <div>Тип: {event.eventType}</div>
</div> </div>
<div className="log-details"> <div className="log-details">
<div>Аккаунт: {(() => { <div>Аккаунт: {(() => {
@ -143,6 +160,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
return account ? formatAccountLabel(account) : (event.phone || event.accountId); return account ? formatAccountLabel(account) : (event.phone || event.accountId);
})()}</div> })()}</div>
<div className="log-users wrap">{buildEventSummary(event)}</div> <div className="log-users wrap">{buildEventSummary(event)}</div>
{buildEventWhy(event) && <div className="log-errors">{buildEventWhy(event)}</div>}
<button <button
type="button" type="button"
className="ghost" className="ghost"

View File

@ -84,13 +84,13 @@ function LogsTab({
const strategyLabel = (strategy) => { const strategyLabel = (strategy) => {
switch (strategy) { switch (strategy) {
case "access_hash": case "access_hash":
return "access_hash (из сообщения)"; return "access hash (из сообщения)";
case "participants": case "participants":
return "участники группы"; return "участники группы";
case "username": case "username":
return "username"; return "username (никнейм)";
case "entity": case "entity":
return "getEntity(userId)"; return "entity по ID";
case "retry": case "retry":
return "повторная попытка"; return "повторная попытка";
default: default:
@ -104,7 +104,7 @@ function LogsTab({
const parsed = JSON.parse(meta); const parsed = JSON.parse(meta);
if (!Array.isArray(parsed)) return meta; if (!Array.isArray(parsed)) return meta;
return parsed return parsed
.map((item) => `${strategyLabel(item.strategy)}: ${item.ok ? "ok" : "fail"}${item.detail ? ` (${item.detail})` : ""}`) .map((item) => `${strategyLabel(item.strategy)}: ${item.ok ? "успех" : "ошибка"}${item.detail ? ` (${item.detail})` : ""}`)
.map((line) => `- ${line}`) .map((line) => `- ${line}`)
.join("\n"); .join("\n");
} catch (error) { } catch (error) {
@ -165,6 +165,17 @@ function LogsTab({
return ""; return "";
} }
}; };
const buildUserReason = (invite) => {
const codeRaw = invite.error || invite.confirmError || invite.skippedReason || "";
const code = String(codeRaw).split(/[:(]/, 1)[0].trim();
if (invite.status === "success") return "Пользователь добавлен и участие подтверждено.";
if (invite.status === "unconfirmed") {
if (code === "USER_NOT_PARTICIPANT") return "Инвайт отправлен, но пользователь еще не вступил в группу.";
return explainRawError(invite.confirmError) || "Инвайт отправлен, но участие пока не подтверждено.";
}
if (invite.status === "skipped") return explainRawError(invite.skippedReason) || "Попытка была пропущена.";
return explainRawError(invite.error) || "Telegram отклонил попытку приглашения.";
};
const buildInviteSummary = (invite) => { const buildInviteSummary = (invite) => {
const userLabel = invite.username const userLabel = invite.username
? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}` ? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}`
@ -299,7 +310,7 @@ function LogsTab({
className={`tab ${logsTab === "fallback" ? "active" : ""}`} className={`tab ${logsTab === "fallback" ? "active" : ""}`}
onClick={() => setLogsTab("fallback")} onClick={() => setLogsTab("fallback")}
> >
Fallback Проблемные
</button> </button>
<button <button
type="button" type="button"
@ -530,6 +541,15 @@ function LogsTab({
> >
{expandedInviteId === invite.id ? "Скрыть детали" : "Подробнее"} {expandedInviteId === invite.id ? "Скрыть детали" : "Подробнее"}
</button> </button>
{expandedInviteId === invite.id && (
<div className="invite-details">
<div><strong>Что случилось:</strong> {formatInviteStatus(invite.status)}</div>
<div><strong>Почему:</strong> {buildUserReason(invite)}</div>
{suggestAction(invite) && (
<div><strong>Что сделать:</strong> {suggestAction(invite).replace(/^Совет:\s*/, "")}</div>
)}
</div>
)}
{expandedInviteId === invite.id && ( {expandedInviteId === invite.id && (
<div className="invite-details"> <div className="invite-details">
<div>ID: {invite.userId}</div> <div>ID: {invite.userId}</div>
@ -590,9 +610,6 @@ function LogsTab({
<div>Статус: {formatInviteStatus(invite.status)}</div> <div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div> <div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div> <div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
{suggestAction(invite) && (
<div>Совет: {suggestAction(invite).replace(/^Совет:\s*/, "")}</div>
)}
{invite.confirmError && invite.confirmError.includes("(") && ( {invite.confirmError && invite.confirmError.includes("(") && (
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div> <div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
)} )}

View File

@ -2,6 +2,8 @@ import React from "react";
export default function QueueTab({ export default function QueueTab({
hasSelectedTask, hasSelectedTask,
selectedTaskId,
refreshQueue,
queueStats, queueStats,
queueSearch, queueSearch,
setQueueSearch, setQueueSearch,
@ -13,11 +15,73 @@ export default function QueueTab({
formatAccountLabel, formatAccountLabel,
formatTimestamp formatTimestamp
}) { }) {
const [queueFilter, setQueueFilter] = React.useState("all");
const formatUserWithUsername = (item) => { const formatUserWithUsername = (item) => {
const id = item.user_id != null ? String(item.user_id) : "—"; const id = item.user_id != null ? String(item.user_id) : "—";
const username = item.username ? String(item.username).replace(/^@/, "") : ""; const username = item.username ? String(item.username).replace(/^@/, "") : "";
return username ? `${id} (@${username})` : id; return username ? `${id} (@${username})` : id;
}; };
const filteredQueue = React.useMemo(() => {
if (!Array.isArray(pagedQueue)) return [];
if (queueFilter === "all") return pagedQueue;
return pagedQueue.filter((item) => {
const hasUsername = Boolean(item.username);
const hasAccessHash = item.access_hash != null || item.user_access_hash != null;
const attempts = Number(item.attempts || 0);
if (queueFilter === "username") return hasUsername;
if (queueFilter === "access_hash") return !hasUsername && hasAccessHash;
if (queueFilter === "retry") return attempts > 0;
if (queueFilter === "empty") return !hasUsername && !hasAccessHash;
return true;
});
}, [pagedQueue, queueFilter]);
const getRowIssue = (item) => {
const hasUsername = Boolean(item.username);
const hasAccessHash = item.access_hash != null || item.user_access_hash != null;
const attempts = Number(item.attempts || 0);
if (!hasUsername && !hasAccessHash) return "Нет username и access hash";
if (!hasUsername && hasAccessHash) return "Только access hash";
if (attempts > 0) return `Повторная попытка: ${attempts}`;
return "";
};
const copyBulk = async (kind) => {
const values = filteredQueue
.map((item) => {
if (kind === "ids") return item.user_id != null ? String(item.user_id) : "";
if (kind === "usernames") return item.username ? `@${String(item.username).replace(/^@/, "")}` : "";
return "";
})
.filter(Boolean);
if (!values.length) {
window.alert("Нет данных для копирования в текущем фильтре.");
return;
}
const text = values.join("\n");
try {
await navigator.clipboard.writeText(text);
window.alert(`Скопировано: ${values.length}`);
} catch {
window.alert("Не удалось скопировать.");
}
};
const clearByFilter = async () => {
if (!filteredQueue.length) {
window.alert("В текущем фильтре нет элементов для удаления.");
return;
}
const ok = window.confirm(`Удалить из очереди элементы текущего фильтра: ${filteredQueue.length}?`);
if (!ok) return;
if (!window.api || !window.api.clearQueueItems) {
window.alert("Функция удаления недоступна.");
return;
}
const ids = filteredQueue.map((item) => item.id).filter(Boolean);
const result = await window.api.clearQueueItems({ taskId: selectedTaskId, ids });
window.alert(`Удалено: ${result && result.removed ? result.removed : 0}`);
if (typeof refreshQueue === "function") {
await refreshQueue();
}
};
if (!hasSelectedTask) { if (!hasSelectedTask) {
return ( return (
@ -65,8 +129,39 @@ export default function QueueTab({
</button> </button>
</div> </div>
</div> </div>
<div className="task-filters">
<button type="button" className={`chip ${queueFilter === "all" ? "active" : ""}`} onClick={() => setQueueFilter("all")}>
Все
</button>
<button type="button" className={`chip ${queueFilter === "username" ? "active" : ""}`} onClick={() => setQueueFilter("username")}>
По username
</button>
<button type="button" className={`chip ${queueFilter === "access_hash" ? "active" : ""}`} onClick={() => setQueueFilter("access_hash")}>
По access hash
</button>
<button type="button" className={`chip ${queueFilter === "retry" ? "active" : ""}`} onClick={() => setQueueFilter("retry")}>
Повторные
</button>
<button type="button" className={`chip ${queueFilter === "empty" ? "active" : ""}`} onClick={() => setQueueFilter("empty")}>
Без данных
</button>
</div>
<div className="status-caption"> <div className="status-caption">
В очереди: {queueStats?.total ?? 0} · username: {queueStats?.withUsername ?? 0} · access_hash: {queueStats?.withAccessHash ?? 0} · пустые: {queueStats?.withoutData ?? 0} В очереди: {queueStats?.total ?? 0} · username: {queueStats?.withUsername ?? 0} · access hash: {queueStats?.withAccessHash ?? 0} · пустые: {queueStats?.withoutData ?? 0}
</div>
<div className="status-caption">
Показано в текущем фильтре: {filteredQueue.length}
</div>
<div className="row-inline">
<button type="button" className="secondary" onClick={() => copyBulk("ids")}>
Скопировать ID
</button>
<button type="button" className="secondary" onClick={() => copyBulk("usernames")}>
Скопировать username
</button>
<button type="button" className="danger ghost" onClick={clearByFilter}>
Удалить по фильтру
</button>
</div> </div>
<div className="log-table"> <div className="log-table">
<div className="log-head"> <div className="log-head">
@ -76,19 +171,23 @@ export default function QueueTab({
<span>Попытки</span> <span>Попытки</span>
<span>Добавлен</span> <span>Добавлен</span>
</div> </div>
{pagedQueue.length === 0 && ( {filteredQueue.length === 0 && (
<div className="log-empty">Очередь пуста.</div> <div className="log-empty">Очередь пуста.</div>
)} )}
{pagedQueue.map((item) => { {filteredQueue.map((item) => {
const watcher = item.watcher_account_id ? accountById.get(item.watcher_account_id) : null; const watcher = item.watcher_account_id ? accountById.get(item.watcher_account_id) : null;
const watcherLabel = watcher ? formatAccountLabel(watcher) : (item.watcher_account_id || "—"); const watcherLabel = watcher ? formatAccountLabel(watcher) : (item.watcher_account_id || "—");
const issue = getRowIssue(item);
return ( return (
<div className="log-row" key={item.id}> <div className={`log-row ${issue ? "queue-row-warn" : ""}`} key={item.id}>
<div>{formatUserWithUsername(item)}</div> <div>{formatUserWithUsername(item)}</div>
<div>{item.source_chat || "—"}</div> <div>{item.source_chat || "—"}</div>
<div>{watcherLabel}</div> <div>{watcherLabel}</div>
<div>{item.attempts ?? 0}</div> <div>{item.attempts ?? 0}</div>
<div>{formatTimestamp(item.created_at)}</div> <div>
{formatTimestamp(item.created_at)}
{issue ? <div className="status-caption queue-row-issue">{issue}</div> : null}
</div>
</div> </div>
); );
})} })}

View File

@ -4,7 +4,7 @@ export const explainInviteError = (error) => {
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности."; return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
} }
if (error === "CHAT_WRITE_FORBIDDEN") { if (error === "CHAT_WRITE_FORBIDDEN") {
return "Аккаунт не может приглашать: нет прав или он не участник группы."; return "Инвайтер не может приглашать: обычно он не состоит в целевой группе (или заявка еще не одобрена), либо у него нет права добавлять участников.";
} }
if (error === "USER_NOT_MUTUAL_CONTACT") { if (error === "USER_NOT_MUTUAL_CONTACT") {
return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы."; return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы.";

View File

@ -0,0 +1,12 @@
export function getPresetLabel(preset) {
if (!preset) return "Кастом (изменено вручную)";
if (preset === "admin") return "Автораспределение + Инвайт через админа";
if (preset === "no_admin") return "Автораспределение + Без админки";
if (preset === "soft_50") return "Мягкий 50/день (5 инвайтеров)";
if (preset === "soft_25") return "Мягкий 25/день (2 инвайтера)";
if (preset === "soft_50_admin") return "Мягкий 50/день + Инвайт через админов";
if (preset === "soft_25_admin") return "Мягкий 25/день + Инвайт через админов";
if (String(preset).startsWith("custom:")) return `Пользовательский: ${String(preset).slice(7)}`;
return String(preset);
}