This commit is contained in:
Ivan Neplokhov 2026-03-03 23:24:33 +04:00
parent 51af20dd6f
commit e7df1dc63a
6 changed files with 121 additions and 441 deletions

View File

@ -1338,6 +1338,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
const readiness = { ok: true, reasons: [] }; const readiness = { ok: true, reasons: [] };
let restrictedAccounts = []; let restrictedAccounts = [];
let totalInvites = 0; let totalInvites = 0;
let totalInvitesSuccess = 0;
let totalInvitesAttempts = 0;
let taskInviteLimitTotal = 0; let taskInviteLimitTotal = 0;
let accountDailyLimitTotal = 0; let accountDailyLimitTotal = 0;
let inviteAccountsCount = 0; let inviteAccountsCount = 0;
@ -1385,11 +1387,13 @@ ipcMain.handle("tasks:status", (_event, id) => {
const account = accountsById.get(row.account_id); const account = accountsById.get(row.account_id);
return sum + Math.max(0, Number(account && account.daily_limit ? account.daily_limit : 0)); return sum + Math.max(0, Number(account && account.daily_limit ? account.daily_limit : 0));
}, 0); }, 0);
totalInvites = totalInvitesSuccess = Number(store.countInvitesByStatus(id, "success") || 0);
Number(store.countInvitesByStatus(id, "success") || 0) totalInvitesAttempts =
totalInvitesSuccess
+ Number(store.countInvitesByStatus(id, "failed") || 0) + Number(store.countInvitesByStatus(id, "failed") || 0)
+ Number(store.countInvitesByStatus(id, "skipped") || 0) + Number(store.countInvitesByStatus(id, "skipped") || 0)
+ Number(store.countInvitesByStatus(id, "unconfirmed") || 0); + Number(store.countInvitesByStatus(id, "unconfirmed") || 0);
totalInvites = totalInvitesAttempts;
const monitorRows = accountRows.filter((row) => row.role_monitor); const monitorRows = accountRows.filter((row) => row.role_monitor);
if (!inviteRows.length) { if (!inviteRows.length) {
const fallbackAvailable = accountRows const fallbackAvailable = accountRows
@ -1510,6 +1514,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
queueCount, queueCount,
dailyUsed, dailyUsed,
totalInvites, totalInvites,
totalInvitesSuccess,
totalInvitesAttempts,
unconfirmedCount, unconfirmedCount,
dailyLimit: effectiveLimit, dailyLimit: effectiveLimit,
taskDailyLimitBase: task ? Number(task.daily_limit || 0) : 0, taskDailyLimitBase: task ? Number(task.daily_limit || 0) : 0,

View File

@ -87,9 +87,11 @@ export default function TasksSidebar({
const dailyLabel = status const dailyLabel = status
? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` ? `Лимит сегодня: ${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}`
: "Лимит сегодня: —"; : "Лимит сегодня: —";
const totalLabel = status ? `Инвайтов всего: ${status.totalInvites || 0}` : "Инвайтов всего: —"; const totalSuccessLabel = status ? `Инвайтов всего: ${status.totalInvitesSuccess || 0}` : "Инвайтов всего: —";
const totalAttemptsLabel = status ? `Попыток всего: ${status.totalInvitesAttempts || 0}` : "Попыток всего: —";
const taskLimitLabel = status ? `Лимит задачи: ${status.dailyLimit || 0}` : "Лимит задачи: —"; const taskLimitLabel = status ? `Лимит задачи: ${status.dailyLimit || 0}` : "Лимит задачи: —";
const accountLimitLabel = status ? `Лимит аккаунтов: ${status.accountDailyLimitTotal || 0}` : "Лимит аккаунтов: —"; const accountCycleLimitLabel = status ? `Лимит аккаунтов (цикл): ${status.taskInviteLimitTotal || 0}` : "Лимит аккаунтов (цикл): —";
const accountDailyCapLabel = status ? `Потолок аккаунтов (день): ${status.accountDailyLimitTotal || 0}` : "Потолок аккаунтов (день): —";
const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —"; const cycleLabel = status && status.running ? `Цикл: ${formatCountdown(status.nextRunAt)}` : "Цикл: —";
const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt const lastMessageRaw = status && status.monitorInfo && status.monitorInfo.lastMessageAt
? status.monitorInfo.lastMessageAt ? status.monitorInfo.lastMessageAt
@ -115,9 +117,11 @@ export default function TasksSidebar({
`Статус: ${statusLabel}`, `Статус: ${statusLabel}`,
`Очередь: ${status ? status.queueCount : "—"}`, `Очередь: ${status ? status.queueCount : "—"}`,
`Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` : "—"}`, `Лимит сегодня: ${status ? `${status.dailyUsed}/${status.dailyLimit}${warmupDelta > 0 ? ` (старт ${warmupStart} +${warmupDelta} прогрев)` : ""}` : "—"}`,
`Инвайтов всего: ${status ? status.totalInvites || 0 : "—"}`, `Инвайтов всего (успешных): ${status ? status.totalInvitesSuccess || 0 : "—"}`,
`Попыток всего: ${status ? status.totalInvitesAttempts || 0 : "—"}`,
`Лимит задачи: ${status ? status.dailyLimit || 0 : "—"}`, `Лимит задачи: ${status ? status.dailyLimit || 0 : "—"}`,
`Лимит аккаунтов: ${status ? status.accountDailyLimitTotal || 0 : "—"}`, `Лимит аккаунтов (цикл): ${status ? status.taskInviteLimitTotal || 0 : "—"}`,
`Потолок аккаунтов (день): ${status ? status.accountDailyLimitTotal || 0 : "—"}`,
`Мониторинг: ${monitoring ? "активен" : "нет"}`, `Мониторинг: ${monitoring ? "активен" : "нет"}`,
`Мониторит: ${monitorLabel}`, `Мониторит: ${monitorLabel}`,
`Последнее: ${lastMessage}`, `Последнее: ${lastMessage}`,
@ -146,11 +150,15 @@ export default function TasksSidebar({
<div className="task-meta-row"> <div className="task-meta-row">
<span className="task-meta">{queueLabel}</span> <span className="task-meta">{queueLabel}</span>
<span className="task-meta">{dailyLabel}</span> <span className="task-meta">{dailyLabel}</span>
<span className="task-meta">{totalLabel}</span> <span className="task-meta">{totalSuccessLabel}</span>
</div> </div>
<div className="task-meta-row"> <div className="task-meta-row">
<span className="task-meta">{taskLimitLabel}</span> <span className="task-meta">{taskLimitLabel}</span>
<span className="task-meta">{accountLimitLabel}</span> <span className="task-meta">{accountCycleLimitLabel}</span>
<span className="task-meta">{accountDailyCapLabel}</span>
</div>
<div className="task-meta-row">
<span className="task-meta">{totalAttemptsLabel}</span>
<span className="task-meta">{cycleLabel}</span> <span className="task-meta">{cycleLabel}</span>
</div> </div>
</div> </div>

View File

@ -340,6 +340,12 @@ export default function useTabProps(
const eventsTabProps = { const eventsTabProps = {
accountEvents, accountEvents,
selectedTaskId,
selectedTask,
competitorLinks: String(competitorText || "")
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
formatTimestamp, formatTimestamp,
onClearEvents, onClearEvents,
accountById, accountById,

View File

@ -72,6 +72,17 @@ export default function useTaskStatusView({
const inviteAccessChecked = inviteAccessStatus && inviteAccessStatus.length > 0; const inviteAccessChecked = inviteAccessStatus && inviteAccessStatus.length > 0;
const inviteAccessOk = inviteAccessChecked && inviteAccessStatus.every((item) => item.canInvite); const inviteAccessOk = inviteAccessChecked && inviteAccessStatus.every((item) => item.canInvite);
const inviteAccessWarn = inviteAccessChecked && !inviteAccessOk; const inviteAccessWarn = inviteAccessChecked && !inviteAccessOk;
const inviteAccessById = new Map((inviteAccessStatus || []).map((item) => [Number(item.accountId), item]));
const resolveInviteMembership = (accountId) => {
const direct = membershipStatus && membershipStatus[accountId] ? membershipStatus[accountId] : null;
if (direct) return direct;
const access = inviteAccessById.get(Number(accountId));
if (!access) return null;
return {
ourGroupMember: access.member === true,
ourGroupPending: false
};
};
const masterAdminId = Number(selectedTask?.invite_admin_master_id || 0); const masterAdminId = Number(selectedTask?.invite_admin_master_id || 0);
const masterAccessRow = inviteAccessChecked const masterAccessRow = inviteAccessChecked
? (inviteAccessStatus || []).find((item) => Number(item.accountId) === masterAdminId) ? (inviteAccessStatus || []).find((item) => Number(item.accountId) === masterAdminId)
@ -121,7 +132,7 @@ export default function useTaskStatusView({
const account = accountById.get(accountId); const account = accountById.get(accountId);
const label = account ? formatAccountLabel(account) : String(accountId); const label = account ? formatAccountLabel(account) : String(accountId);
const row = inviteById.get(Number(accountId)); const row = inviteById.get(Number(accountId));
const member = membershipStatus && membershipStatus[accountId] ? membershipStatus[accountId] : null; const member = resolveInviteMembership(accountId);
const membershipText = !member const membershipText = !member
? "статус участия не проверен" ? "статус участия не проверен"
: member.ourGroupMember : member.ourGroupMember
@ -210,7 +221,7 @@ export default function useTaskStatusView({
); );
const inviteMembershipList = inviteAccountIds.map((id) => ({ const inviteMembershipList = inviteAccountIds.map((id) => ({
id, id,
status: membershipStatus ? membershipStatus[id] : null status: resolveInviteMembership(id)
})); }));
const inviteMembershipUnknown = inviteMembershipList.filter((item) => !item.status).length; const inviteMembershipUnknown = inviteMembershipList.filter((item) => !item.status).length;
const inviteMembershipPending = inviteMembershipList.filter((item) => item.status && item.status.ourGroupPending).length; const inviteMembershipPending = inviteMembershipList.filter((item) => item.status && item.status.ourGroupPending).length;

View File

@ -47,18 +47,6 @@ function AccountsTab({
const [healthFilter, setHealthFilter] = useState("all"); const [healthFilter, setHealthFilter] = useState("all");
const [proxyBusy, setProxyBusy] = useState(false); const [proxyBusy, setProxyBusy] = useState(false);
const [proxyNotice, setProxyNotice] = useState(null); const [proxyNotice, setProxyNotice] = useState(null);
const [proxyImportText, setProxyImportText] = useState("");
const [bulkProxyId, setBulkProxyId] = useState(0);
const [proxyForm, setProxyForm] = useState({
id: 0,
name: "",
protocol: "socks5",
host: "",
port: "1080",
username: "",
password: "",
enabled: true
});
const inviteAccessById = React.useMemo(() => { const inviteAccessById = React.useMemo(() => {
const map = new Map(); const map = new Map();
(inviteAccessStatus || []).forEach((item) => { (inviteAccessStatus || []).forEach((item) => {
@ -67,6 +55,24 @@ function AccountsTab({
}); });
return map; return map;
}, [inviteAccessStatus]); }, [inviteAccessStatus]);
const resolveMembership = React.useCallback((accountId) => {
const direct = membershipStatus && membershipStatus[accountId] ? membershipStatus[accountId] : null;
if (direct) return { value: direct, fromInviteAccess: false };
const access = inviteAccessById.get(Number(accountId));
if (!access) return { value: null, fromInviteAccess: false };
return {
value: {
competitorCount: 0,
competitorTotal: 0,
competitorGroups: [],
ourGroupMember: access.member === true,
ourGroupPending: false,
ourGroupPendingAt: "",
ourGroup: null
},
fromInviteAccess: true
};
}, [membershipStatus, inviteAccessById]);
const adminConfirmConfigRisk = React.useMemo( const adminConfirmConfigRisk = React.useMemo(
() => (typeof computeAdminConfirmConfigRisk === "function" ? computeAdminConfirmConfigRisk(taskAccountRoles) : null), () => (typeof computeAdminConfirmConfigRisk === "function" ? computeAdminConfirmConfigRisk(taskAccountRoles) : null),
[computeAdminConfirmConfigRisk, taskAccountRoles] [computeAdminConfirmConfigRisk, taskAccountRoles]
@ -107,18 +113,6 @@ function AccountsTab({
const visibleFreeOrSelected = (accountBuckets.freeOrSelected || []).filter(healthFilterMatch); const visibleFreeOrSelected = (accountBuckets.freeOrSelected || []).filter(healthFilterMatch);
const visibleBusy = (accountBuckets.busy || []).filter(healthFilterMatch); const visibleBusy = (accountBuckets.busy || []).filter(healthFilterMatch);
const authDupCount = (accounts || []).filter(isAuthKeyDuplicatedAccount).length; const authDupCount = (accounts || []).filter(isAuthKeyDuplicatedAccount).length;
const resetProxyForm = () => {
setProxyForm({
id: 0,
name: "",
protocol: "socks5",
host: "",
port: "1080",
username: "",
password: "",
enabled: true
});
};
const setProxyMessage = (text, tone = "info") => { const setProxyMessage = (text, tone = "info") => {
setProxyNotice({ text, tone, at: Date.now() }); setProxyNotice({ text, tone, at: Date.now() });
}; };
@ -130,61 +124,6 @@ function AccountsTab({
setProxyBusy(false); setProxyBusy(false);
} }
}; };
const onSaveProxy = async () => {
const host = String(proxyForm.host || "").trim();
const port = Number(proxyForm.port || 0);
if (!host || !port) {
setProxyMessage("Укажи host и port прокси.", "error");
return;
}
await runProxyAction(async () => {
const result = await saveProxy({
id: Number(proxyForm.id || 0),
name: proxyForm.name,
protocol: proxyForm.protocol,
host,
port,
username: proxyForm.username,
password: proxyForm.password,
enabled: Boolean(proxyForm.enabled)
});
if (result && result.ok) {
const testInfo = result.test
? (result.test.ok
? ` Проверка: OK${result.test.latencyMs ? ` (${result.test.latencyMs} ms)` : ""}.`
: ` Проверка: ${result.test.error || "ошибка"}.`)
: "";
setProxyMessage(`Прокси сохранен.${testInfo}`, "success");
resetProxyForm();
} else {
setProxyMessage((result && result.error) || "Не удалось сохранить прокси.", "error");
}
});
};
const onTestProxy = async (proxy) => {
await runProxyAction(async () => {
const result = await testProxy(proxy && proxy.id ? { id: proxy.id } : proxyForm);
if (result && result.ok) {
setProxyMessage(`Прокси доступен${result.latencyMs ? ` (${result.latencyMs} ms)` : ""}.`, "success");
} else {
setProxyMessage(`Проверка не прошла: ${(result && result.error) || "ошибка"}.`, "error");
}
});
};
const onDeleteProxy = async (proxy) => {
if (!proxy || !proxy.id) return;
await runProxyAction(async () => {
const result = await removeProxy(proxy.id);
if (result && result.ok) {
setProxyMessage("Прокси удален.", "success");
if (Number(proxyForm.id || 0) === Number(proxy.id)) {
resetProxyForm();
}
} else {
setProxyMessage((result && result.error) || "Не удалось удалить прокси.", "error");
}
});
};
const onAccountProxyChange = async (accountId, nextProxyId) => { const onAccountProxyChange = async (accountId, nextProxyId) => {
await runProxyAction(async () => { await runProxyAction(async () => {
try { try {
@ -195,358 +134,17 @@ function AccountsTab({
} }
}); });
}; };
const parseProxyLine = (line, defaultProtocol = "socks5") => {
const raw = String(line || "").trim();
if (!raw) return null;
try {
if (raw.includes("://")) {
const url = new URL(raw);
const protocol = String(url.protocol || "").replace(":", "").toLowerCase();
const host = (url.hostname || "").trim();
const port = Number(url.port || 0);
if (host && port) {
return {
protocol: protocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: decodeURIComponent(url.username || ""),
password: decodeURIComponent(url.password || "")
};
}
}
} catch (_error) {
// fallback below
}
const parts = raw.split(":").map((part) => part.trim());
if (parts.length < 2) return null;
const host = parts[0] || "";
const port = Number(parts[1] || 0);
if (!host || !port) return null;
if (parts.length >= 4) {
return {
protocol: defaultProtocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: parts[2] || "",
password: parts.slice(3).join(":") || ""
};
}
return {
protocol: defaultProtocol === "socks4" ? "socks4" : "socks5",
host,
port,
username: "",
password: ""
};
};
const onImportProxies = async () => {
const lines = String(proxyImportText || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) {
setProxyMessage("Вставь список прокси для импорта.", "error");
return;
}
let imported = 0;
let failed = 0;
await runProxyAction(async () => {
for (let i = 0; i < lines.length; i += 1) {
const parsed = parseProxyLine(lines[i], proxyForm.protocol || "socks5");
if (!parsed) {
failed += 1;
continue;
}
const payload = {
name: `import_${Date.now()}_${i + 1}`,
protocol: parsed.protocol,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
enabled: true
};
const result = await saveProxy(payload);
if (result && result.ok) imported += 1;
else failed += 1;
}
await reloadProxies();
});
setProxyMessage(`Импорт завершен: добавлено ${imported}, ошибок ${failed}.`, failed ? "warn" : "success");
if (imported > 0) {
setProxyImportText("");
}
};
const onImportProxiesFromFile = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt,.csv,text/plain,text/csv";
input.onchange = async () => {
const file = input.files && input.files[0] ? input.files[0] : null;
if (!file) return;
try {
const text = await file.text();
setProxyImportText(text || "");
setProxyMessage(`Файл загружен: ${file.name}. Нажми «Импортировать».`, "info");
} catch (error) {
setProxyMessage(`Не удалось прочитать файл: ${error.message || String(error)}`, "error");
}
};
input.click();
};
const onTestAllProxies = async () => {
const rows = Array.isArray(proxies) ? proxies : [];
if (!rows.length) {
setProxyMessage("Нет прокси для проверки.", "error");
return;
}
let okCount = 0;
let failCount = 0;
await runProxyAction(async () => {
for (const proxy of rows) {
const result = await testProxy({ id: proxy.id });
if (result && result.ok) okCount += 1;
else failCount += 1;
}
await reloadProxies();
});
setProxyMessage(`Проверка завершена: OK ${okCount}, ошибки ${failCount}.`, failCount ? "warn" : "success");
};
const onAutoDistributeProxies = async () => {
const enabledProxies = (proxies || []).filter((proxy) => proxy && proxy.enabled);
if (!enabledProxies.length) {
setProxyMessage("Нет активных прокси для распределения.", "error");
return;
}
const accountIds = (selectedAccountIds || []).map((id) => Number(id || 0)).filter((id) => id > 0);
if (!accountIds.length) {
setProxyMessage("Для распределения выбери аккаунты в задаче.", "error");
return;
}
const assignments = accountIds.map((accountId, index) => ({
accountId,
proxyId: Number(enabledProxies[index % enabledProxies.length].id || 0)
}));
await runProxyAction(async () => {
try {
const result = await setAccountsProxyMap(assignments);
setProxyMessage(
`Распределение выполнено: аккаунтов ${accountIds.length}, обновлено ${result.changed}, reconnect ok ${result.reconnected}, ошибок ${result.failed}.`,
result.failed ? "warn" : "success"
);
} catch (error) {
setProxyMessage(error.message || String(error), "error");
}
});
};
const onAssignBulkProxy = async () => {
const ids = (selectedAccountIds || []).map((id) => Number(id || 0)).filter((id) => id > 0);
if (!ids.length) {
setProxyMessage("Для массовой привязки выбери аккаунты в задаче.", "error");
return;
}
await runProxyAction(async () => {
try {
const result = await setAccountsProxyBulk(ids, Number(bulkProxyId || 0));
setProxyMessage(
`Массовая привязка выполнена: ${result.changed}/${ids.length}, reconnect ok ${result.reconnected}, ошибок ${result.failed}.`,
result.failed ? "warn" : "success"
);
} catch (error) {
setProxyMessage(error.message || String(error), "error");
}
});
};
return ( return (
<section className="card"> <section className="card">
<div className="row-header"> <div className="row-header">
<h3>Аккаунты</h3> <h3>Аккаунты</h3>
</div> </div>
<div className="card-inner" style={{ marginBottom: 12 }}>
<div className="row-header">
<h4>Мои прокси</h4>
<button
type="button"
className="secondary"
onClick={() => runProxyAction(() => reloadProxies())}
disabled={proxyBusy}
>
Обновить
</button>
</div>
{proxyNotice && ( {proxyNotice && (
<div className={`notice ${proxyNotice.tone === "error" || proxyNotice.tone === "warn" ? "warn" : "ok"}`}> <div className={`notice ${proxyNotice.tone === "error" || proxyNotice.tone === "warn" ? "warn" : "ok"}`}>
{proxyNotice.text} {proxyNotice.text}
</div> </div>
)} )}
<div className="role-presets">
<label className="inline-input">
Название
<input
type="text"
value={proxyForm.name}
onChange={(event) => setProxyForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Прокси 1"
/>
</label>
<label className="inline-input">
Тип
<select
value={proxyForm.protocol}
onChange={(event) => setProxyForm((prev) => ({ ...prev, protocol: event.target.value }))}
>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</label>
<label className="inline-input">
Host
<input
type="text"
value={proxyForm.host}
onChange={(event) => setProxyForm((prev) => ({ ...prev, host: event.target.value }))}
placeholder="127.0.0.1"
/>
</label>
<label className="inline-input">
Port
<input
type="number"
min="1"
value={proxyForm.port}
onChange={(event) => setProxyForm((prev) => ({ ...prev, port: event.target.value }))}
placeholder="1080"
/>
</label>
<label className="inline-input">
Логин
<input
type="text"
value={proxyForm.username}
onChange={(event) => setProxyForm((prev) => ({ ...prev, username: event.target.value }))}
placeholder="username"
/>
</label>
<label className="inline-input">
Пароль
<input
type="text"
value={proxyForm.password}
onChange={(event) => setProxyForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="password"
/>
</label>
<label className="inline-input">
Активен
<input
type="checkbox"
checked={Boolean(proxyForm.enabled)}
onChange={(event) => setProxyForm((prev) => ({ ...prev, enabled: event.target.checked }))}
/>
</label>
<button type="button" className="secondary" onClick={onSaveProxy} disabled={proxyBusy}>
{proxyForm.id ? "Обновить прокси" : "Добавить прокси"}
</button>
<button type="button" className="secondary" onClick={() => onTestProxy(null)} disabled={proxyBusy}>
Проверить
</button>
{proxyForm.id > 0 && (
<button type="button" className="secondary" onClick={resetProxyForm} disabled={proxyBusy}>
Сбросить форму
</button>
)}
</div>
<div className="role-presets" style={{ marginTop: 8 }}>
<label className="inline-input" style={{ flex: 1 }}>
Импорт списка прокси (по 1 в строке)
<textarea
rows={4}
value={proxyImportText}
onChange={(event) => setProxyImportText(event.target.value)}
placeholder={"socks5://user:pass@1.2.3.4:1080\n1.2.3.4:1080:user:pass\n1.2.3.4:1080"}
/>
</label>
<button type="button" className="secondary" onClick={onImportProxiesFromFile} disabled={proxyBusy}>
Загрузить файл
</button>
<button type="button" className="secondary" onClick={onImportProxies} disabled={proxyBusy}>
Импортировать
</button>
<button type="button" className="secondary" onClick={onTestAllProxies} disabled={proxyBusy}>
Проверить все
</button>
</div>
<div className="role-presets" style={{ marginTop: 8 }}>
<label className="inline-input">
Массовый прокси для выбранных аккаунтов
<select value={Number(bulkProxyId || 0)} onChange={(event) => setBulkProxyId(Number(event.target.value || 0))}>
<option value={0}>Без прокси</option>
{(proxies || []).map((proxy) => (
<option key={proxy.id} value={proxy.id}>
{proxy.name || `${proxy.host}:${proxy.port}`} ({String(proxy.protocol || "socks5").toUpperCase()})
</option>
))}
</select>
</label>
<button type="button" className="secondary" onClick={onAssignBulkProxy} disabled={proxyBusy}>
Применить к выбранным ({(selectedAccountIds || []).length})
</button>
<button type="button" className="secondary" onClick={onAutoDistributeProxies} disabled={proxyBusy}>
Автораспределить
</button>
</div>
<div className="account-list">
{(proxies || []).length === 0 && <div className="empty">Прокси не добавлены.</div>}
{(proxies || []).map((proxy) => (
<div key={proxy.id} className="account-row free">
<div className="account-main">
<div className="account-header">
<div>
<div className="account-phone">
{proxy.name || `Proxy #${proxy.id}`} · {String(proxy.protocol || "socks5").toUpperCase()}
</div>
<div className={`status ${proxy.status === "ok" ? "ok" : proxy.status === "error" ? "error" : "limited"}`}>
{proxy.status || "unknown"}
</div>
<div className="account-meta">
{proxy.host}:{proxy.port}
{proxy.username ? ` · ${proxy.username}` : ""}
</div>
<div className="account-meta">Аккаунтов: {proxy.accountsCount || 0}</div>
{proxy.lastError && <div className="account-meta">Ошибка: {proxy.lastError}</div>}
</div>
</div>
</div>
<div className="account-actions">
<button
type="button"
className="secondary tiny"
onClick={() => setProxyForm({
id: Number(proxy.id || 0),
name: proxy.name || "",
protocol: proxy.protocol || "socks5",
host: proxy.host || "",
port: String(proxy.port || 1080),
username: proxy.username || "",
password: proxy.password || "",
enabled: Boolean(proxy.enabled)
})}
disabled={proxyBusy}
>
Редактировать
</button>
<button type="button" className="secondary tiny" onClick={() => onTestProxy(proxy)} disabled={proxyBusy}>
Проверить
</button>
<button type="button" className="danger tiny" onClick={() => onDeleteProxy(proxy)} disabled={proxyBusy}>
Удалить
</button>
</div>
</div>
))}
</div>
</div>
<div className="task-filters"> <div className="task-filters">
<button <button
type="button" type="button"
@ -685,7 +283,9 @@ function AccountsTab({
{accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>} {accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>}
{visibleFreeOrSelected.map((account) => { {visibleFreeOrSelected.map((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || []; const assignedTasks = assignedAccountMap.get(account.id) || [];
const membership = membershipStatus[account.id]; const membershipResolved = resolveMembership(account.id);
const membership = membershipResolved.value;
const membershipFromInviteAccess = membershipResolved.fromInviteAccess;
const stats = accountStatsMap.get(account.id); const stats = accountStatsMap.get(account.id);
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null; const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
const used = stats ? stats.usedToday : 0; const used = stats ? stats.usedToday : 0;
@ -713,7 +313,7 @@ function AccountsTab({
: membership && membership.ourGroupPending : membership && membership.ourGroupPending
? [`Заявка отправлена${membership.ourGroupPendingAt ? ` (${new Date(membership.ourGroupPendingAt).toLocaleString()})` : ""}`] ? [`Заявка отправлена${membership.ourGroupPendingAt ? ` (${new Date(membership.ourGroupPendingAt).toLocaleString()})` : ""}`]
: []; : [];
const competitorInfo = membership const competitorInfo = membership && !membershipFromInviteAccess
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}` ? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
: "В конкурентах: —"; : "В конкурентах: —";
const ourInfo = membership const ourInfo = membership
@ -950,7 +550,9 @@ function AccountsTab({
return roles ? `${name} (${roles})` : name; return roles ? `${name} (${roles})` : name;
}) })
.join(", "); .join(", ");
const membership = membershipStatus[account.id]; const membershipResolved = resolveMembership(account.id);
const membership = membershipResolved.value;
const membershipFromInviteAccess = membershipResolved.fromInviteAccess;
const stats = accountStatsMap.get(account.id); const stats = accountStatsMap.get(account.id);
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null; const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
const used = stats ? stats.usedToday : 0; const used = stats ? stats.usedToday : 0;
@ -978,7 +580,7 @@ function AccountsTab({
: membership && membership.ourGroupPending : membership && membership.ourGroupPending
? [`Заявка отправлена${membership.ourGroupPendingAt ? ` (${new Date(membership.ourGroupPendingAt).toLocaleString()})` : ""}`] ? [`Заявка отправлена${membership.ourGroupPendingAt ? ` (${new Date(membership.ourGroupPendingAt).toLocaleString()})` : ""}`]
: []; : [];
const competitorInfo = membership const competitorInfo = membership && !membershipFromInviteAccess
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}` ? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
: "В конкурентах: —"; : "В конкурентах: —";
const ourInfo = membership const ourInfo = membership

View File

@ -1,12 +1,28 @@
import React, { memo, useMemo, useState } from "react"; import React, { memo, useEffect, useMemo, useState } from "react";
const TEMP_ADMIN_FILTER = "__temp_admin__"; const TEMP_ADMIN_FILTER = "__temp_admin__";
function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById, formatAccountLabel }) { function EventsTab({
accountEvents,
selectedTaskId,
selectedTask,
competitorLinks,
formatTimestamp,
onClearEvents,
accountById,
formatAccountLabel
}) {
const [typeFilter, setTypeFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all");
const [scopeFilter, setScopeFilter] = useState(selectedTaskId ? "task" : "all");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [expandedEventId, setExpandedEventId] = useState(null); const [expandedEventId, setExpandedEventId] = useState(null);
useEffect(() => {
if (!selectedTaskId && scopeFilter === "task") {
setScopeFilter("all");
}
}, [selectedTaskId, scopeFilter]);
const buildEventSummary = (event) => { const buildEventSummary = (event) => {
const firstLine = event.message ? String(event.message).split("\n")[0] : ""; const firstLine = event.message ? String(event.message).split("\n")[0] : "";
const extractUserLabel = (line) => { const extractUserLabel = (line) => {
@ -133,7 +149,20 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const taskIdToken = selectedTaskId ? `задача ${selectedTaskId}` : "";
const ourGroup = String(selectedTask && selectedTask.our_group ? selectedTask.our_group : "").trim();
const links = Array.isArray(competitorLinks) ? competitorLinks.filter(Boolean) : [];
const belongsToTask = (event) => {
if (!selectedTaskId) return true;
const message = String(event && event.message ? event.message : "");
if (!message) return false;
if (taskIdToken && message.includes(taskIdToken)) return true;
if (ourGroup && message.includes(ourGroup)) return true;
if (links.some((link) => message.includes(link))) return true;
return false;
};
return accountEvents.filter((event) => { return accountEvents.filter((event) => {
if (scopeFilter === "task" && !belongsToTask(event)) return false;
if (typeFilter === TEMP_ADMIN_FILTER) { if (typeFilter === TEMP_ADMIN_FILTER) {
if (!String(event.eventType || "").startsWith("temp_admin_")) return false; if (!String(event.eventType || "").startsWith("temp_admin_")) return false;
} else if (typeFilter !== "all" && event.eventType !== typeFilter) { } else if (typeFilter !== "all" && event.eventType !== typeFilter) {
@ -145,7 +174,7 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
.toLowerCase(); .toLowerCase();
return text.includes(q); return text.includes(q);
}); });
}, [accountEvents, typeFilter, query]); }, [accountEvents, typeFilter, scopeFilter, query, selectedTaskId, selectedTask, competitorLinks]);
return ( return (
<section className="card logs"> <section className="card logs">
@ -165,6 +194,24 @@ function EventsTab({ accountEvents, formatTimestamp, onClearEvents, accountById,
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по событиям" placeholder="Поиск по событиям"
/> />
<div className="row-inline">
<button
type="button"
className={`chip ${scopeFilter === "task" ? "active" : ""}`}
onClick={() => setScopeFilter("task")}
disabled={!selectedTaskId}
title={selectedTaskId ? "Показывать только события текущей задачи" : "Сначала выбери задачу"}
>
Текущая задача
</button>
<button
type="button"
className={`chip ${scopeFilter === "all" ? "active" : ""}`}
onClick={() => setScopeFilter("all")}
>
Все задачи
</button>
</div>
<div className="task-filters"> <div className="task-filters">
{eventTypes.map((type) => ( {eventTypes.map((type) => (
<button <button