This commit is contained in:
Ivan Neplokhov 2026-01-23 15:19:35 +04:00
parent 4e15becd60
commit 303755f221
7 changed files with 1553 additions and 1049 deletions

View File

@ -112,9 +112,11 @@ const startTaskWithChecks = async (id) => {
if (missingSessions.length) { if (missingSessions.length) {
warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`); warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`);
} }
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite); if (task.invite_via_admins) {
if (noRights.length) { const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`); if (noRights.length) {
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
}
} }
} }
if (task.invite_via_admins) { if (task.invite_via_admins) {
@ -596,6 +598,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
const runner = taskRunners.get(id); const runner = taskRunners.get(id);
const queueCount = store.getPendingCount(id); const queueCount = store.getPendingCount(id);
const dailyUsed = store.countInvitesToday(id); const dailyUsed = store.countInvitesToday(id);
const unconfirmedCount = store.countInvitesByStatus(id, "unconfirmed");
const task = store.getTask(id); const task = store.getTask(id);
const monitorInfo = telegram.getTaskMonitorInfo(id); const monitorInfo = telegram.getTaskMonitorInfo(id);
const warnings = []; const warnings = [];
@ -686,14 +689,16 @@ ipcMain.handle("tasks:status", (_event, id) => {
const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length; const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length;
const isChannel = parsed.some((row) => row.targetType === "channel"); const isChannel = parsed.some((row) => row.targetType === "channel");
const checkedAt = task.task_invite_access_at || ""; const checkedAt = task.task_invite_access_at || "";
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`); if (task.invite_via_admins) {
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`);
}
if (disconnected) { if (disconnected) {
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`); warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
} }
if (isChannel) { if (isChannel && task.invite_via_admins) {
warnings.push("Цель — канал: добавлять участников могут только админы."); warnings.push("Цель — канал: добавлять участников могут только админы.");
} }
if (canInvite === 0) { if (canInvite === 0 && task.invite_via_admins) {
readiness.ok = false; readiness.ok = false;
readiness.reasons.push("Нет аккаунтов с правами инвайта."); readiness.reasons.push("Нет аккаунтов с правами инвайта.");
} }
@ -708,6 +713,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
running: runner ? runner.isRunning() : false, running: runner ? runner.isRunning() : false,
queueCount, queueCount,
dailyUsed, dailyUsed,
unconfirmedCount,
dailyLimit: effectiveLimit, dailyLimit: effectiveLimit,
dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0, dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0,
cycleCompetitors: task ? Boolean(task.cycle_competitors) : false, cycleCompetitors: task ? Boolean(task.cycle_competitors) : false,
@ -808,13 +814,14 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
startedAt: log.startedAt, startedAt: log.startedAt,
finishedAt: log.finishedAt, finishedAt: log.finishedAt,
invitedCount: log.invitedCount, invitedCount: log.invitedCount,
unconfirmedCount: log.meta && log.meta.unconfirmedCount != null ? log.meta.unconfirmedCount : "",
cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "", cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "",
queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "", queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "",
batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "", batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "",
successIds: JSON.stringify(log.successIds || []), successIds: JSON.stringify(log.successIds || []),
errors: JSON.stringify(log.errors || []) errors: JSON.stringify(log.errors || [])
})); }));
const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors"]); const csv = toCsv(logs, ["taskId", "startedAt", "finishedAt", "invitedCount", "unconfirmedCount", "cycleLimit", "queueCount", "batchSize", "successIds", "errors"]);
fs.writeFileSync(filePath, csv, "utf8"); fs.writeFileSync(filePath, csv, "utf8");
return { ok: true, filePath }; return { ok: true, filePath };
}); });
@ -860,6 +867,7 @@ ipcMain.handle("invites:exportProblems", async (_event, taskId) => {
const time = invite.invitedAt ? new Date(invite.invitedAt).getTime() : 0; const time = invite.invitedAt ? new Date(invite.invitedAt).getTime() : 0;
if (!Number.isFinite(time) || time < cutoff) return false; if (!Number.isFinite(time) || time < cutoff) return false;
if (invite.status === "success") return false; if (invite.status === "success") return false;
if (invite.status === "unconfirmed") return true;
if (!invite.error && !invite.skippedReason) return false; if (!invite.error && !invite.skippedReason) return false;
return true; return true;
}).map((invite) => ({ }).map((invite) => ({

View File

@ -898,6 +898,18 @@ function initStore(userDataPath) {
).get(dayStart, accountId, taskId || 0).count; ).get(dayStart, accountId, taskId || 0).count;
} }
function countInvitesByStatus(taskId, status) {
if (!status) return 0;
if (taskId == null) {
return db.prepare(
"SELECT COUNT(*) as count FROM invites WHERE status = ? AND archived = 0"
).get(status).count;
}
return db.prepare(
"SELECT COUNT(*) as count FROM invites WHERE status = ? AND task_id = ? AND archived = 0"
).get(status, taskId || 0).count;
}
function addLog(entry) { function addLog(entry) {
db.prepare(` db.prepare(`
INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary, meta) INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary, meta)
@ -1042,6 +1054,7 @@ function initStore(userDataPath) {
recordInvite, recordInvite,
countInvitesToday, countInvitesToday,
countInvitesTodayByAccount, countInvitesTodayByAccount,
countInvitesByStatus,
addLog addLog
}; };
} }

