some
This commit is contained in:
parent
3728863b63
commit
27bac2ef1b
@ -445,6 +445,12 @@ ipcMain.handle("queue:clear", (_event, taskId) => {
|
||||
store.clearQueue(taskId);
|
||||
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) => {
|
||||
const taskId = payload && payload.taskId != null ? payload.taskId : 0;
|
||||
const limit = payload && payload.limit != null ? payload.limit : 200;
|
||||
@ -467,7 +473,10 @@ ipcMain.handle("test:inviteOnce", async (_event, payload) => {
|
||||
accounts.forEach((account) => accountMap.set(account.id, account));
|
||||
let accountsForInvite = accountRows.map((row) => row.account_id);
|
||||
if (!item.username && task.use_watcher_invite_no_username && item.watcher_account_id) {
|
||||
accountsForInvite = [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];
|
||||
}
|
||||
}
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
store.addAccountEvent(
|
||||
|
||||
@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld("api", {
|
||||
exportLogs: (taskId) => ipcRenderer.invoke("logs:export", taskId),
|
||||
exportInvites: (taskId) => ipcRenderer.invoke("invites:export", taskId),
|
||||
clearQueue: (taskId) => ipcRenderer.invoke("queue:clear", taskId),
|
||||
clearQueueItems: (payload) => ipcRenderer.invoke("queue:clearItems", payload),
|
||||
listQueue: (payload) => ipcRenderer.invoke("queue:list", payload),
|
||||
testInviteOnce: (payload) => ipcRenderer.invoke("test:inviteOnce", payload),
|
||||
startTask: () => ipcRenderer.invoke("task:start"),
|
||||
|
||||
@ -599,6 +599,22 @@ function initStore(userDataPath) {
|
||||
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) {
|
||||
const limit = Number(hours || 0);
|
||||
if (!Number.isFinite(limit) || limit <= 0) return 0;
|
||||
@ -1237,6 +1253,7 @@ function initStore(userDataPath) {
|
||||
getPendingCount,
|
||||
getPendingStats,
|
||||
clearQueue,
|
||||
clearQueueItems,
|
||||
clearQueueOlderThan,
|
||||
markInviteStatus,
|
||||
incrementInviteAttempt,
|
||||
|
||||
@ -275,7 +275,18 @@ class TaskRunner {
|
||||
}
|
||||
}
|
||||
if (!item.username && this.task.use_watcher_invite_no_username && item.watcher_account_id) {
|
||||
accountsForInvite = [item.watcher_account_id];
|
||||
const watcherCanInvite = inviteAccounts.includes(Number(item.watcher_account_id));
|
||||
if (watcherCanInvite) {
|
||||
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 result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, {
|
||||
|
||||
@ -665,6 +665,16 @@ export default function App() {
|
||||
formatCountdown,
|
||||
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({
|
||||
activeTab,
|
||||
selectedTaskId,
|
||||
@ -778,6 +788,8 @@ export default function App() {
|
||||
setLogsTab
|
||||
});
|
||||
const { taskSettings, accountsTab, logsTab: logsTabGroup, queueTab: queueTabGroup, eventsTab, settingsTab } = useAppTabGroups({
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
selectedTaskName,
|
||||
taskForm,
|
||||
setTaskForm,
|
||||
|
||||
@ -80,13 +80,13 @@ export default function AppOverlays({
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={liveConfirmOpen}
|
||||
title="Live‑инвайт"
|
||||
title="Реальная проверка инвайта"
|
||||
message={
|
||||
liveConfirmContext
|
||||
? `Live‑прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые).\nДополнительно он делает один реальный инвайт из очереди для проверки фактической работы и ошибок Telegram.\n\nПользователь: ${liveConfirmContext.userId || "—"}${liveConfirmContext.username ? ` (@${liveConfirmContext.username})` : ""}\nИсточник: ${liveConfirmContext.sourceChat || "—"}`
|
||||
: "Live‑прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые). Дополнительно он делает один реальный инвайт из очереди."
|
||||
? `Этот прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые).\nДополнительно он делает один реальный инвайт из очереди, чтобы проверить фактический результат и возможные ошибки Telegram.\n\nПользователь: ${liveConfirmContext.userId || "—"}${liveConfirmContext.username ? ` (@${liveConfirmContext.username})` : ""}\nИсточник: ${liveConfirmContext.sourceChat || "—"}`
|
||||
: "Этот прогон проверяет мониторинг, инвайт и подтверждение участия, а также видимость конкурентов (открытые/закрытые). Дополнительно он делает один реальный инвайт из очереди."
|
||||
}
|
||||
confirmLabel="Сделать инвайт"
|
||||
confirmLabel="Запустить реальную проверку"
|
||||
cancelLabel="Отмена"
|
||||
onConfirm={onConfirmLiveInvite}
|
||||
onCancel={onCancelLiveInvite}
|
||||
|
||||
@ -25,6 +25,13 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
>
|
||||
Функции
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "terms" ? "active" : ""}`}
|
||||
onClick={() => setInfoTab("terms")}
|
||||
>
|
||||
Термины
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${infoTab === "strategies" ? "active" : ""}`}
|
||||
@ -57,14 +64,24 @@ export default function InfoModal({ open, onClose, infoTab, setInfoTab }) {
|
||||
{infoTab === "features" && (
|
||||
<div className="help-note">
|
||||
<strong>Функции и режимы:</strong>
|
||||
<div>1) Мониторинг: отслеживает новые сообщения в чатах конкурентов и добавляет авторов в очередь.</div>
|
||||
<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>4) Прогрев лимита: плавно увеличивает нагрузку, чтобы снизить риск ограничений.</div>
|
||||
<div>5) История/Очередь/События: показывает, что произошло и почему.</div>
|
||||
</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>
|
||||
)}
|
||||
{infoTab === "strategies" && (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { getPresetLabel } from "../utils/presetLabels.js";
|
||||
|
||||
export default function TaskSettingsTab({
|
||||
selectedTaskName,
|
||||
@ -31,6 +32,128 @@ export default function TaskSettingsTab({
|
||||
setFileImportForm,
|
||||
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Будут изменены разделы:\n- ${sections.join("\n- ")}\n\nПрименить?`
|
||||
: `Пресет "${label}" не меняет текущие настройки.\nПрименить?`;
|
||||
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 (
|
||||
<div className="task-columns">
|
||||
<details className="card collapsible task-editor" open>
|
||||
@ -43,57 +166,82 @@ export default function TaskSettingsTab({
|
||||
<div className="section-title">Основное</div>
|
||||
<div className="row-inline">
|
||||
<div className="status-caption">Пресеты:</div>
|
||||
{taskForm.rolesMode === "auto" ? (
|
||||
<>
|
||||
<button
|
||||
className={`secondary ${activePreset === "admin" ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => applyTaskPreset("admin")}
|
||||
onClick={() => confirmApply(taskForm, "Автораспределение + Инвайт через админа", () => applyTaskPreset("admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||||
>
|
||||
Автораспределение + Инвайт через админа
|
||||
</button>
|
||||
<button
|
||||
className={`secondary ${activePreset === "no_admin" ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => applyTaskPreset("no_admin")}
|
||||
onClick={() => confirmApply(taskForm, "Автораспределение + Без админки", () => applyTaskPreset("no_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||||
>
|
||||
Автораспределение + Без админки
|
||||
</button>
|
||||
<button
|
||||
className={`secondary ${activePreset === "soft_50" ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => applyTaskPreset("soft_50")}
|
||||
onClick={() => confirmApply(taskForm, "Мягкий 50/день (5 инвайтеров)", () => applyTaskPreset("soft_50"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
|
||||
>
|
||||
Мягкий 50/день (5 инвайтеров)
|
||||
</button>
|
||||
<button
|
||||
className={`secondary ${activePreset === "soft_25" ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => applyTaskPreset("soft_25")}
|
||||
onClick={() => confirmApply(taskForm, "Мягкий 25/день (2 инвайтера)", () => applyTaskPreset("soft_25"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
|
||||
>
|
||||
Мягкий 25/день (2 инвайтера)
|
||||
</button>
|
||||
<button
|
||||
className={`secondary ${activePreset === "soft_50_admin" ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => applyTaskPreset("soft_50_admin")}
|
||||
onClick={() => confirmApply(taskForm, "Мягкий 50/день + админы", () => applyTaskPreset("soft_50_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||||
>
|
||||
Мягкий 50/день + админы
|
||||
</button>
|
||||
<button
|
||||
className={`secondary ${activePreset === "soft_25_admin" ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => applyTaskPreset("soft_25_admin")}
|
||||
onClick={() => confirmApply(taskForm, "Мягкий 25/день + админы", () => applyTaskPreset("soft_25_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||||
>
|
||||
Мягкий 25/день + админы
|
||||
</button>
|
||||
<span className="status-caption">
|
||||
Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="status-caption">В ручном режиме пресеты недоступны.</span>
|
||||
)}
|
||||
<span className="status-caption">
|
||||
Мастер-админ: {taskForm.inviteAdminMasterId ? formatAccountLabel(accountById.get(taskForm.inviteAdminMasterId)) : "не выбран"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-caption">
|
||||
Выбранный пресет: {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 className="row">
|
||||
<label>
|
||||
@ -316,7 +464,7 @@ export default function TaskSettingsTab({
|
||||
</div>
|
||||
</details>
|
||||
</details>
|
||||
<details className="section" open>
|
||||
<details className="section">
|
||||
<summary className="section-title">Расширенные настройки</summary>
|
||||
<details className="section">
|
||||
<summary className="section-title">Инвайт через админов</summary>
|
||||
@ -588,34 +736,31 @@ export default function TaskSettingsTab({
|
||||
<span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span>
|
||||
</label>
|
||||
</div>
|
||||
<details className="section">
|
||||
<summary className="section-title">Очень редко</summary>
|
||||
<div className="toggle-row">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.allowStartWithoutInviteRights)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, allowStartWithoutInviteRights: event.target.checked })}
|
||||
/>
|
||||
Разрешать запуск без прав инвайта
|
||||
<span className="hint">Полезно, если вы выдаёте админов после автодобавления.</span>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.useWatcherInviteNoUsername)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
|
||||
/>
|
||||
Инвайт через наблюдателя, если нет username
|
||||
<span className="hint">
|
||||
Правило 1: сначала резолвим @username в сессии инвайтера — участие в группе конкурентов не требуется.
|
||||
</span>
|
||||
<span className="hint">
|
||||
Правило 2: если username нет — инвайтим наблюдателем, потому что access_hash валиден только в его сессии.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<div className="toggle-row">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.allowStartWithoutInviteRights)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, allowStartWithoutInviteRights: event.target.checked })}
|
||||
/>
|
||||
Разрешать запуск без прав инвайта
|
||||
<span className="hint">Полезно, если вы выдаёте админов после автодобавления.</span>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(taskForm.useWatcherInviteNoUsername)}
|
||||
onChange={(event) => setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
|
||||
/>
|
||||
Инвайт через наблюдателя, если нет username
|
||||
<span className="hint">
|
||||
Правило 1: сначала резолвим @username в сессии инвайтера — участие в группе конкурентов не требуется.
|
||||
</span>
|
||||
<span className="hint">
|
||||
Правило 2: если username нет — инвайтим наблюдателем, потому что access_hash валиден только в его сессии.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label>
|
||||
<span className="label-line">Остановить при блоке, %</span>
|
||||
|
||||
@ -78,7 +78,14 @@ export default function TasksSidebar({
|
||||
const statusClass = status ? (status.running ? "ok" : "off") : "off";
|
||||
const unconfirmedCount = status ? Number(status.unconfirmedCount || 0) : 0;
|
||||
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 lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
|
||||
? status.monitorInfo.lastMessageAt
|
||||
@ -103,7 +110,7 @@ export default function TasksSidebar({
|
||||
const tooltip = [
|
||||
`Статус: ${statusLabel}`,
|
||||
`Очередь: ${status ? status.queueCount : "—"}`,
|
||||
`Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}` : "—"}`,
|
||||
`Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` : "—"}`,
|
||||
`Мониторинг: ${monitoring ? "активен" : "нет"}`,
|
||||
`Мониторит: ${monitorLabel}`,
|
||||
`Последнее: ${lastMessage}`,
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
if (!testRun) return null;
|
||||
const { status, mode, steps, startedAt, finishedAt, summary } = testRun;
|
||||
const modeLabel = mode === "live" ? "с реальной проверкой" : mode === "safe" ? "безопасный" : "—";
|
||||
const statusLabel = status === "running"
|
||||
? "В процессе"
|
||||
: status === "ok"
|
||||
@ -12,40 +14,59 @@ export default function TestRunCard({ testRun, onRunSafe, onRunLive }) {
|
||||
: status === "error"
|
||||
? "Ошибки"
|
||||
: "Не запускался";
|
||||
const stepStatusLabel = (value) => {
|
||||
if (value === "ok") return "Успех";
|
||||
if (value === "warn") return "Предупреждение";
|
||||
if (value === "error") return "Ошибка";
|
||||
return value || "—";
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row-header">
|
||||
<h3>Тестовый прогон</h3>
|
||||
<div className="row-inline">
|
||||
<button className="secondary" type="button" onClick={onRunSafe}>Safe</button>
|
||||
<button className="secondary" type="button" onClick={onRunLive}>Live</button>
|
||||
<button className="secondary" type="button" onClick={onRunSafe}>Безопасный</button>
|
||||
<button className="secondary" type="button" onClick={onRunLive}>С реальной проверкой</button>
|
||||
<button className="ghost" type="button" onClick={() => setCollapsed((prev) => !prev)}>
|
||||
{collapsed ? "Развернуть" : "Свернуть"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-banner">
|
||||
Статус: <strong>{statusLabel}</strong>{mode ? ` · режим: ${mode}` : ""}{summary ? ` · ${summary}` : ""}
|
||||
</div>
|
||||
{(startedAt || finishedAt) && (
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="status-banner">
|
||||
Статус: <strong>{statusLabel}</strong>{mode ? ` · режим: ${modeLabel}` : ""}{summary ? ` · ${summary}` : ""}
|
||||
</div>
|
||||
{(startedAt || finishedAt) && (
|
||||
<div className="status-caption">
|
||||
Старт: {startedAt || "—"} · Завершение: {finishedAt || "—"}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-table">
|
||||
<div className="log-head">
|
||||
<span>Шаг</span>
|
||||
<span>Статус</span>
|
||||
<span>Детали</span>
|
||||
</div>
|
||||
{steps.length === 0 && (
|
||||
<div className="log-empty">Прогон ещё не запускался.</div>
|
||||
)}
|
||||
{steps.map((step, index) => (
|
||||
<div className="log-row" key={`${step.key || step.title}-${index}`}>
|
||||
<div>{step.title}</div>
|
||||
<div>{stepStatusLabel(step.status)}</div>
|
||||
<div>{step.details || "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="status-caption">
|
||||
Старт: {startedAt || "—"} · Завершение: {finishedAt || "—"}
|
||||
Свернуто. Статус: {statusLabel}
|
||||
</div>
|
||||
)}
|
||||
<div className="log-table">
|
||||
<div className="log-head">
|
||||
<span>Шаг</span>
|
||||
<span>Статус</span>
|
||||
<span>Детали</span>
|
||||
</div>
|
||||
{steps.length === 0 && (
|
||||
<div className="log-empty">Прогон ещё не запускался.</div>
|
||||
)}
|
||||
{steps.map((step, index) => (
|
||||
<div className="log-row" key={`${step.key || step.title}-${index}`}>
|
||||
<div>{step.title}</div>
|
||||
<div>{step.status}</div>
|
||||
<div>{step.details || "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default function useAppTabGroups({
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
selectedTaskName,
|
||||
taskForm,
|
||||
setTaskForm,
|
||||
@ -247,6 +249,8 @@ export default function useAppTabGroups({
|
||||
formatAccountLabel
|
||||
};
|
||||
const queueTabGroup = {
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
setQueueSearch,
|
||||
|
||||
@ -117,6 +117,8 @@ export default function useTabProps(
|
||||
mutualContactDiagnostics
|
||||
} = logsTab;
|
||||
const {
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
setQueueSearch,
|
||||
@ -257,6 +259,8 @@ export default function useTabProps(
|
||||
};
|
||||
const queueTabProps = {
|
||||
hasSelectedTask,
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
setQueueSearch,
|
||||
|
||||
@ -335,10 +335,27 @@ export default function useTaskActions({
|
||||
return;
|
||||
}
|
||||
showNotification("Проверяем всё: доступ, права, участие...", "info");
|
||||
await checkAccess(source, true);
|
||||
await checkInviteAccess(source, true);
|
||||
await refreshMembership(source, true);
|
||||
setTaskNotice({ text: "Проверка завершена.", tone: "success", source });
|
||||
const warnings = [];
|
||||
try {
|
||||
await checkAccess(source, true);
|
||||
} catch (error) {
|
||||
warnings.push(`Доступ: ${error.message || String(error)}`);
|
||||
}
|
||||
try {
|
||||
await checkInviteAccess(source, true);
|
||||
} catch (error) {
|
||||
warnings.push(`Права инвайта: ${error.message || String(error)}`);
|
||||
}
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
const joinGroupsForTask = async (source = "editor") => {
|
||||
|
||||
@ -32,12 +32,16 @@ export default function useTaskStatusView({
|
||||
return account ? formatAccountLabel(account) : String(id);
|
||||
})
|
||||
.filter(Boolean);
|
||||
const pauseReasonText = taskStatus.running
|
||||
? ""
|
||||
: (taskStatus.pauseReason || taskStatus.lastStopReason || "");
|
||||
const nowLine = [
|
||||
`Мониторинг: ${taskStatus.monitorInfo && taskStatus.monitorInfo.monitoring ? "вкл." : "выкл."}`,
|
||||
`Очередь: ${taskStatus.queueCount}`,
|
||||
`Инвайт: ${taskStatus.running ? "активен" : "остановлен"}`,
|
||||
`Следующий цикл: ${taskStatus.running ? formatCountdownWithNow(taskStatus.nextRunAt) : "—"}`
|
||||
].join(" • ");
|
||||
`Инвайт: ${taskStatus.running ? "идет" : "остановлен"}`,
|
||||
`Следующий цикл: ${taskStatus.running ? formatCountdownWithNow(taskStatus.nextRunAt) : "—"}`,
|
||||
pauseReasonText ? `Причина паузы: ${pauseReasonText}` : null
|
||||
].filter(Boolean).join(" • ");
|
||||
const primaryIssue = taskStatus.readiness && !taskStatus.readiness.ok && taskStatus.readiness.reasons && taskStatus.readiness.reasons.length
|
||||
? taskStatus.readiness.reasons[0]
|
||||
: "";
|
||||
|
||||
@ -1432,7 +1432,7 @@ button.danger {
|
||||
.role-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
margin: 4px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.busy-accounts {
|
||||
@ -1443,15 +1443,18 @@ button.danger {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.account-header {
|
||||
@ -1473,7 +1476,7 @@ button.danger {
|
||||
.account-meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.status {
|
||||
@ -1500,8 +1503,18 @@ button.danger {
|
||||
.account-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
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 {
|
||||
@ -1538,6 +1551,11 @@ button.danger {
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.account-row.free .account-actions,
|
||||
.account-row.busy .account-actions {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.role-toggle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -1932,6 +1950,8 @@ label .hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
@ -1947,6 +1967,8 @@ label .hint {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-users,
|
||||
@ -1954,12 +1976,14 @@ label .hint {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
white-space: pre-line;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-result-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
@ -1988,6 +2012,17 @@ label .hint {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.queue-row-warn {
|
||||
background: #fff8ed;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.queue-row-issue {
|
||||
color: #b45309;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
|
||||
@ -148,8 +148,10 @@ function AccountsTab({
|
||||
<div>
|
||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
<div className={`account-status-pill ${selected ? "busy" : "free"}`}>
|
||||
{selected ? "В задаче" : "Свободен"}
|
||||
</div>
|
||||
</div>
|
||||
{filterFreeAccounts && <div className="account-status-pill free">Свободен</div>}
|
||||
</div>
|
||||
<div className="role-badges">
|
||||
{roles.monitor && <span className="role-pill">Мониторинг</span>}
|
||||
@ -159,27 +161,27 @@ function AccountsTab({
|
||||
</div>
|
||||
<div className="account-meta">User ID: {account.user_id || "—"}</div>
|
||||
<div className="account-meta membership-row compact">
|
||||
<strong>Участие:</strong>
|
||||
<span>{competitorInfo}</span>
|
||||
<span>·</span>
|
||||
<span>{ourInfo}</span>
|
||||
<strong>{competitorInfo}</strong>
|
||||
{membership && (
|
||||
<>
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
|
||||
>
|
||||
Список
|
||||
</button>
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
|
||||
>
|
||||
Список
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta membership-row compact">
|
||||
<strong>{ourInfo}</strong>
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasSelectedTask && rolesMode !== "auto" && (
|
||||
@ -330,17 +332,20 @@ function AccountsTab({
|
||||
|
||||
return (
|
||||
<div key={account.id} className="account-row busy">
|
||||
<div>
|
||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
<div className="account-status-pill busy">Занят</div>
|
||||
<div className="account-main">
|
||||
<div className="account-header">
|
||||
<div>
|
||||
<div className="account-phone">{formatAccountLabel(account)}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
<div className="account-status-pill busy">Занят</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="role-badges">
|
||||
{roles.monitor && <span className="role-pill">Мониторинг</span>}
|
||||
{roles.invite && <span className="role-pill">Инвайт</span>}
|
||||
{roles.confirm && <span className="role-pill">Подтверждение</span>}
|
||||
</div>
|
||||
<div className="account-meta">User ID: {account.user_id || "—"}</div>
|
||||
<div className="account-meta status-caption">Группы для выбранной задачи</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{competitorInfo}</strong>
|
||||
{membership && (
|
||||
@ -365,17 +370,20 @@ function AccountsTab({
|
||||
</button>
|
||||
)}
|
||||
</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">
|
||||
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
|
||||
</div>
|
||||
<div className="account-meta">Работает в задачах: {assignedTasks.length ? taskNames : "—"}</div>
|
||||
{cooldownActive && (
|
||||
<details className="account-details">
|
||||
<summary>Детали и лимиты</summary>
|
||||
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
|
||||
<div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
|
||||
<div className="account-meta">
|
||||
Таймер FLOOD: {cooldownMinutes} мин
|
||||
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
|
||||
</div>
|
||||
)}
|
||||
<div className="account-meta">Работает в задачах: {assignedTasks.length ? taskNames : "—"}</div>
|
||||
{cooldownActive && (
|
||||
<div className="account-meta">
|
||||
Таймер FLOOD: {cooldownMinutes} мин
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
{account.status !== "ok" && account.last_error && (
|
||||
<div className="account-error">{account.last_error}</div>
|
||||
|
||||
@ -40,11 +40,11 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
case "monitor_polling_started":
|
||||
return `Мониторинг: старт опроса.${firstLine ? ` ${firstLine}` : ""}`;
|
||||
case "new_message":
|
||||
return `Новый пользователь добавлен в очередь.${firstLine ? ` ${firstLine}` : ""}`;
|
||||
return `Добавлен в очередь на инвайт.${firstLine ? ` ${firstLine}` : ""}`;
|
||||
case "new_message_duplicate":
|
||||
return firstLine
|
||||
? `Пользователь уже был обработан: ${firstLine}`
|
||||
: "Пользователь уже был обработан (повтор не выполнялся).";
|
||||
? `Повторный автор сообщения пропущен (уже обрабатывался): ${firstLine}`
|
||||
: "Повторный автор сообщения пропущен (уже обрабатывался).";
|
||||
case "invite_attempt":
|
||||
return `Попытка отправить инвайт.${firstLine ? ` ${formatLineWithUsername(firstLine)}` : ""}`;
|
||||
case "invite_sent":
|
||||
@ -58,7 +58,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
case "confirm_ok":
|
||||
return `Участие подтверждено.${userLabel ? ` ${userLabel}` : ""}`;
|
||||
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":
|
||||
return `Повторная проверка запланирована.${firstLine ? ` ${firstLine}` : ""}`;
|
||||
case "confirm_retry_ok":
|
||||
@ -78,10 +78,27 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
case "preset_applied":
|
||||
return `Применен пресет настроек.${firstLine ? ` ${firstLine}` : ""}`;
|
||||
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 types = new Set(accountEvents.map((event) => event.eventType));
|
||||
return ["all", ...Array.from(types)];
|
||||
@ -135,7 +152,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
<div key={event.id} className="log-row">
|
||||
<div className="log-time">
|
||||
<div>{formatTimestamp(event.createdAt)}</div>
|
||||
<div>{event.eventType}</div>
|
||||
<div>Тип: {event.eventType}</div>
|
||||
</div>
|
||||
<div className="log-details">
|
||||
<div>Аккаунт: {(() => {
|
||||
@ -143,6 +160,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
|
||||
return account ? formatAccountLabel(account) : (event.phone || event.accountId);
|
||||
})()}</div>
|
||||
<div className="log-users wrap">{buildEventSummary(event)}</div>
|
||||
{buildEventWhy(event) && <div className="log-errors">{buildEventWhy(event)}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
|
||||
@ -84,13 +84,13 @@ function LogsTab({
|
||||
const strategyLabel = (strategy) => {
|
||||
switch (strategy) {
|
||||
case "access_hash":
|
||||
return "access_hash (из сообщения)";
|
||||
return "access hash (из сообщения)";
|
||||
case "participants":
|
||||
return "участники группы";
|
||||
case "username":
|
||||
return "username";
|
||||
return "username (никнейм)";
|
||||
case "entity":
|
||||
return "getEntity(userId)";
|
||||
return "entity по ID";
|
||||
case "retry":
|
||||
return "повторная попытка";
|
||||
default:
|
||||
@ -104,7 +104,7 @@ function LogsTab({
|
||||
const parsed = JSON.parse(meta);
|
||||
if (!Array.isArray(parsed)) return meta;
|
||||
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}`)
|
||||
.join("\n");
|
||||
} catch (error) {
|
||||
@ -165,6 +165,17 @@ function LogsTab({
|
||||
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 userLabel = invite.username
|
||||
? `@${invite.username}${invite.userId ? ` (ID: ${invite.userId})` : ""}`
|
||||
@ -299,7 +310,7 @@ function LogsTab({
|
||||
className={`tab ${logsTab === "fallback" ? "active" : ""}`}
|
||||
onClick={() => setLogsTab("fallback")}
|
||||
>
|
||||
Fallback
|
||||
Проблемные
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -530,6 +541,15 @@ function LogsTab({
|
||||
>
|
||||
{expandedInviteId === invite.id ? "Скрыть детали" : "Подробнее"}
|
||||
</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 && (
|
||||
<div className="invite-details">
|
||||
<div>ID: {invite.userId}</div>
|
||||
@ -590,9 +610,6 @@ function LogsTab({
|
||||
<div>Статус: {formatInviteStatus(invite.status)}</div>
|
||||
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
|
||||
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
|
||||
{suggestAction(invite) && (
|
||||
<div>Совет: {suggestAction(invite).replace(/^Совет:\s*/, "")}</div>
|
||||
)}
|
||||
{invite.confirmError && invite.confirmError.includes("(") && (
|
||||
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
|
||||
)}
|
||||
|
||||
@ -2,6 +2,8 @@ import React from "react";
|
||||
|
||||
export default function QueueTab({
|
||||
hasSelectedTask,
|
||||
selectedTaskId,
|
||||
refreshQueue,
|
||||
queueStats,
|
||||
queueSearch,
|
||||
setQueueSearch,
|
||||
@ -13,11 +15,73 @@ export default function QueueTab({
|
||||
formatAccountLabel,
|
||||
formatTimestamp
|
||||
}) {
|
||||
const [queueFilter, setQueueFilter] = React.useState("all");
|
||||
const formatUserWithUsername = (item) => {
|
||||
const id = item.user_id != null ? String(item.user_id) : "—";
|
||||
const username = item.username ? String(item.username).replace(/^@/, "") : "";
|
||||
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) {
|
||||
return (
|
||||
@ -65,8 +129,39 @@ export default function QueueTab({
|
||||
</button>
|
||||
</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">
|
||||
В очереди: {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 className="log-table">
|
||||
<div className="log-head">
|
||||
@ -76,19 +171,23 @@ export default function QueueTab({
|
||||
<span>Попытки</span>
|
||||
<span>Добавлен</span>
|
||||
</div>
|
||||
{pagedQueue.length === 0 && (
|
||||
{filteredQueue.length === 0 && (
|
||||
<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 watcherLabel = watcher ? formatAccountLabel(watcher) : (item.watcher_account_id || "—");
|
||||
const issue = getRowIssue(item);
|
||||
return (
|
||||
<div className="log-row" key={item.id}>
|
||||
<div className={`log-row ${issue ? "queue-row-warn" : ""}`} key={item.id}>
|
||||
<div>{formatUserWithUsername(item)}</div>
|
||||
<div>{item.source_chat || "—"}</div>
|
||||
<div>{watcherLabel}</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -4,7 +4,7 @@ export const explainInviteError = (error) => {
|
||||
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
|
||||
}
|
||||
if (error === "CHAT_WRITE_FORBIDDEN") {
|
||||
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
|
||||
return "Инвайтер не может приглашать: обычно он не состоит в целевой группе (или заявка еще не одобрена), либо у него нет права добавлять участников.";
|
||||
}
|
||||
if (error === "USER_NOT_MUTUAL_CONTACT") {
|
||||
return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы.";
|
||||
|
||||
12
src/renderer/utils/presetLabels.js
Normal file
12
src/renderer/utils/presetLabels.js
Normal 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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user