1003 lines
55 KiB
JavaScript
1003 lines
55 KiB
JavaScript
import React from "react";
|
||
import HelpTip from "./HelpTip.jsx";
|
||
import { getPresetLabel } from "../utils/presetLabels.js";
|
||
|
||
export default function TaskSettingsTab({
|
||
selectedTaskName,
|
||
taskForm,
|
||
setTaskForm,
|
||
activePreset,
|
||
setActivePreset,
|
||
applyTaskPreset,
|
||
formatAccountLabel,
|
||
accountById,
|
||
competitorText,
|
||
setCompetitorText,
|
||
roleMode,
|
||
applyRoleMode,
|
||
normalizeIntervals,
|
||
taskStatus,
|
||
perAccountInviteSum,
|
||
hasSelectedTask,
|
||
inviteAccessStatus,
|
||
inviteAccessCheckedAt,
|
||
confirmAccessStatus,
|
||
confirmAccessCheckedAt,
|
||
formatTimestamp,
|
||
checkInviteAccess,
|
||
checkConfirmAccess,
|
||
accounts,
|
||
showNotification,
|
||
copyToClipboard,
|
||
sanitizeTaskForm,
|
||
hasPerAccountInviteLimits,
|
||
fileImportResult,
|
||
importInviteFile,
|
||
fileImportForm,
|
||
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");
|
||
}
|
||
};
|
||
|
||
const inviteChecks = Array.isArray(inviteAccessStatus) ? inviteAccessStatus : [];
|
||
const inviteChecksById = React.useMemo(() => {
|
||
const map = new Map();
|
||
inviteChecks.forEach((item) => {
|
||
map.set(Number(item.accountId), item);
|
||
});
|
||
return map;
|
||
}, [inviteChecks]);
|
||
const masterId = Number(taskForm.inviteAdminMasterId || 0);
|
||
const masterAccount = masterId ? (accountById.get(masterId) || accounts.find((item) => Number(item.id) === masterId)) : null;
|
||
const masterCheck = masterId ? inviteChecksById.get(masterId) : null;
|
||
const checkedCount = inviteChecks.length;
|
||
const canInviteCount = inviteChecks.filter((item) => item && item.canInvite).length;
|
||
const confirmChecks = Array.isArray(confirmAccessStatus) ? confirmAccessStatus : [];
|
||
const confirmCheckedCount = confirmChecks.length;
|
||
const confirmOkCount = confirmChecks.filter((item) => item && item.ok).length;
|
||
const diagnostics = [
|
||
{
|
||
title: "Режим",
|
||
value: taskForm.inviteViaAdmins ? "включен" : "выключен",
|
||
tone: taskForm.inviteViaAdmins ? "ok" : "warn"
|
||
},
|
||
{
|
||
title: "Мастер-админ",
|
||
value: masterId ? formatAccountLabel(masterAccount || { id: masterId, phone: `ID ${masterId}` }) : "не выбран",
|
||
tone: masterId ? "ok" : "fail"
|
||
},
|
||
{
|
||
title: "Проверка прав",
|
||
value: checkedCount ? `выполнена (${checkedCount} аккаунтов)` : "не выполнялась",
|
||
tone: checkedCount ? "ok" : "warn"
|
||
},
|
||
{
|
||
title: "Права мастера",
|
||
value: !masterId
|
||
? "нельзя проверить без выбора мастера"
|
||
: !checkedCount
|
||
? "нет данных (нажмите «Проверить права»)"
|
||
: masterCheck
|
||
? (masterCheck.canInvite ? "OK: может приглашать" : `ошибка: ${masterCheck.reason || "нет права приглашать"}`)
|
||
: "мастер не вошел в результаты проверки",
|
||
tone: !masterId ? "warn" : !checkedCount ? "warn" : (masterCheck && masterCheck.canInvite ? "ok" : "fail")
|
||
},
|
||
{
|
||
title: "Инвайтеры с правом",
|
||
value: checkedCount ? `${canInviteCount}/${checkedCount}` : "—",
|
||
tone: !checkedCount ? "warn" : canInviteCount > 0 ? "ok" : "fail"
|
||
}
|
||
];
|
||
|
||
return (
|
||
<div className="task-columns">
|
||
<details className="card collapsible task-editor" open>
|
||
<summary>
|
||
<div className="row-header">
|
||
<h3>Настройки задачи</h3>
|
||
<div className="status-caption">Для: {selectedTaskName}</div>
|
||
</div>
|
||
</summary>
|
||
<div className="section-title">Основное</div>
|
||
<div className="row-inline">
|
||
<div className="status-caption">Пресеты:</div>
|
||
<button
|
||
className={`secondary ${activePreset === "admin" ? "active" : ""}`}
|
||
type="button"
|
||
onClick={() => confirmApply(taskForm, "Автораспределение + Инвайт через админа", () => applyTaskPreset("admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||
>
|
||
Автораспределение + Инвайт через админа
|
||
</button>
|
||
<button
|
||
className={`secondary ${activePreset === "no_admin" ? "active" : ""}`}
|
||
type="button"
|
||
onClick={() => confirmApply(taskForm, "Автораспределение + Без админки", () => applyTaskPreset("no_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||
>
|
||
Автораспределение + Без админки
|
||
</button>
|
||
<button
|
||
className={`secondary ${activePreset === "soft_50" ? "active" : ""}`}
|
||
type="button"
|
||
onClick={() => confirmApply(taskForm, "Мягкий 50/день (5 инвайтеров)", () => applyTaskPreset("soft_50"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
|
||
>
|
||
Мягкий 50/день (5 инвайтеров)
|
||
</button>
|
||
<button
|
||
className={`secondary ${activePreset === "soft_25" ? "active" : ""}`}
|
||
type="button"
|
||
onClick={() => confirmApply(taskForm, "Мягкий 25/день (2 инвайтера)", () => applyTaskPreset("soft_25"), ["Роли ботов и вступление", "Интервалы и лимиты"])}
|
||
>
|
||
Мягкий 25/день (2 инвайтера)
|
||
</button>
|
||
<button
|
||
className={`secondary ${activePreset === "soft_50_admin" ? "active" : ""}`}
|
||
type="button"
|
||
onClick={() => confirmApply(taskForm, "Мягкий 50/день + админы", () => applyTaskPreset("soft_50_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||
>
|
||
Мягкий 50/день + админы
|
||
</button>
|
||
<button
|
||
className={`secondary ${activePreset === "soft_25_admin" ? "active" : ""}`}
|
||
type="button"
|
||
onClick={() => confirmApply(taskForm, "Мягкий 25/день + админы", () => applyTaskPreset("soft_25_admin"), ["Роли ботов и вступление", "Инвайт через админов", "Интервалы и лимиты"])}
|
||
>
|
||
Мягкий 25/день + админы
|
||
</button>
|
||
<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>
|
||
<span className="label-line">Название задачи <span className="required">*</span></span>
|
||
<input
|
||
type="text"
|
||
value={taskForm.name}
|
||
onChange={(event) => setTaskForm({ ...taskForm, name: event.target.value })}
|
||
placeholder="Например, Таиланд"
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">Наша группа <span className="required">*</span></span>
|
||
<input
|
||
type="text"
|
||
value={taskForm.ourGroup}
|
||
onChange={(event) => setTaskForm({ ...taskForm, ourGroup: event.target.value })}
|
||
placeholder="https://t.me/..."
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span className="label-line">Группы конкурентов <span className="required">*</span></span>
|
||
<textarea
|
||
rows="6"
|
||
value={competitorText}
|
||
onChange={(event) => setCompetitorText(event.target.value)}
|
||
placeholder="Каждая группа с новой строки"
|
||
/>
|
||
</label>
|
||
<div className="task-editor-grid">
|
||
<details className="section" open>
|
||
<summary className="section-title">Базовые настройки</summary>
|
||
<details className="section" open>
|
||
<summary className="section-title">Роли ботов и вступление</summary>
|
||
<div className="toggle-row">
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.autoJoinCompetitors)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, autoJoinCompetitors: event.target.checked })}
|
||
/>
|
||
Автодобавление аккаунтов в группы конкурентов
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.autoJoinOurGroup)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, autoJoinOurGroup: event.target.checked })}
|
||
/>
|
||
Автодобавление аккаунтов в нашу группу
|
||
</label>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<label className="checkbox">
|
||
<input
|
||
type="radio"
|
||
name="roleMode"
|
||
checked={roleMode === "split"}
|
||
onChange={() => applyRoleMode("split")}
|
||
/>
|
||
Разделить роли (конкуренты и наша группа разными аккаунтами)
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="radio"
|
||
name="roleMode"
|
||
checked={roleMode === "same"}
|
||
onChange={() => applyRoleMode("same")}
|
||
/>
|
||
Один и тот же бот в конкурентах и нашей группе
|
||
</label>
|
||
</div>
|
||
<div className="status-text compact">
|
||
Режим “один и тот же бот” нужен, когда аккаунт должен быть в конкурентах и в нашей группе для инвайта.
|
||
</div>
|
||
</details>
|
||
<details className="section" open>
|
||
<summary className="section-title">Интервалы и лимиты</summary>
|
||
<div className="row">
|
||
<label>
|
||
<span className="label-line">
|
||
Мин. интервал (мин) <span className="required">*</span>
|
||
<HelpTip text="Минимальная пауза между циклами приглашений. Фактический интервал выбирается случайно между мин/макс." />
|
||
</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.minIntervalMinutes || ""}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, minIntervalMinutes: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">
|
||
Макс. интервал (мин) <span className="required">*</span>
|
||
<HelpTip text="Максимальная пауза между циклами приглашений." />
|
||
</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.maxIntervalMinutes || ""}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, maxIntervalMinutes: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">
|
||
Лимит в день <span className="required">*</span>
|
||
<HelpTip text="Сколько успешных инвайтов разрешено в сутки. Итоговый лимит = min(дневной лимит, разогрев)." />
|
||
</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.dailyLimit === "" ? "" : taskForm.dailyLimit}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, dailyLimit: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.dailyLimit);
|
||
setTaskForm({ ...taskForm, dailyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
/>
|
||
<span className="hint">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</span>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">
|
||
История сообщений (шт) <span className="required">*</span>
|
||
<HelpTip text="Сколько последних сообщений анализировать при сборе истории." />
|
||
</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.historyLimit === 0 ? "" : taskForm.historyLimit}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, historyLimit: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.historyLimit);
|
||
setTaskForm({ ...taskForm, historyLimit: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
/>
|
||
</label>
|
||
<div className="limit-block">
|
||
<label>
|
||
<span className="label-line">
|
||
Инвайтов за цикл <span className="required">*</span>
|
||
<HelpTip text="Максимум приглашений за один цикл. Распределяется между инвайтерами по их лимитам." />
|
||
</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.maxInvitesPerCycle === "" ? "" : taskForm.maxInvitesPerCycle}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, maxInvitesPerCycle: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.maxInvitesPerCycle);
|
||
setTaskForm({ ...taskForm, maxInvitesPerCycle: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
/>
|
||
<span className="hint">Это общий потолок на цикл. Пер‑аккаунтные лимиты распределяют инвайты внутри этого числа.</span>
|
||
</label>
|
||
<div className="hint">
|
||
Итоговая формула: <strong>фактический лимит сегодня</strong> = min(дневной лимит задачи, разогрев).
|
||
Внутри цикла инвайты распределяются по аккаунтам согласно их лимитам.
|
||
</div>
|
||
<div className="status-caption">Фактический лимит сегодня: {taskStatus.dailyLimit || "—"}</div>
|
||
<div className="status-caption">Сумма лимитов по аккаунтам: {perAccountInviteSum || "—"}</div>
|
||
</div>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.warmupEnabled)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, warmupEnabled: event.target.checked })}
|
||
/>
|
||
Разогрев лимита
|
||
<HelpTip text="Плавно увеличивает дневной лимит по дням. Итоговый лимит не превышает дневной лимит задачи." />
|
||
<span
|
||
className="hint"
|
||
title="График: дни 1–3 — 1/д; 4–7 — 2/д; 8–12 — 3/д; 13–18 — 4/д; 19–25 — 5/д; 26–33 — 6/д; с 34-го — 7/д. Итоговый лимит не превышает дневной лимит задачи."
|
||
>
|
||
Плавно увеличивает дневной лимит по дням.
|
||
</span>
|
||
<span className="hint">Нужен для “прогрева” новых аккаунтов и снижения риска флуда.</span>
|
||
<span className="hint">График: 1/д ×3, 2/д ×4, 3/д ×5, 4/д ×6, 5/д ×7, 6/д ×8, далее 7/д.</span>
|
||
</label>
|
||
{taskForm.warmupEnabled && (
|
||
<>
|
||
<label>
|
||
<span className="label-line">Стартовый лимит</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.warmupStartLimit === "" ? "" : taskForm.warmupStartLimit}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, warmupStartLimit: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.warmupStartLimit);
|
||
setTaskForm({ ...taskForm, warmupStartLimit: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
/>
|
||
</label>
|
||
<div className="hint">
|
||
Стартовый лимит применяется в первый день. Далее лимит увеличивается по графику прогрева: итог = Стартовый лимит + (ступень прогрева − 1), но не выше дневного лимита задачи.
|
||
</div>
|
||
<label>
|
||
<span className="label-line">Прирост в день</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={taskForm.warmupDailyIncrease === "" ? "" : taskForm.warmupDailyIncrease}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, warmupDailyIncrease: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.warmupDailyIncrease);
|
||
setTaskForm({ ...taskForm, warmupDailyIncrease: Number.isFinite(value) && value >= 0 ? value : 0 });
|
||
}}
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
</div>
|
||
</details>
|
||
</details>
|
||
<details className="section">
|
||
<summary className="section-title">Расширенные настройки</summary>
|
||
<details className="section">
|
||
<summary className="section-title">Инвайт через админов</summary>
|
||
<div className="status-text compact">
|
||
Шаги: 1) Включить режим 2) Выбрать мастер‑админа 3) Проверить права
|
||
</div>
|
||
<div className="admin-invite-grid">
|
||
<label className="checkbox admin-invite-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.inviteViaAdmins)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, inviteViaAdmins: event.target.checked })}
|
||
/>
|
||
Инвайтить через админов
|
||
<HelpTip text="Мастер‑админ временно выдаёт право приглашать инвайтеру, затем снимает." />
|
||
<span className="hint">
|
||
Временно выдаём инвайтеру право “Приглашать”, затем снимаем права.
|
||
</span>
|
||
</label>
|
||
<div className="admin-invite-actions">
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
onClick={() => checkInviteAccess("admin_block")}
|
||
disabled={!hasSelectedTask}
|
||
>
|
||
Проверить права
|
||
</button>
|
||
<span className={`status-pill ${inviteAccessStatus && inviteAccessStatus.length ? "ok" : "off"}`}>
|
||
{inviteAccessStatus && inviteAccessStatus.length
|
||
? `Права проверены${inviteAccessCheckedAt ? ` (${formatTimestamp(inviteAccessCheckedAt)})` : ""}${
|
||
inviteAccessStatus.every((item) => item.canInvite) ? " · OK" : " · есть ошибки"
|
||
}`
|
||
: "Нет проверки"}
|
||
</span>
|
||
</div>
|
||
{taskForm.inviteViaAdmins && !taskForm.inviteAdminMasterId && (
|
||
<div className="notice inline warn">
|
||
Не выбран мастер‑админ. Инвайт через админов работать не будет.
|
||
</div>
|
||
)}
|
||
<label className="admin-invite-master">
|
||
<span className="label-line">
|
||
Главный аккаунт
|
||
<HelpTip text="Мастер‑админ: должен быть в целевой группе и иметь право выдавать админов." />
|
||
</span>
|
||
<div className="input-row">
|
||
<select
|
||
value={taskForm.inviteAdminMasterId || ""}
|
||
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminMasterId: Number(event.target.value) || 0 })}
|
||
disabled={!taskForm.inviteViaAdmins}
|
||
>
|
||
<option value="">Не выбран</option>
|
||
{accounts.map((account) => (
|
||
<option key={`master-${account.id}`} value={account.id}>
|
||
{formatAccountLabel(account)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
className="secondary"
|
||
disabled={!taskForm.inviteViaAdmins || !taskForm.inviteAdminMasterId}
|
||
onClick={async () => {
|
||
const account = accountById.get(taskForm.inviteAdminMasterId);
|
||
const username = account && account.username ? `@${account.username}` : "";
|
||
if (!username) {
|
||
showNotification("У выбранного аккаунта нет username.", "error");
|
||
return;
|
||
}
|
||
const ok = await copyToClipboard(username);
|
||
showNotification(ok ? `Скопировано: ${username}` : "Не удалось скопировать.", ok ? "success" : "error");
|
||
}}
|
||
>
|
||
Копировать username
|
||
</button>
|
||
</div>
|
||
<span className="hint">
|
||
Этот аккаунт должен быть админом в целевой группе и уметь выдавать права другим.
|
||
</span>
|
||
</label>
|
||
<label className="checkbox admin-invite-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.inviteAdminAnonymous)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAnonymous: event.target.checked })}
|
||
disabled={!taskForm.inviteViaAdmins}
|
||
/>
|
||
Делать админов анонимными
|
||
<HelpTip text="Выдаём права инвайтерам с анонимностью и минимальными правами." />
|
||
<span className="hint">
|
||
Мастер-админ назначает остальных админами с анонимностью и минимальными правами.
|
||
</span>
|
||
</label>
|
||
<label className="checkbox admin-invite-flood">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.inviteAdminAllowFlood)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, inviteAdminAllowFlood: event.target.checked })}
|
||
disabled={!taskForm.inviteViaAdmins}
|
||
/>
|
||
Инвайтить в чаты с флудом
|
||
<HelpTip text="Пробуем административный путь, если Telegram ограничивает инвайт." />
|
||
<span className="hint">
|
||
Использует выдачу прав между аккаунтами, если Telegram ограничивает инвайтинг.
|
||
</span>
|
||
</label>
|
||
<div className="admin-diagnostics" role="status" aria-live="polite">
|
||
<div className="admin-diagnostics-title">Диагностика мастер-админа</div>
|
||
<div className="admin-diagnostics-list">
|
||
{diagnostics.map((item) => (
|
||
<div key={item.title} className={`admin-diagnostics-item ${item.tone}`}>
|
||
<span className="name">{item.title}</span>
|
||
<span className="value">{item.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="hint">
|
||
Если есть ошибки: проверьте, что мастер-админ в целевой группе, у него есть право выдачи админов, а инвайтеры состоят в целевой группе.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details className="section">
|
||
<summary className="section-title">Распределение ботов</summary>
|
||
<div className="row">
|
||
{roleMode === "same" ? (
|
||
<label>
|
||
<span className="label-line">Ботов в обеих группах</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
disabled={taskForm.rolesMode === "manual"}
|
||
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
if (value === "") {
|
||
setTaskForm({ ...taskForm, maxCompetitorBots: "", maxOurBots: "" });
|
||
return;
|
||
}
|
||
const nextValue = Number(value);
|
||
const nextForm = { ...taskForm, maxCompetitorBots: nextValue, maxOurBots: nextValue };
|
||
setTaskForm(sanitizeTaskForm(nextForm));
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.maxCompetitorBots);
|
||
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
|
||
setTaskForm(sanitizeTaskForm({ ...taskForm, maxCompetitorBots: normalized, maxOurBots: normalized }));
|
||
}}
|
||
/>
|
||
<span className="hint">
|
||
Одинаковое количество для конкурентов и нашей группы.
|
||
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
|
||
</span>
|
||
</label>
|
||
) : (
|
||
<>
|
||
<label>
|
||
<span className="label-line">Ботов в конкурентах</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
disabled={taskForm.rolesMode === "manual"}
|
||
value={taskForm.maxCompetitorBots === "" ? "" : taskForm.maxCompetitorBots}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({
|
||
...taskForm,
|
||
maxCompetitorBots: value === "" ? "" : Number(value)
|
||
});
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.maxCompetitorBots);
|
||
setTaskForm({ ...taskForm, maxCompetitorBots: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
/>
|
||
<span className="hint">
|
||
Используется для авто‑распределения ролей мониторинга и авто‑вступления в группы конкурентов.
|
||
В ручном режиме ролей не ограничивает мониторинг.
|
||
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
|
||
</span>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">Ботов в нашей группе</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
disabled={taskForm.rolesMode === "manual"}
|
||
value={taskForm.maxOurBots === "" ? "" : taskForm.maxOurBots}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, maxOurBots: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.maxOurBots);
|
||
setTaskForm({ ...taskForm, maxOurBots: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
/>
|
||
<span className="hint">
|
||
Используется для авто‑распределения инвайтеров и авто‑вступления в нашу группу.
|
||
В ручном режиме ролей инвайт идёт по отмеченным аккаунтам.
|
||
{taskForm.rolesMode === "manual" ? " В ручном режиме не используется." : ""}
|
||
</span>
|
||
</label>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="row">
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.separateConfirmRoles)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, separateConfirmRoles: event.target.checked })}
|
||
disabled={!taskForm.separateBotRoles}
|
||
/>
|
||
Подтверждение отдельными аккаунтами
|
||
<span className="hint">
|
||
Если включено, проверку участия выполняют отдельные аккаунты, не совпадающие с инвайтерами.
|
||
Ручные чекбоксы ролей в разделе “Аккаунты” имеют приоритет над авто‑распределением.
|
||
При включении нельзя совмещать роли “Инвайт” и “Подтверждение” у одного бота.
|
||
</span>
|
||
{taskForm.separateConfirmRoles && (
|
||
<div className="status-caption">
|
||
Проверка подтверждения: {confirmCheckedCount ? `OK ${confirmOkCount}/${confirmCheckedCount}` : "не выполнялась"}
|
||
{confirmAccessCheckedAt ? ` (${formatTimestamp(confirmAccessCheckedAt)})` : ""}
|
||
<button
|
||
type="button"
|
||
className="ghost tiny"
|
||
onClick={() => checkConfirmAccess("editor")}
|
||
>
|
||
Проверить
|
||
</button>
|
||
</div>
|
||
)}
|
||
</label>
|
||
<label>
|
||
<span className="label-line">Ботов для подтверждения</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.maxConfirmBots === "" ? "" : taskForm.maxConfirmBots}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, maxConfirmBots: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.maxConfirmBots);
|
||
const normalized = Number.isFinite(value) && value > 0 ? value : 1;
|
||
setTaskForm({ ...taskForm, maxConfirmBots: normalized });
|
||
}}
|
||
disabled={!taskForm.separateBotRoles || !taskForm.separateConfirmRoles}
|
||
/>
|
||
<span className="hint">Используется при авто-разделении ролей.</span>
|
||
</label>
|
||
</div>
|
||
</details>
|
||
</details>
|
||
<details className="section">
|
||
<summary className="section-title">Дополнительные настройки</summary>
|
||
<div className="status-text compact">Не трогайте, если не уверены.</div>
|
||
<div className="section-title">Безопасность</div>
|
||
<div className="toggle-row">
|
||
{!hasPerAccountInviteLimits && (
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.randomAccounts)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
|
||
/>
|
||
Случайный выбор аккаунтов
|
||
<span className="hint">Инвайты распределяются случайно между доступными аккаунтами.</span>
|
||
</label>
|
||
)}
|
||
{!hasPerAccountInviteLimits && (
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.multiAccountsPerRun)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
|
||
/>
|
||
Несколько аккаунтов за цикл
|
||
<span className="hint">Если выключено — в каждом цикле используется один аккаунт.</span>
|
||
</label>
|
||
)}
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.retryOnFail)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
|
||
/>
|
||
Повторять при ошибке
|
||
<HelpTip text="При неудаче делаем до 2 повторов (зависит от типа ошибки)." />
|
||
<span className="hint">Повторяем до 2 раз при неудачном инвайте.</span>
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.inviteLinkOnFail)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, inviteLinkOnFail: event.target.checked })}
|
||
/>
|
||
Отправлять ссылку при USER_NOT_MUTUAL_CONTACT
|
||
<HelpTip text="Если инвайт невозможен, отправляем пользователю ссылку на нашу группу." />
|
||
<span className="hint">
|
||
Отправляет пользователю ссылку из поля “Наша группа”.
|
||
</span>
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.stopOnBlocked)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
|
||
/>
|
||
Останавливать при блокировках
|
||
<HelpTip text="Автоматическая пауза, если процент заблокированных аккаунтов выше порога." />
|
||
<span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span>
|
||
</label>
|
||
</div>
|
||
<div className="toggle-row">
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.allowStartWithoutInviteRights)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, allowStartWithoutInviteRights: event.target.checked })}
|
||
/>
|
||
Разрешать запуск без прав инвайта
|
||
<HelpTip text="Полезно, если права инвайта будут выданы позже." />
|
||
<span className="hint">Полезно, если вы выдаёте админов после автодобавления.</span>
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.useWatcherInviteNoUsername)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, useWatcherInviteNoUsername: event.target.checked })}
|
||
/>
|
||
Инвайт через наблюдателя, если нет username
|
||
<HelpTip text="Если username отсутствует, инвайт идёт аккаунтом‑наблюдателем, потому что access_hash валиден в его сессии." />
|
||
<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>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={taskForm.stopBlockedPercent === "" ? "" : taskForm.stopBlockedPercent}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setTaskForm({ ...taskForm, stopBlockedPercent: value === "" ? "" : Number(value) });
|
||
}}
|
||
onBlur={() => {
|
||
const value = Number(taskForm.stopBlockedPercent);
|
||
setTaskForm({ ...taskForm, stopBlockedPercent: Number.isFinite(value) && value > 0 ? value : 1 });
|
||
}}
|
||
disabled={!taskForm.stopOnBlocked}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<details className="section">
|
||
<summary className="section-title">Импорт аудитории <span className="status-caption">необязательно</span></summary>
|
||
<div className="status-text compact">Используйте только если нужен импорт из файла или полный список участников.</div>
|
||
<div className="row-inline">
|
||
<button className="secondary" type="button" onClick={importInviteFile}>
|
||
Импортировать файл
|
||
</button>
|
||
{fileImportResult && (
|
||
<div className="status-text compact">
|
||
Импортировано: {fileImportResult.importedCount} · Пропущено: {fileImportResult.skippedCount} · Ошибок: {fileImportResult.failed.length}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<details className="section">
|
||
<summary className="section-title">Дополнительные параметры</summary>
|
||
<div className="row">
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.parseParticipants)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, parseParticipants: event.target.checked })}
|
||
/>
|
||
Собирать участников чатов конкурентов
|
||
<span className="hint">
|
||
Используется для закрытых участников и полного списка аудитории.
|
||
</span>
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(taskForm.cycleCompetitors)}
|
||
onChange={(event) => setTaskForm({ ...taskForm, cycleCompetitors: event.target.checked })}
|
||
/>
|
||
Циклически обходить конкурентов
|
||
<span className="hint">
|
||
Мониторинг и сбор будут переключаться по группам по очереди.
|
||
</span>
|
||
</label>
|
||
<label className="checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(fileImportForm.onlyIds)}
|
||
onChange={(event) => setFileImportForm({ ...fileImportForm, onlyIds: event.target.checked })}
|
||
/>
|
||
В файле только ID
|
||
<span className="hint">Если включено — нужен источник (чат), из которого брались ID.</span>
|
||
</label>
|
||
<label>
|
||
<span className="label-line">Источник для ID</span>
|
||
<input
|
||
type="text"
|
||
placeholder="https://t.me/чат"
|
||
value={fileImportForm.sourceChat}
|
||
onChange={(event) => setFileImportForm({ ...fileImportForm, sourceChat: event.target.value })}
|
||
disabled={!fileImportForm.onlyIds}
|
||
/>
|
||
<span className="hint">Используется для резолва ID при инвайте.</span>
|
||
</label>
|
||
</div>
|
||
</details>
|
||
{fileImportResult && fileImportResult.failed.length > 0 && (
|
||
<div className="access-list">
|
||
{fileImportResult.failed.map((item, index) => (
|
||
<div key={`${item.path}-${index}`} className="access-row fail">
|
||
<div className="access-title">{item.path}</div>
|
||
<div className="access-error">{item.error}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</details>
|
||
<details className="section">
|
||
<summary className="section-title">Ошибки аккаунтов</summary>
|
||
{criticalErrorAccounts.length === 0 && (
|
||
<div className="status-text compact">Ошибок нет.</div>
|
||
)}
|
||
{criticalErrorAccounts.length > 0 && (
|
||
<div className="access-list">
|
||
{criticalErrorAccounts.map((account) => (
|
||
<div key={account.id} className="access-row fail">
|
||
<div className="access-title">{formatAccountLabel(account)}</div>
|
||
<div className="access-error">{account.last_error || "Ошибка сессии"}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</details>
|
||
<label>
|
||
<span className="label-line">Заметки</span>
|
||
<textarea
|
||
rows="2"
|
||
value={taskForm.notes}
|
||
onChange={(event) => setTaskForm({ ...taskForm, notes: event.target.value })}
|
||
/>
|
||
</label>
|
||
</details>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
);
|
||
}
|