View File

@ -81,6 +81,7 @@ class TaskRunner {
const errors = []; const errors = [];
const successIds = []; const successIds = [];
let invitedCount = 0; let invitedCount = 0;
let unconfirmedCount = 0;
this.nextRunAt = ""; this.nextRunAt = "";
this.nextInviteAccountId = 0; this.nextInviteAccountId = 0;
const accountMap = new Map( const accountMap = new Map(
@ -204,10 +205,16 @@ class TaskRunner {
sourceChat: item.source_chat sourceChat: item.source_chat
}); });
if (result.ok) { if (result.ok) {
invitedCount += 1; const isConfirmed = result.confirmed === true;
successIds.push(item.user_id); if (isConfirmed) {
this.store.markInviteStatus(item.id, "invited"); invitedCount += 1;
successIds.push(item.user_id);
} else {
unconfirmedCount += 1;
}
this.store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
this.lastInviteAccountId = result.accountId || this.lastInviteAccountId; this.lastInviteAccountId = result.accountId || this.lastInviteAccountId;
const inviteStatus = isConfirmed ? "success" : "unconfirmed";
this.store.recordInvite( this.store.recordInvite(
this.task.id, this.task.id,
item.user_id, item.user_id,
@ -215,7 +222,7 @@ class TaskRunner {
result.accountId, result.accountId,
result.accountPhone, result.accountPhone,
item.source_chat, item.source_chat,
"success", inviteStatus,
"", "",
"", "",
"invite", "invite",
@ -226,7 +233,7 @@ class TaskRunner {
result.strategyMeta, result.strategyMeta,
this.task.our_group, this.task.our_group,
result.targetType, result.targetType,
result.confirmed !== false, result.confirmed === true,
result.confirmError || "" result.confirmError || ""
); );
if (result.confirmed === false) { if (result.confirmed === false) {
@ -363,7 +370,7 @@ class TaskRunner {
invitedCount, invitedCount,
successIds, successIds,
errors, errors,
meta: { cycleLimit: perCycleLimit, ...(this.cycleMeta || {}) } meta: { cycleLimit: perCycleLimit, unconfirmedCount, ...(this.cycleMeta || {}) }
}); });
this._scheduleNext(); this._scheduleNext();

View File

@ -550,24 +550,64 @@ class TelegramManager {
let targetEntity = null; let targetEntity = null;
let targetType = ""; let targetType = "";
let resolvedUser = null; let resolvedUser = null;
const confirmMembership = async (user) => { const buildConfirmDetail = (code, message, sourceLabel) => {
if (!code) return message || "";
const base = message ? `${code}: ${message}` : code;
return sourceLabel ? `${base} (${sourceLabel})` : base;
};
const confirmMembership = async (user, confirmClient = client, sourceLabel = "") => {
if (!targetEntity || targetEntity.className !== "Channel") { if (!targetEntity || targetEntity.className !== "Channel") {
return { confirmed: true, error: "" }; return { confirmed: true, error: "", detail: "" };
} }
try { try {
await client.invoke(new Api.channels.GetParticipant({ await confirmClient.invoke(new Api.channels.GetParticipant({
channel: targetEntity, channel: targetEntity,
participant: user participant: user
})); }));
return { confirmed: true, error: "" }; return { confirmed: true, error: "", detail: "" };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
if (errorText.includes("USER_NOT_PARTICIPANT")) { if (errorText.includes("USER_NOT_PARTICIPANT")) {
return { confirmed: false, error: "not in group" }; return {
confirmed: false,
error: "USER_NOT_PARTICIPANT",
detail: buildConfirmDetail("USER_NOT_PARTICIPANT", "пользователь не в группе", sourceLabel)
};
} }
return { confirmed: false, error: errorText }; if (errorText.includes("CHAT_ADMIN_REQUIRED")) {
return {
confirmed: null,
error: "CHAT_ADMIN_REQUIRED",
detail: buildConfirmDetail("CHAT_ADMIN_REQUIRED", "нет прав для подтверждения участия", sourceLabel)
};
}
return {
confirmed: null,
error: errorText,
detail: buildConfirmDetail(errorText, "ошибка подтверждения участия", sourceLabel)
};
} }
}; };
const confirmMembershipWithFallback = async (user) => {
const attempts = [];
const direct = await confirmMembership(user, client, "проверка этим аккаунтом");
if (direct.detail) {
attempts.push({ strategy: "confirm", ok: direct.confirmed === true, detail: direct.detail });
}
if (direct.confirmed !== null) {
return { ...direct, attempts };
}
const masterId = Number(task.invite_admin_master_id || 0);
const masterEntry = masterId ? this.clients.get(masterId) : null;
if (masterEntry && masterEntry.client && masterEntry.client !== client) {
const adminConfirm = await confirmMembership(user, masterEntry.client, "проверка админом");
if (adminConfirm.detail) {
attempts.push({ strategy: "confirm_admin", ok: adminConfirm.confirmed === true, detail: adminConfirm.detail });
}
return { ...adminConfirm, attempts };
}
return { ...direct, attempts };
};
const attemptInvite = async (user) => { const attemptInvite = async (user) => {
if (!targetEntity) { if (!targetEntity) {
throw new Error("Target group not resolved"); throw new Error("Target group not resolved");
@ -630,6 +670,8 @@ class TelegramManager {
user = null; user = null;
attempts.push({ strategy: "access_hash", ok: false, detail: "invalid access_hash" }); attempts.push({ strategy: "access_hash", ok: false, detail: "invalid access_hash" });
} }
} else {
attempts.push({ strategy: "access_hash", ok: false, detail: "not provided" });
} }
if (!user && sourceChat) { if (!user && sourceChat) {
const resolved = await this._resolveUserFromSource(client, sourceChat, userId); const resolved = await this._resolveUserFromSource(client, sourceChat, userId);
@ -647,6 +689,8 @@ class TelegramManager {
} else { } else {
attempts.push({ strategy: "participants", ok: false, detail: resolved ? resolved.detail : "no result" }); attempts.push({ strategy: "participants", ok: false, detail: resolved ? resolved.detail : "no result" });
} }
} else if (!user && !sourceChat) {
attempts.push({ strategy: "participants", ok: false, detail: "source chat not provided" });
} }
if (!user && providedUsername) { if (!user && providedUsername) {
const username = providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`; const username = providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`;
@ -657,6 +701,8 @@ class TelegramManager {
user = null; user = null;
attempts.push({ strategy: "username", ok: false, detail: "resolve failed" }); attempts.push({ strategy: "username", ok: false, detail: "resolve failed" });
} }
} else if (!user && !providedUsername) {
attempts.push({ strategy: "username", ok: false, detail: "username not provided" });
} }
if (!user) { if (!user) {
const resolvedUser = await client.getEntity(userId); const resolvedUser = await client.getEntity(userId);
@ -694,7 +740,10 @@ class TelegramManager {
await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account); await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account);
lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" }); lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" });
await attemptInvite(user); await attemptInvite(user);
const confirm = await confirmMembership(user); const confirm = await confirmMembershipWithFallback(user);
if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts);
}
lastAttempts.push({ strategy: "temp_admin_invite", ok: true, detail: "invite" }); lastAttempts.push({ strategy: "temp_admin_invite", ok: true, detail: "invite" });
this.store.updateAccountStatus(account.id, "ok", ""); this.store.updateAccountStatus(account.id, "ok", "");
return { return {
@ -705,7 +754,7 @@ class TelegramManager {
strategyMeta: JSON.stringify(lastAttempts), strategyMeta: JSON.stringify(lastAttempts),
targetType, targetType,
confirmed: confirm.confirmed, confirmed: confirm.confirmed,
confirmError: confirm.error confirmError: confirm.detail || ""
}; };
} catch (adminError) { } catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError); const adminText = adminError.errorMessage || adminError.message || String(adminError);
@ -727,7 +776,10 @@ class TelegramManager {
const masterEntry = masterId ? this.clients.get(masterId) : null; const masterEntry = masterId ? this.clients.get(masterId) : null;
const adminClient = masterEntry ? masterEntry.client : client; const adminClient = masterEntry ? masterEntry.client : client;
await attemptAdminInvite(user, adminClient); await attemptAdminInvite(user, adminClient);
const confirm = await confirmMembership(user); const confirm = await confirmMembershipWithFallback(user);
if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts);
}
lastAttempts.push({ strategy: "admin_invite", ok: true, detail: "editAdmin" }); lastAttempts.push({ strategy: "admin_invite", ok: true, detail: "editAdmin" });
this.store.updateAccountStatus(account.id, "ok", ""); this.store.updateAccountStatus(account.id, "ok", "");
return { return {
@ -738,7 +790,7 @@ class TelegramManager {
strategyMeta: JSON.stringify(lastAttempts), strategyMeta: JSON.stringify(lastAttempts),
targetType, targetType,
confirmed: confirm.confirmed, confirmed: confirm.confirmed,
confirmError: confirm.error confirmError: confirm.detail || ""
}; };
} catch (adminError) { } catch (adminError) {
const adminText = adminError.errorMessage || adminError.message || String(adminError); const adminText = adminError.errorMessage || adminError.message || String(adminError);
@ -761,7 +813,10 @@ class TelegramManager {
} }
} }
await attemptInvite(user); await attemptInvite(user);
const confirm = await confirmMembership(user); const confirm = await confirmMembershipWithFallback(user);
if (confirm.attempts && confirm.attempts.length) {
lastAttempts.push(...confirm.attempts);
}
this.store.updateAccountStatus(account.id, "ok", ""); this.store.updateAccountStatus(account.id, "ok", "");
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0]; const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
@ -773,7 +828,7 @@ class TelegramManager {
strategyMeta: JSON.stringify(lastAttempts), strategyMeta: JSON.stringify(lastAttempts),
targetType, targetType,
confirmed: confirm.confirmed, confirmed: confirm.confirmed,
confirmError: confirm.error confirmError: confirm.detail || ""
}; };
} catch (error) { } catch (error) {
const errorText = error.errorMessage || error.message || String(error); const errorText = error.errorMessage || error.message || String(error);
@ -834,6 +889,11 @@ class TelegramManager {
} }
} }
if (errorText === "USER_ID_INVALID") { if (errorText === "USER_ID_INVALID") {
lastAttempts.push({
strategy: "user_id_invalid",
ok: false,
detail: `username=${options.username || "—"}; hash=${options.userAccessHash || "—"}; source=${options.sourceChat || "—"}`
});
const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : ""; const username = options.username ? (options.username.startsWith("@") ? options.username : `@${options.username}`) : "";
try { try {
let retryUser = null; let retryUser = null;

File diff suppressed because it is too large Load Diff

View File

@ -129,24 +129,97 @@ body {
gap: 12px; gap: 12px;
} }
.overview .row-header {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.overview .row-inline {
justify-content: flex-start;
gap: 6px;
}
.overview summary {
list-style: none;
cursor: pointer;
}
.overview summary::-webkit-details-marker {
display: none;
}
.collapsible > summary {
list-style: none;
cursor: pointer;
position: relative;
padding-right: 24px;
}
.collapsible > summary::after {
content: "▾";
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.2s ease;
color: #64748b;
}
.collapsible[open] > summary::after {
transform: translateY(-50%) rotate(180deg);
}
.collapsible > summary::-webkit-details-marker {
display: none;
}
.overview-actions {
display: inline-flex;
gap: 8px;
align-items: center;
}
.overview .summary-grid {
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 6px;
}
.overview .summary-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
}
.overview .summary-card {
padding: 4px 6px;
gap: 2px;
}
.overview .summary-value {
font-size: 13px;
}
.overview .live-label {
font-size: 9px;
}
.summary-grid { .summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px; gap: 6px;
} }
.summary-grid.compact { .summary-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
} }
.summary-card { .summary-card {
background: #f8fafc; background: #f8fafc;
border-radius: 12px; border-radius: 12px;
padding: 10px 12px; padding: 6px 8px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
} }
.divider { .divider {
@ -157,7 +230,7 @@ body {
} }
.summary-value { .summary-value {
font-size: 16px; font-size: 14px;
font-weight: 700; font-weight: 700;
} }
@ -174,10 +247,10 @@ body {
} }
.live-label { .live-label {
font-size: 11px; font-size: 10px;
color: #64748b; color: #64748b;
text-transform: uppercase; text-transform: none;
letter-spacing: 0.08em; letter-spacing: 0;
} }
.live-value { .live-value {
@ -260,16 +333,34 @@ body {
opacity: 0.75; opacity: 0.75;
} }
.header-actions button {
min-width: 120px;
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.top-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
align-items: stretch;
margin-bottom: 20px;
}
.top-row .card {
width: 100%;
}
.top-row .card {
min-height: 360px;
max-height: 360px;
overflow: hidden;
}
.top-row details[open] {
overflow: auto;
}
.global-actions { .global-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -279,12 +370,47 @@ body {
min-width: 140px; min-width: 140px;
} }
.admin-invite-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.admin-invite-grid .input-row {
grid-template-columns: 1fr auto;
}
.admin-invite-master {
grid-column: span 2;
}
@media (max-width: 900px) {
.admin-invite-grid {
grid-template-columns: 1fr;
}
.admin-invite-master {
grid-column: span 1;
}
}
.tabs { .tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.sticky-tabs {
position: sticky;
top: 12px;
z-index: 3;
background: #f1f5f9;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 12px 18px rgba(15, 23, 42, 0.08);
}
.tab { .tab {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
background: #f8fafc; background: #f8fafc;
@ -433,6 +559,13 @@ body {
color: #64748b; color: #64748b;
} }
.autosave-note {
margin-left: 8px;
font-size: 11px;
color: #16a34a;
font-weight: 600;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
@ -441,17 +574,23 @@ body {
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 240px minmax(0, 1fr) 360px; grid-template-columns: 240px minmax(0, 1fr);
grid-template-areas: "left main";
gap: 20px; gap: 20px;
align-items: start; align-items: start;
} }
.main { .main {
grid-area: main;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
} }
.main > * {
width: 100%;
}
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -459,11 +598,24 @@ body {
} }
.sidebar.left { .sidebar.left {
order: 0; grid-area: left;
} }
.sidebar.right { .sidebar.right {
order: 2; grid-area: right;
}
.task-columns {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 20px;
width: 100%;
}
@media (max-width: 1200px) {
.task-columns {
grid-template-columns: 1fr;
}
} }
.sticky { .sticky {
@ -518,22 +670,75 @@ body {
padding: 3px 8px; padding: 3px 8px;
} }
.action-buttons .cta { .action-buttons {
margin-left: auto; display: grid;
padding: 4px 8px; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
border-radius: 999px; gap: 8px;
font-weight: 600;
} }
.action-buttons .cta + .cta { .action-buttons .cta {
margin-left: 8px; padding: 8px 14px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.02em;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.2);
display: inline-flex;
align-items: center;
gap: 6px;
}
.action-buttons .danger.cta {
box-shadow: 0 6px 14px rgba(185, 28, 28, 0.2);
}
.cta-icon {
font-size: 12px;
line-height: 1;
} }
.action-buttons button { .action-buttons button {
padding: 4px 8px; padding: 6px 10px;
font-size: 11px; font-size: 11px;
} }
.task-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.task-editor-grid .section {
border-bottom: none;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
background: #f8fafc;
}
.task-editor-grid .section summary {
margin-bottom: 6px;
}
@media (max-width: 960px) {
.task-editor-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.row-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 1024px) {
.top-row {
grid-template-columns: 1fr;
}
}
.status-pill { .status-pill {
padding: 6px 12px; padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
@ -660,7 +865,7 @@ textarea {
.row-header { .row-header {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@ -699,6 +904,28 @@ textarea {
display: none; display: none;
} }
.section > summary,
.status-details > summary {
position: relative;
padding-right: 18px;
}
.section > summary::after,
.status-details > summary::after {
content: "▾";
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
transition: transform 0.2s ease;
color: #94a3b8;
}
.section[open] > summary::after,
.status-details[open] > summary::after {
transform: translateY(-50%) rotate(180deg);
}
.row-inline { .row-inline {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -1004,8 +1231,7 @@ button.danger {
border-radius: 12px; border-radius: 12px;
text-align: left; text-align: left;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: flex-start;
gap: 6px; gap: 6px;
} }
@ -1046,8 +1272,8 @@ button.danger {
.task-meta-row { .task-meta-row {
display: flex; display: flex;
gap: 8px; flex-direction: column;
flex-wrap: wrap; gap: 2px;
} }
.task-meta.monitor { .task-meta.monitor {
@ -1105,6 +1331,13 @@ button.danger {
color: #475569; color: #475569;
} }
.task-badge-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.task-badge.ok { .task-badge.ok {
background: #dcfce7; background: #dcfce7;
color: #15803d; color: #15803d;
@ -1115,6 +1348,11 @@ button.danger {
color: #64748b; color: #64748b;
} }
.task-badge.warn {
background: #ffedd5;
color: #b45309;
}
.match-badge { .match-badge {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@ -1307,6 +1545,36 @@ label .hint {
color: #b91c1c; color: #b91c1c;
} }
.log-result.unconfirmed {
color: #b45309;
}
.log-status {
font-weight: 600;
}
.log-status.success {
color: #0f766e;
}
.log-status.failed {
color: #b91c1c;
}
.log-status.skipped {
color: #64748b;
}
.log-status.unconfirmed {
color: #b45309;
}
.invite-stats {
font-size: 12px;
color: #475569;
margin-bottom: 8px;
}
.invite-details { .invite-details {
margin-top: 10px; margin-top: 10px;
padding: 10px 12px; padding: 10px 12px;

View File

@ -43,7 +43,15 @@ function LogsTab({
accountById, accountById,
formatAccountLabel, formatAccountLabel,
expandedInviteId, expandedInviteId,
setExpandedInviteId setExpandedInviteId,
inviteStats,
selectedTask,
taskAccountRoles,
accessStatus,
inviteAccessStatus,
selectedTaskName,
roleSummary,
mutualContactDiagnostics
}) { }) {
const strategyLabel = (strategy) => { const strategyLabel = (strategy) => {
switch (strategy) { switch (strategy) {
@ -100,6 +108,24 @@ function LogsTab({
if (!Number.isFinite(startMs) || !Number.isFinite(finishMs)) return null; if (!Number.isFinite(startMs) || !Number.isFinite(finishMs)) return null;
return Math.max(0, finishMs - startMs); return Math.max(0, finishMs - startMs);
}; };
const hasBothRoles = (accountId) => {
if (!accountId || !taskAccountRoles) return false;
const roles = taskAccountRoles[String(accountId)] || taskAccountRoles[accountId];
return Boolean(roles && roles.monitor && roles.invite);
};
const formatInviteStatus = (status) => {
switch (status) {
case "success":
return "Успех";
case "skipped":
return "Пропуск";
case "unconfirmed":
return "Не подтверждено";
case "failed":
default:
return "Ошибка";
}
};
return ( return (
<section className="card logs"> <section className="card logs">
@ -157,6 +183,13 @@ function LogsTab({
> >
Fallback Fallback
</button> </button>
<button
type="button"
className={`tab ${logsTab === "diagnostics" ? "active" : ""}`}
onClick={() => setLogsTab("diagnostics")}
>
Диагностика
</button>
</div> </div>
{logsTab === "logs" && ( {logsTab === "logs" && (
<> <>
@ -270,6 +303,11 @@ function LogsTab({
)} )}
{logsTab === "invites" && ( {logsTab === "invites" && (
<> <>
{inviteStats && (
<div className="invite-stats">
Всего: {inviteStats.total} | Успех: {inviteStats.success} | Ошибка: {inviteStats.failed} | Пропуск: {inviteStats.skipped} | Не подтверждено: {inviteStats.unconfirmed}
</div>
)}
<div className="row-inline"> <div className="row-inline">
<input <input
type="text" type="text"
@ -341,6 +379,16 @@ function LogsTab({
> >
Пропуск Пропуск
</button> </button>
<button
type="button"
className={`chip ${inviteFilter === "unconfirmed" ? "active" : ""}`}
onClick={() => {
setInviteFilter("unconfirmed");
setInvitePage(1);
}}
>
Не подтверждено
</button>
</div> </div>
{pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>} {pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>}
{pagedInvites.map((invite) => ( {pagedInvites.map((invite) => (
@ -348,17 +396,13 @@ function LogsTab({
<div className="log-time"> <div className="log-time">
<div>{formatTimestamp(invite.invitedAt)}</div> <div>{formatTimestamp(invite.invitedAt)}</div>
<div> <div>
{invite.status === "success" <span className={`log-status ${invite.status}`}>{formatInviteStatus(invite.status)}</span>
? "Успех"
: invite.status === "skipped"
? "Пропуск"
: "Ошибка"}
</div> </div>
</div> </div>
<div className="log-details"> <div className="log-details">
<div>ID: {invite.userId}</div> <div>ID: {invite.userId}</div>
<div className="log-users wrap"> <div className="log-users wrap">
Ник: {invite.username ? `@${invite.username}` : "—"} Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}
</div> </div>
<div className="log-users wrap"> <div className="log-users wrap">
Источник: {invite.sourceChat || "—"} Источник: {invite.sourceChat || "—"}
@ -379,6 +423,12 @@ function LogsTab({
</span> </span>
)} )}
</div> </div>
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div className="log-users">
Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.
</div>
)}
<div className="log-users">Наблюдатель: {(() => { <div className="log-users">Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId); const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—"); return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
@ -394,9 +444,9 @@ function LogsTab({
Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"} Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}
</div> </div>
)} )}
{invite.status === "success" && invite.confirmed === false && ( {invite.confirmError && (
<div className="log-errors"> <div className="log-errors">
Подтверждение: не найден в группе{invite.confirmError ? ` (${invite.confirmError})` : ""} Подтверждение: {invite.confirmError}
</div> </div>
)} )}
{invite.strategy && ( {invite.strategy && (
@ -427,9 +477,14 @@ function LogsTab({
<div>Цель: {invite.targetChat || "—"}</div> <div>Цель: {invite.targetChat || "—"}</div>
<div>Тип цели: {formatTargetType(invite.targetType)}</div> <div>Тип цели: {formatTargetType(invite.targetType)}</div>
<div>Действие: {invite.action || "invite"}</div> <div>Действие: {invite.action || "invite"}</div>
<div>Статус: {invite.status}</div> <div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Пропуск: {invite.skippedReason || "—"}</div> <div>Пропуск: {invite.skippedReason || "—"}</div>
<div>Ошибка: {invite.error || "—"}</div> <div>Ошибка: {invite.error || "—"}</div>
<div>Подтверждение: {invite.confirmError || "—"}</div>
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>
)}
<div>Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}</div> <div>Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}</div>
<div>Стратегия: {invite.strategy || "—"}</div> <div>Стратегия: {invite.strategy || "—"}</div>
<div className="pre-line"> <div className="pre-line">
@ -560,6 +615,95 @@ function LogsTab({
))} ))}
</> </>
)} )}
{logsTab === "diagnostics" && (
<>
<div className="log-users">Для: {selectedTaskName}</div>
{accessStatus && accessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Доступ к группам</div>
<div className="access-list">
{accessStatus.map((item, index) => (
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
<div className="access-title">
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
</div>
<div className="access-status">
{item.ok ? "Доступ есть" : "Нет доступа"}
</div>
{!item.ok && <div className="access-error">{item.details}</div>}
</div>
))}
</div>
</div>
)}
{inviteAccessStatus && inviteAccessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Права инвайта</div>
<div className="access-subtitle">
Проверяются аккаунты с ролью инвайта: {roleSummary ? roleSummary.invite.length : "—"}
</div>
<div className="access-list">
{inviteAccessStatus.map((item, index) => (
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>
<div className="access-title">
{(() => {
const account = accountById.get(item.accountId);
return account ? formatAccountLabel(account) : (item.accountPhone || item.accountId);
})()}: {item.title || item.targetChat}
{item.targetType ? ` (${formatTargetType(item.targetType)})` : ""}
</div>
<div className="access-status">
{item.canInvite ? "Можно инвайтить" : "Нет прав"}
</div>
{!item.canInvite && <div className="access-error">{item.reason || "—"}</div>}
</div>
))}
</div>
</div>
)}
{mutualContactDiagnostics && (
<div className="access-block">
<div className="access-title">USER_NOT_MUTUAL_CONTACT</div>
<div className="access-subtitle">
Ошибок в истории: {mutualContactDiagnostics.count}
</div>
<div className="access-list">
<div className="access-row">
<div className="access-title">
Цель: {(() => {
const entry = inviteAccessStatus && inviteAccessStatus[0];
if (entry) {
const label = entry.title || entry.targetChat || (selectedTask ? selectedTask.ourGroup : "—");
const typeLabel = formatTargetType(entry.targetType);
return typeLabel ? `${label} (${typeLabel})` : label;
}
return selectedTask ? selectedTask.ourGroup : "—";
})()}
</div>
<div className="access-status">
{inviteAccessStatus && inviteAccessStatus.length ? "Права проверены" : "Нет данных проверки"}
</div>
</div>
{mutualContactDiagnostics.recent.map((item) => (
<div key={`mutual-${item.id}`} className="access-row fail">
<div className="access-title">
Пользователь: {item.userId}{item.username ? ` (@${item.username})` : ""}
</div>
<div className="access-status">{formatTimestamp(item.invitedAt)}</div>
</div>
))}
</div>
<div className="access-error">
Возможные причины: Telegram ограничивает инвайт, если пользователь скрывает приём приглашений,
целевая группа требует взаимного контакта, или у пользователя есть приватные ограничения.
</div>
</div>
)}
{!accessStatus?.length && !inviteAccessStatus?.length && (
<div className="empty">Диагностика пока пустая.</div>
)}
</>
)}
</section> </section>
); );
} }