some
This commit is contained in:
parent
4e15becd60
commit
303755f221
@ -112,11 +112,13 @@ const startTaskWithChecks = async (id) => {
|
||||
if (missingSessions.length) {
|
||||
warnings.push(`Сессии не подключены: ${missingSessions.length} аккаунт(ов).`);
|
||||
}
|
||||
if (task.invite_via_admins) {
|
||||
const noRights = (inviteAccess.result || []).filter((row) => !row.canInvite);
|
||||
if (noRights.length) {
|
||||
warnings.push(`Нет прав инвайта у ${noRights.length} аккаунт(ов).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (task.invite_via_admins) {
|
||||
warnings.push("Режим инвайта через админов включен.");
|
||||
}
|
||||
@ -596,6 +598,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
const runner = taskRunners.get(id);
|
||||
const queueCount = store.getPendingCount(id);
|
||||
const dailyUsed = store.countInvitesToday(id);
|
||||
const unconfirmedCount = store.countInvitesByStatus(id, "unconfirmed");
|
||||
const task = store.getTask(id);
|
||||
const monitorInfo = telegram.getTaskMonitorInfo(id);
|
||||
const warnings = [];
|
||||
@ -686,14 +689,16 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
const disconnected = parsed.filter((row) => row.reason === "Сессия не подключена").length;
|
||||
const isChannel = parsed.some((row) => row.targetType === "channel");
|
||||
const checkedAt = task.task_invite_access_at || "";
|
||||
if (task.invite_via_admins) {
|
||||
warnings.push(`Права инвайта: ${canInvite}/${total} аккаунтов могут добавлять.${checkedAt ? ` Проверка: ${formatTimestamp(checkedAt)}.` : ""}`);
|
||||
}
|
||||
if (disconnected) {
|
||||
warnings.push(`Сессия не подключена: ${disconnected} аккаунт(ов).`);
|
||||
}
|
||||
if (isChannel) {
|
||||
if (isChannel && task.invite_via_admins) {
|
||||
warnings.push("Цель — канал: добавлять участников могут только админы.");
|
||||
}
|
||||
if (canInvite === 0) {
|
||||
if (canInvite === 0 && task.invite_via_admins) {
|
||||
readiness.ok = false;
|
||||
readiness.reasons.push("Нет аккаунтов с правами инвайта.");
|
||||
}
|
||||
@ -708,6 +713,7 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
running: runner ? runner.isRunning() : false,
|
||||
queueCount,
|
||||
dailyUsed,
|
||||
unconfirmedCount,
|
||||
dailyLimit: effectiveLimit,
|
||||
dailyRemaining: task ? Math.max(0, Number(effectiveLimit || 0) - dailyUsed) : 0,
|
||||
cycleCompetitors: task ? Boolean(task.cycle_competitors) : false,
|
||||
@ -808,13 +814,14 @@ ipcMain.handle("logs:export", async (_event, taskId) => {
|
||||
startedAt: log.startedAt,
|
||||
finishedAt: log.finishedAt,
|
||||
invitedCount: log.invitedCount,
|
||||
unconfirmedCount: log.meta && log.meta.unconfirmedCount != null ? log.meta.unconfirmedCount : "",
|
||||
cycleLimit: log.meta && log.meta.cycleLimit ? log.meta.cycleLimit : "",
|
||||
queueCount: log.meta && log.meta.queueCount != null ? log.meta.queueCount : "",
|
||||
batchSize: log.meta && log.meta.batchSize != null ? log.meta.batchSize : "",
|
||||
successIds: JSON.stringify(log.successIds || []),
|
||||
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");
|
||||
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;
|
||||
if (!Number.isFinite(time) || time < cutoff) return false;
|
||||
if (invite.status === "success") return false;
|
||||
if (invite.status === "unconfirmed") return true;
|
||||
if (!invite.error && !invite.skippedReason) return false;
|
||||
return true;
|
||||
}).map((invite) => ({
|
||||
|
||||
@ -898,6 +898,18 @@ function initStore(userDataPath) {
|
||||
).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) {
|
||||
db.prepare(`
|
||||
INSERT INTO logs (task_id, started_at, finished_at, invited_count, success_ids, error_summary, meta)
|
||||
@ -1042,6 +1054,7 @@ function initStore(userDataPath) {
|
||||
recordInvite,
|
||||
countInvitesToday,
|
||||
countInvitesTodayByAccount,
|
||||
countInvitesByStatus,
|
||||
addLog
|
||||
};
|
||||
}
|
||||
|
||||
@ -81,6 +81,7 @@ class TaskRunner {
|
||||
const errors = [];
|
||||
const successIds = [];
|
||||
let invitedCount = 0;
|
||||
let unconfirmedCount = 0;
|
||||
this.nextRunAt = "";
|
||||
this.nextInviteAccountId = 0;
|
||||
const accountMap = new Map(
|
||||
@ -204,10 +205,16 @@ class TaskRunner {
|
||||
sourceChat: item.source_chat
|
||||
});
|
||||
if (result.ok) {
|
||||
const isConfirmed = result.confirmed === true;
|
||||
if (isConfirmed) {
|
||||
invitedCount += 1;
|
||||
successIds.push(item.user_id);
|
||||
this.store.markInviteStatus(item.id, "invited");
|
||||
} else {
|
||||
unconfirmedCount += 1;
|
||||
}
|
||||
this.store.markInviteStatus(item.id, isConfirmed ? "invited" : "unconfirmed");
|
||||
this.lastInviteAccountId = result.accountId || this.lastInviteAccountId;
|
||||
const inviteStatus = isConfirmed ? "success" : "unconfirmed";
|
||||
this.store.recordInvite(
|
||||
this.task.id,
|
||||
item.user_id,
|
||||
@ -215,7 +222,7 @@ class TaskRunner {
|
||||
result.accountId,
|
||||
result.accountPhone,
|
||||
item.source_chat,
|
||||
"success",
|
||||
inviteStatus,
|
||||
"",
|
||||
"",
|
||||
"invite",
|
||||
@ -226,7 +233,7 @@ class TaskRunner {
|
||||
result.strategyMeta,
|
||||
this.task.our_group,
|
||||
result.targetType,
|
||||
result.confirmed !== false,
|
||||
result.confirmed === true,
|
||||
result.confirmError || ""
|
||||
);
|
||||
if (result.confirmed === false) {
|
||||
@ -363,7 +370,7 @@ class TaskRunner {
|
||||
invitedCount,
|
||||
successIds,
|
||||
errors,
|
||||
meta: { cycleLimit: perCycleLimit, ...(this.cycleMeta || {}) }
|
||||
meta: { cycleLimit: perCycleLimit, unconfirmedCount, ...(this.cycleMeta || {}) }
|
||||
});
|
||||
|
||||
this._scheduleNext();
|
||||
|
||||
@ -550,23 +550,63 @@ class TelegramManager {
|
||||
let targetEntity = null;
|
||||
let targetType = "";
|
||||
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") {
|
||||
return { confirmed: true, error: "" };
|
||||
return { confirmed: true, error: "", detail: "" };
|
||||
}
|
||||
try {
|
||||
await client.invoke(new Api.channels.GetParticipant({
|
||||
await confirmClient.invoke(new Api.channels.GetParticipant({
|
||||
channel: targetEntity,
|
||||
participant: user
|
||||
}));
|
||||
return { confirmed: true, error: "" };
|
||||
return { confirmed: true, error: "", detail: "" };
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
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) => {
|
||||
if (!targetEntity) {
|
||||
@ -630,6 +670,8 @@ class TelegramManager {
|
||||
user = null;
|
||||
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) {
|
||||
const resolved = await this._resolveUserFromSource(client, sourceChat, userId);
|
||||
@ -647,6 +689,8 @@ class TelegramManager {
|
||||
} else {
|
||||
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) {
|
||||
const username = providedUsername.startsWith("@") ? providedUsername : `@${providedUsername}`;
|
||||
@ -657,6 +701,8 @@ class TelegramManager {
|
||||
user = null;
|
||||
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) {
|
||||
const resolvedUser = await client.getEntity(userId);
|
||||
@ -694,7 +740,10 @@ class TelegramManager {
|
||||
await this._grantTempInviteAdmin(masterEntry.client, targetEntity, account);
|
||||
lastAttempts.push({ strategy: "temp_admin", ok: true, detail: "granted" });
|
||||
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" });
|
||||
this.store.updateAccountStatus(account.id, "ok", "");
|
||||
return {
|
||||
@ -705,7 +754,7 @@ class TelegramManager {
|
||||
strategyMeta: JSON.stringify(lastAttempts),
|
||||
targetType,
|
||||
confirmed: confirm.confirmed,
|
||||
confirmError: confirm.error
|
||||
confirmError: confirm.detail || ""
|
||||
};
|
||||
} catch (adminError) {
|
||||
const adminText = adminError.errorMessage || adminError.message || String(adminError);
|
||||
@ -727,7 +776,10 @@ class TelegramManager {
|
||||
const masterEntry = masterId ? this.clients.get(masterId) : null;
|
||||
const adminClient = masterEntry ? masterEntry.client : client;
|
||||
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" });
|
||||
this.store.updateAccountStatus(account.id, "ok", "");
|
||||
return {
|
||||
@ -738,7 +790,7 @@ class TelegramManager {
|
||||
strategyMeta: JSON.stringify(lastAttempts),
|
||||
targetType,
|
||||
confirmed: confirm.confirmed,
|
||||
confirmError: confirm.error
|
||||
confirmError: confirm.detail || ""
|
||||
};
|
||||
} catch (adminError) {
|
||||
const adminText = adminError.errorMessage || adminError.message || String(adminError);
|
||||
@ -761,7 +813,10 @@ class TelegramManager {
|
||||
}
|
||||
}
|
||||
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", "");
|
||||
const last = lastAttempts.filter((item) => item.ok).slice(-1)[0];
|
||||
@ -773,7 +828,7 @@ class TelegramManager {
|
||||
strategyMeta: JSON.stringify(lastAttempts),
|
||||
targetType,
|
||||
confirmed: confirm.confirmed,
|
||||
confirmError: confirm.error
|
||||
confirmError: confirm.detail || ""
|
||||
};
|
||||
} catch (error) {
|
||||
const errorText = error.errorMessage || error.message || String(error);
|
||||
@ -834,6 +889,11 @@ class TelegramManager {
|
||||
}
|
||||
}
|
||||
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}`) : "";
|
||||
try {
|
||||
let retryUser = null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -129,24 +129,97 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summary-grid.compact {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
@ -157,7 +230,7 @@ body {
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@ -174,10 +247,10 @@ body {
|
||||
}
|
||||
|
||||
.live-label {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.live-value {
|
||||
@ -260,16 +333,34 @@ body {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@ -279,12 +370,47 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
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 {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
@ -433,6 +559,13 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.autosave-note {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
color: #16a34a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
@ -441,17 +574,23 @@ body {
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr) 360px;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
grid-template-areas: "left main";
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.main > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -459,11 +598,24 @@ body {
|
||||
}
|
||||
|
||||
.sidebar.left {
|
||||
order: 0;
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@ -518,22 +670,75 @@ body {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.action-buttons .cta {
|
||||
margin-left: auto;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons .cta + .cta {
|
||||
margin-left: 8px;
|
||||
.action-buttons .cta {
|
||||
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 {
|
||||
padding: 4px 8px;
|
||||
padding: 6px 10px;
|
||||
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 {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
@ -660,7 +865,7 @@ textarea {
|
||||
|
||||
.row-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
@ -699,6 +904,28 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@ -1004,8 +1231,7 @@ button.danger {
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@ -1046,8 +1272,8 @@ button.danger {
|
||||
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-meta.monitor {
|
||||
@ -1105,6 +1331,13 @@ button.danger {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.task-badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-badge.ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
@ -1115,6 +1348,11 @@ button.danger {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-badge.warn {
|
||||
background: #ffedd5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.match-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@ -1307,6 +1545,36 @@ label .hint {
|
||||
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 {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@ -43,7 +43,15 @@ function LogsTab({
|
||||
accountById,
|
||||
formatAccountLabel,
|
||||
expandedInviteId,
|
||||
setExpandedInviteId
|
||||
setExpandedInviteId,
|
||||
inviteStats,
|
||||
selectedTask,
|
||||
taskAccountRoles,
|
||||
accessStatus,
|
||||
inviteAccessStatus,
|
||||
selectedTaskName,
|
||||
roleSummary,
|
||||
mutualContactDiagnostics
|
||||
}) {
|
||||
const strategyLabel = (strategy) => {
|
||||
switch (strategy) {
|
||||
@ -100,6 +108,24 @@ function LogsTab({
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(finishMs)) return null;
|
||||
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 (
|
||||
<section className="card logs">
|
||||
@ -157,6 +183,13 @@ function LogsTab({
|
||||
>
|
||||
Fallback
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${logsTab === "diagnostics" ? "active" : ""}`}
|
||||
onClick={() => setLogsTab("diagnostics")}
|
||||
>
|
||||
Диагностика
|
||||
</button>
|
||||
</div>
|
||||
{logsTab === "logs" && (
|
||||
<>
|
||||
@ -270,6 +303,11 @@ function LogsTab({
|
||||
)}
|
||||
{logsTab === "invites" && (
|
||||
<>
|
||||
{inviteStats && (
|
||||
<div className="invite-stats">
|
||||
Всего: {inviteStats.total} | Успех: {inviteStats.success} | Ошибка: {inviteStats.failed} | Пропуск: {inviteStats.skipped} | Не подтверждено: {inviteStats.unconfirmed}
|
||||
</div>
|
||||
)}
|
||||
<div className="row-inline">
|
||||
<input
|
||||
type="text"
|
||||
@ -341,6 +379,16 @@ function LogsTab({
|
||||
>
|
||||
Пропуск
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${inviteFilter === "unconfirmed" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setInviteFilter("unconfirmed");
|
||||
setInvitePage(1);
|
||||
}}
|
||||
>
|
||||
Не подтверждено
|
||||
</button>
|
||||
</div>
|
||||
{pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>}
|
||||
{pagedInvites.map((invite) => (
|
||||
@ -348,17 +396,13 @@ function LogsTab({
|
||||
<div className="log-time">
|
||||
<div>{formatTimestamp(invite.invitedAt)}</div>
|
||||
<div>
|
||||
{invite.status === "success"
|
||||
? "Успех"
|
||||
: invite.status === "skipped"
|
||||
? "Пропуск"
|
||||
: "Ошибка"}
|
||||
<span className={`log-status ${invite.status}`}>{formatInviteStatus(invite.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-details">
|
||||
<div>ID: {invite.userId}</div>
|
||||
<div className="log-users wrap">
|
||||
Ник: {invite.username ? `@${invite.username}` : "—"}
|
||||
Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}
|
||||
</div>
|
||||
<div className="log-users wrap">
|
||||
Источник: {invite.sourceChat || "—"}
|
||||
@ -379,6 +423,12 @@ function LogsTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
|
||||
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
|
||||
<div className="log-users">
|
||||
Примечание: у наблюдателя стоят обе роли, но включен случайный выбор — инвайт выполнен другим аккаунтом.
|
||||
</div>
|
||||
)}
|
||||
<div className="log-users">Наблюдатель: {(() => {
|
||||
const account = accountById.get(invite.watcherAccountId);
|
||||
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
|
||||
@ -394,9 +444,9 @@ function LogsTab({
|
||||
Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}
|
||||
</div>
|
||||
)}
|
||||
{invite.status === "success" && invite.confirmed === false && (
|
||||
{invite.confirmError && (
|
||||
<div className="log-errors">
|
||||
Подтверждение: не найден в группе{invite.confirmError ? ` (${invite.confirmError})` : ""}
|
||||
Подтверждение: {invite.confirmError}
|
||||
</div>
|
||||
)}
|
||||
{invite.strategy && (
|
||||
@ -427,9 +477,14 @@ function LogsTab({
|
||||
<div>Цель: {invite.targetChat || "—"}</div>
|
||||
<div>Тип цели: {formatTargetType(invite.targetType)}</div>
|
||||
<div>Действие: {invite.action || "invite"}</div>
|
||||
<div>Статус: {invite.status}</div>
|
||||
<div>Статус: {formatInviteStatus(invite.status)}</div>
|
||||
<div>Пропуск: {invite.skippedReason || "—"}</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>Стратегия: {invite.strategy || "—"}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user