some
This commit is contained in:
parent
3329b91c3f
commit
10ee8fb3c1
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "telegram-invite-automation",
|
||||
"version": "0.3.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Automated user parsing and invites for Telegram groups",
|
||||
"main": "src/main/index.js",
|
||||
@ -59,8 +59,7 @@
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
"dmg"
|
||||
],
|
||||
"artifactName": "Telegram-Invite-Automation-mac-${version}.${ext}"
|
||||
},
|
||||
|
||||
@ -313,7 +313,8 @@ ipcMain.handle("tasks:status", (_event, id) => {
|
||||
dailyUsed,
|
||||
dailyLimit: task ? task.daily_limit : 0,
|
||||
dailyRemaining: task ? Math.max(0, Number(task.daily_limit || 0) - dailyUsed) : 0,
|
||||
monitorInfo
|
||||
monitorInfo,
|
||||
nextRunAt: runner ? runner.getNextRunAt() : ""
|
||||
};
|
||||
});
|
||||
|
||||
@ -339,6 +340,14 @@ ipcMain.handle("tasks:membershipStatus", async (_event, id) => {
|
||||
return telegram.getMembershipStatus(competitors, task.our_group);
|
||||
});
|
||||
|
||||
ipcMain.handle("tasks:groupVisibility", async (_event, id) => {
|
||||
const task = store.getTask(id);
|
||||
if (!task) return { ok: false, error: "Task not found" };
|
||||
const competitors = store.listTaskCompetitors(id).map((row) => row.link);
|
||||
const result = await telegram.getGroupVisibility(task, competitors);
|
||||
return { ok: true, result };
|
||||
});
|
||||
|
||||
const toCsv = (rows, headers) => {
|
||||
const escape = (value) => {
|
||||
const text = value == null ? "" : String(value);
|
||||
@ -382,7 +391,21 @@ ipcMain.handle("invites:export", async (_event, taskId) => {
|
||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||
|
||||
const invites = store.listInvites(2000, taskId);
|
||||
const csv = toCsv(invites, ["taskId", "invitedAt", "userId", "username", "status", "error"]);
|
||||
const csv = toCsv(invites, [
|
||||
"taskId",
|
||||
"invitedAt",
|
||||
"userId",
|
||||
"username",
|
||||
"status",
|
||||
"error",
|
||||
"accountId",
|
||||
"accountPhone",
|
||||
"watcherAccountId",
|
||||
"watcherPhone",
|
||||
"strategy",
|
||||
"strategyMeta",
|
||||
"sourceChat"
|
||||
]);
|
||||
fs.writeFileSync(filePath, csv, "utf8");
|
||||
return { ok: true, filePath };
|
||||
});
|
||||
|
||||
@ -39,5 +39,6 @@ contextBridge.exposeInMainWorld("api", {
|
||||
taskStatus: (id) => ipcRenderer.invoke("tasks:status", id),
|
||||
parseHistoryByTask: (id) => ipcRenderer.invoke("tasks:parseHistory", id),
|
||||
checkAccessByTask: (id) => ipcRenderer.invoke("tasks:checkAccess", id),
|
||||
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id)
|
||||
membershipStatusByTask: (id) => ipcRenderer.invoke("tasks:membershipStatus", id),
|
||||
groupVisibilityByTask: (id) => ipcRenderer.invoke("tasks:groupVisibility", id)
|
||||
});
|
||||
|
||||
@ -63,7 +63,12 @@ class Scheduler {
|
||||
"skipped",
|
||||
"",
|
||||
"account_own",
|
||||
"skip"
|
||||
"skip",
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
""
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -82,7 +87,12 @@ class Scheduler {
|
||||
"success",
|
||||
"",
|
||||
"",
|
||||
"invite"
|
||||
"invite",
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
""
|
||||
);
|
||||
} else {
|
||||
errors.push(`${item.user_id}: ${result.error}`);
|
||||
@ -97,7 +107,12 @@ class Scheduler {
|
||||
"failed",
|
||||
result.error || "",
|
||||
result.error || "",
|
||||
"invite"
|
||||
"invite",
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ function initStore(userDataPath) {
|
||||
api_hash TEXT NOT NULL,
|
||||
session TEXT NOT NULL,
|
||||
user_id TEXT DEFAULT '',
|
||||
username TEXT DEFAULT '',
|
||||
max_groups INTEGER DEFAULT 10,
|
||||
daily_limit INTEGER DEFAULT 50,
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
@ -54,6 +55,7 @@ function initStore(userDataPath) {
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
user_access_hash TEXT DEFAULT '',
|
||||
watcher_account_id INTEGER DEFAULT 0,
|
||||
source_chat TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
@ -89,12 +91,17 @@ function initStore(userDataPath) {
|
||||
user_access_hash TEXT DEFAULT '',
|
||||
account_id INTEGER DEFAULT 0,
|
||||
account_phone TEXT DEFAULT '',
|
||||
watcher_account_id INTEGER DEFAULT 0,
|
||||
watcher_phone TEXT DEFAULT '',
|
||||
strategy TEXT DEFAULT '',
|
||||
strategy_meta TEXT DEFAULT '',
|
||||
source_chat TEXT DEFAULT '',
|
||||
action TEXT DEFAULT 'invite',
|
||||
skipped_reason TEXT DEFAULT '',
|
||||
invited_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT NOT NULL
|
||||
error TEXT NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
@ -143,15 +150,22 @@ function initStore(userDataPath) {
|
||||
|
||||
ensureColumn("invite_queue", "username", "TEXT DEFAULT ''");
|
||||
ensureColumn("invite_queue", "user_access_hash", "TEXT DEFAULT ''");
|
||||
ensureColumn("invite_queue", "watcher_account_id", "INTEGER DEFAULT 0");
|
||||
ensureColumn("invites", "username", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "user_access_hash", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "account_id", "INTEGER DEFAULT 0");
|
||||
ensureColumn("accounts", "max_groups", "INTEGER DEFAULT 10");
|
||||
ensureColumn("accounts", "daily_limit", "INTEGER DEFAULT 50");
|
||||
ensureColumn("invites", "account_phone", "TEXT DEFAULT ''");
|
||||
ensureColumn("accounts", "username", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "watcher_account_id", "INTEGER DEFAULT 0");
|
||||
ensureColumn("invites", "watcher_phone", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "strategy", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "strategy_meta", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "source_chat", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "action", "TEXT DEFAULT 'invite'");
|
||||
ensureColumn("invites", "skipped_reason", "TEXT DEFAULT ''");
|
||||
ensureColumn("invites", "archived", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("accounts", "cooldown_until", "TEXT DEFAULT ''");
|
||||
ensureColumn("accounts", "cooldown_reason", "TEXT DEFAULT ''");
|
||||
ensureColumn("accounts", "user_id", "TEXT DEFAULT ''");
|
||||
@ -161,6 +175,8 @@ function initStore(userDataPath) {
|
||||
ensureColumn("invites", "task_id", "INTEGER DEFAULT 0");
|
||||
ensureColumn("tasks", "auto_join_competitors", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("tasks", "auto_join_our_group", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("tasks", "separate_bot_roles", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tasks", "require_same_bot_in_both", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
const settingsRow = db.prepare("SELECT value FROM settings WHERE key = ?").get("settings");
|
||||
if (!settingsRow) {
|
||||
@ -230,14 +246,15 @@ function initStore(userDataPath) {
|
||||
function addAccount(account) {
|
||||
const now = dayjs().toISOString();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO accounts (phone, api_id, api_hash, session, user_id, max_groups, daily_limit, status, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO accounts (phone, api_id, api_hash, session, user_id, username, max_groups, daily_limit, status, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
account.phone,
|
||||
account.apiId,
|
||||
account.apiHash,
|
||||
account.session,
|
||||
account.userId || "",
|
||||
account.username || "",
|
||||
account.maxGroups ?? DEFAULT_SETTINGS.accountMaxGroups,
|
||||
account.dailyLimit ?? DEFAULT_SETTINGS.accountDailyLimit,
|
||||
account.status || "ok",
|
||||
@ -254,10 +271,10 @@ function initStore(userDataPath) {
|
||||
.run(status, lastError || "", now, id);
|
||||
}
|
||||
|
||||
function updateAccountIdentity(id, userId, phone) {
|
||||
function updateAccountIdentity(id, userId, phone, username) {
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare("UPDATE accounts SET user_id = ?, phone = ?, updated_at = ? WHERE id = ?")
|
||||
.run(userId || "", phone || "", now, id);
|
||||
db.prepare("UPDATE accounts SET user_id = ?, phone = ?, username = ?, updated_at = ? WHERE id = ?")
|
||||
.run(userId || "", phone || "", username || "", now, id);
|
||||
}
|
||||
|
||||
function setAccountCooldown(id, minutes, reason) {
|
||||
@ -309,14 +326,14 @@ function initStore(userDataPath) {
|
||||
}));
|
||||
}
|
||||
|
||||
function enqueueInvite(taskId, userId, username, sourceChat, accessHash) {
|
||||
function enqueueInvite(taskId, userId, username, sourceChat, accessHash, watcherAccountId) {
|
||||
const now = dayjs().toISOString();
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO invite_queue (task_id, user_id, username, user_access_hash, source_chat, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
`).run(taskId || 0, userId, username || "", accessHash || "", sourceChat, now, now);
|
||||
return true;
|
||||
const result = db.prepare(`
|
||||
INSERT OR IGNORE INTO invite_queue (task_id, user_id, username, user_access_hash, watcher_account_id, source_chat, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
`).run(taskId || 0, userId, username || "", accessHash || "", watcherAccountId || 0, sourceChat, now, now);
|
||||
return result.changes > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
@ -362,7 +379,8 @@ function initStore(userDataPath) {
|
||||
UPDATE tasks
|
||||
SET name = ?, our_group = ?, min_interval_minutes = ?, max_interval_minutes = ?, daily_limit = ?,
|
||||
history_limit = ?, max_competitor_bots = ?, max_our_bots = ?, random_accounts = ?, multi_accounts_per_run = ?,
|
||||
retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, updated_at = ?
|
||||
retry_on_fail = ?, auto_join_competitors = ?, auto_join_our_group = ?, separate_bot_roles = ?,
|
||||
require_same_bot_in_both = ?, stop_on_blocked = ?, stop_blocked_percent = ?, notes = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
task.name,
|
||||
@ -378,6 +396,8 @@ function initStore(userDataPath) {
|
||||
task.retryOnFail ? 1 : 0,
|
||||
task.autoJoinCompetitors ? 1 : 0,
|
||||
task.autoJoinOurGroup ? 1 : 0,
|
||||
task.separateBotRoles ? 1 : 0,
|
||||
task.requireSameBotInBoth ? 1 : 0,
|
||||
task.stopOnBlocked ? 1 : 0,
|
||||
task.stopBlockedPercent || 25,
|
||||
task.notes || "",
|
||||
@ -391,8 +411,8 @@ function initStore(userDataPath) {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO tasks (name, our_group, min_interval_minutes, max_interval_minutes, daily_limit, history_limit,
|
||||
max_competitor_bots, max_our_bots, random_accounts, multi_accounts_per_run, retry_on_fail, auto_join_competitors,
|
||||
auto_join_our_group, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
auto_join_our_group, separate_bot_roles, require_same_bot_in_both, stop_on_blocked, stop_blocked_percent, notes, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
task.name,
|
||||
task.ourGroup,
|
||||
@ -407,6 +427,8 @@ function initStore(userDataPath) {
|
||||
task.retryOnFail ? 1 : 0,
|
||||
task.autoJoinCompetitors ? 1 : 0,
|
||||
task.autoJoinOurGroup ? 1 : 0,
|
||||
task.separateBotRoles ? 1 : 0,
|
||||
task.requireSameBotInBoth ? 1 : 0,
|
||||
task.stopOnBlocked ? 1 : 0,
|
||||
task.stopBlockedPercent || 25,
|
||||
task.notes || "",
|
||||
@ -459,11 +481,45 @@ function initStore(userDataPath) {
|
||||
.run(now, queueId);
|
||||
}
|
||||
|
||||
function recordInvite(taskId, userId, username, accountId, accountPhone, sourceChat, status, error, skippedReason, action, userAccessHash) {
|
||||
function recordInvite(
|
||||
taskId,
|
||||
userId,
|
||||
username,
|
||||
accountId,
|
||||
accountPhone,
|
||||
sourceChat,
|
||||
status,
|
||||
error,
|
||||
skippedReason,
|
||||
action,
|
||||
userAccessHash,
|
||||
watcherAccountId,
|
||||
watcherPhone,
|
||||
strategy,
|
||||
strategyMeta
|
||||
) {
|
||||
const now = dayjs().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO invites (task_id, user_id, username, user_access_hash, account_id, account_phone, source_chat, action, skipped_reason, invited_at, status, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO invites (
|
||||
task_id,
|
||||
user_id,
|
||||
username,
|
||||
user_access_hash,
|
||||
account_id,
|
||||
account_phone,
|
||||
watcher_account_id,
|
||||
watcher_phone,
|
||||
strategy,
|
||||
strategy_meta,
|
||||
source_chat,
|
||||
action,
|
||||
skipped_reason,
|
||||
invited_at,
|
||||
status,
|
||||
error,
|
||||
archived
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
`).run(
|
||||
taskId || 0,
|
||||
userId,
|
||||
@ -471,6 +527,10 @@ function initStore(userDataPath) {
|
||||
userAccessHash || "",
|
||||
accountId || 0,
|
||||
accountPhone || "",
|
||||
watcherAccountId || 0,
|
||||
watcherPhone || "",
|
||||
strategy || "",
|
||||
strategyMeta || "",
|
||||
sourceChat || "",
|
||||
action || "invite",
|
||||
skippedReason || "",
|
||||
@ -549,13 +609,14 @@ function initStore(userDataPath) {
|
||||
if (taskId != null) {
|
||||
rows = db.prepare(`
|
||||
SELECT * FROM invites
|
||||
WHERE task_id = ?
|
||||
WHERE task_id = ? AND archived = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(taskId || 0, limit || 200);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT * FROM invites
|
||||
WHERE archived = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(limit || 200);
|
||||
@ -568,6 +629,10 @@ function initStore(userDataPath) {
|
||||
userAccessHash: row.user_access_hash || "",
|
||||
accountId: row.account_id || 0,
|
||||
accountPhone: row.account_phone || "",
|
||||
watcherAccountId: row.watcher_account_id || 0,
|
||||
watcherPhone: row.watcher_phone || "",
|
||||
strategy: row.strategy || "",
|
||||
strategyMeta: row.strategy_meta || "",
|
||||
sourceChat: row.source_chat || "",
|
||||
action: row.action || "invite",
|
||||
skippedReason: row.skipped_reason || "",
|
||||
@ -579,12 +644,10 @@ function initStore(userDataPath) {
|
||||
|
||||
function clearInvites(taskId) {
|
||||
if (taskId == null) {
|
||||
db.prepare("DELETE FROM invites").run();
|
||||
db.prepare("DELETE FROM invite_queue").run();
|
||||
db.prepare("UPDATE invites SET archived = 1").run();
|
||||
return;
|
||||
}
|
||||
db.prepare("DELETE FROM invites WHERE task_id = ?").run(taskId || 0);
|
||||
db.prepare("DELETE FROM invite_queue WHERE task_id = ?").run(taskId || 0);
|
||||
db.prepare("UPDATE invites SET archived = 1 WHERE task_id = ?").run(taskId || 0);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -7,12 +7,17 @@ class TaskRunner {
|
||||
this.task = task;
|
||||
this.running = false;
|
||||
this.timer = null;
|
||||
this.nextRunAt = "";
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
getNextRunAt() {
|
||||
return this.nextRunAt || "";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
@ -24,6 +29,7 @@ class TaskRunner {
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.nextRunAt = "";
|
||||
this.telegram.stopTaskMonitor(this.task.id);
|
||||
}
|
||||
|
||||
@ -39,6 +45,7 @@ class TaskRunner {
|
||||
const minMs = Number(this.task.min_interval_minutes || 5) * 60 * 1000;
|
||||
const maxMs = Number(this.task.max_interval_minutes || 10) * 60 * 1000;
|
||||
const jitter = Math.max(minMs, Math.min(maxMs, minMs + Math.random() * (maxMs - minMs)));
|
||||
this.nextRunAt = new Date(Date.now() + jitter).toISOString();
|
||||
this.timer = setTimeout(() => this._runBatch(), jitter);
|
||||
}
|
||||
|
||||
@ -47,13 +54,28 @@ class TaskRunner {
|
||||
const errors = [];
|
||||
const successIds = [];
|
||||
let invitedCount = 0;
|
||||
this.nextRunAt = "";
|
||||
|
||||
try {
|
||||
const accounts = this.store.listTaskAccounts(this.task.id).map((row) => row.account_id);
|
||||
let inviteAccounts = accounts;
|
||||
if (this.task.separate_bot_roles || this.task.require_same_bot_in_both) {
|
||||
const roles = this.telegram.getTaskRoleAssignments(this.task.id);
|
||||
inviteAccounts = this.task.require_same_bot_in_both
|
||||
? (roles.competitorIds || [])
|
||||
: (roles.ourIds || []);
|
||||
if (!inviteAccounts.length) {
|
||||
errors.push(this.task.require_same_bot_in_both ? "No invite accounts (same bot required)" : "No invite accounts (separated roles)");
|
||||
}
|
||||
} else {
|
||||
const limit = Math.max(1, Number(this.task.max_our_bots || accounts.length || 1));
|
||||
if (inviteAccounts.length > limit) {
|
||||
inviteAccounts = inviteAccounts.slice(0, limit);
|
||||
}
|
||||
}
|
||||
if (!accounts.length) {
|
||||
errors.push("No accounts assigned");
|
||||
}
|
||||
let inviteAccounts = accounts;
|
||||
if (!this.task.multi_accounts_per_run) {
|
||||
const entry = this.telegram.pickInviteAccount(inviteAccounts, Boolean(this.task.random_accounts));
|
||||
inviteAccounts = entry ? [entry.account.id] : [];
|
||||
@ -77,6 +99,7 @@ class TaskRunner {
|
||||
const remaining = dailyLimit - alreadyInvited;
|
||||
const batchSize = Math.min(20, remaining);
|
||||
const pending = this.store.getPendingInvites(this.task.id, batchSize);
|
||||
const accountMap = new Map(this.store.listAccounts().map((account) => [account.id, account]));
|
||||
if (!inviteAccounts.length && pending.length) {
|
||||
errors.push("No available accounts under limits");
|
||||
}
|
||||
@ -86,10 +109,16 @@ class TaskRunner {
|
||||
this.store.markInviteStatus(item.id, "failed");
|
||||
continue;
|
||||
}
|
||||
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, inviteAccounts, {
|
||||
let accountsForInvite = inviteAccounts;
|
||||
if (item.watcher_account_id && !inviteAccounts.includes(item.watcher_account_id)) {
|
||||
accountsForInvite = [item.watcher_account_id];
|
||||
}
|
||||
const watcherAccount = accountMap.get(item.watcher_account_id || 0);
|
||||
const result = await this.telegram.inviteUserForTask(this.task, item.user_id, accountsForInvite, {
|
||||
randomize: Boolean(this.task.random_accounts),
|
||||
userAccessHash: item.user_access_hash,
|
||||
username: item.username
|
||||
username: item.username,
|
||||
sourceChat: item.source_chat
|
||||
});
|
||||
if (result.ok) {
|
||||
invitedCount += 1;
|
||||
@ -106,7 +135,11 @@ class TaskRunner {
|
||||
"",
|
||||
"",
|
||||
"invite",
|
||||
item.user_access_hash
|
||||
item.user_access_hash,
|
||||
watcherAccount ? watcherAccount.id : 0,
|
||||
watcherAccount ? watcherAccount.phone : "",
|
||||
result.strategy,
|
||||
result.strategyMeta
|
||||
);
|
||||
} else {
|
||||
errors.push(`${item.user_id}: ${result.error}`);
|
||||
@ -127,7 +160,11 @@ class TaskRunner {
|
||||
result.error || "",
|
||||
result.error || "",
|
||||
"invite",
|
||||
item.user_access_hash
|
||||
item.user_access_hash,
|
||||
watcherAccount ? watcherAccount.id : 0,
|
||||
watcherAccount ? watcherAccount.phone : "",
|
||||
result.strategy,
|
||||
result.strategyMeta
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -50,6 +50,26 @@ body {
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.notice.warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
.visibility-list {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.visibility-item {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.help {
|
||||
gap: 12px;
|
||||
}
|
||||
@ -85,40 +105,46 @@ body {
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.live-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.live-label {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.live-value {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@ -444,17 +470,17 @@ label {
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d7e0;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d1d7e0;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@ -475,6 +501,12 @@ textarea {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
@ -484,6 +516,24 @@ textarea {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-inline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@ -555,6 +605,31 @@ button {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.ghost.tiny {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.membership-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-list-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
@ -642,19 +717,41 @@ button.danger {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.task-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-search {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-search input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-filters .chip {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.select-inline {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.select-inline select {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
@ -677,20 +774,13 @@ button.danger {
|
||||
.task-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@ -713,15 +803,27 @@ button.danger {
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-meta.monitor {
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -743,19 +845,68 @@ button.danger {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
.task-summary {
|
||||
background: #eef2ff;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-summary-title {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.task-summary-row {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.task-badge.ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.task-badge.off {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.task-status.ok {
|
||||
color: #16a34a;
|
||||
.match-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.task-status.off {
|
||||
color: #94a3b8;
|
||||
.match-badge.ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.match-badge.warn {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.task-editor {
|
||||
@ -779,10 +930,15 @@ button.danger {
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
label .hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tdata-report {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
@ -817,8 +973,20 @@ button.danger {
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: #1d4ed8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-text.compact {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.task-toolbar {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
@ -827,6 +995,11 @@ button.danger {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-actions button {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-actions.expanded {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@ -834,23 +1007,23 @@ button.danger {
|
||||
.side-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.side-stat {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.side-stat strong {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
|
||||
282
src/renderer/tabs/AccountsTab.jsx
Normal file
282
src/renderer/tabs/AccountsTab.jsx
Normal file
@ -0,0 +1,282 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function AccountsTab({
|
||||
accounts,
|
||||
accountStats,
|
||||
settings,
|
||||
membershipStatus,
|
||||
assignedAccountMap,
|
||||
accountBuckets,
|
||||
filterFreeAccounts,
|
||||
selectedAccountIds,
|
||||
hasSelectedTask,
|
||||
taskNotice,
|
||||
refreshMembership,
|
||||
refreshIdentity,
|
||||
formatAccountStatus,
|
||||
resetCooldown,
|
||||
deleteAccount,
|
||||
toggleAccountSelection,
|
||||
removeAccountFromTask,
|
||||
moveAccountToTask
|
||||
}) {
|
||||
const [membershipModal, setMembershipModal] = useState(null);
|
||||
|
||||
const openMembershipModal = (title, lines) => {
|
||||
setMembershipModal({ title, lines });
|
||||
};
|
||||
|
||||
const closeMembershipModal = () => {
|
||||
setMembershipModal(null);
|
||||
};
|
||||
|
||||
const buildAccountLabel = (account) => `${account.username ? `@${account.username}` : "—"} (${account.user_id || "—"})`;
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row-header">
|
||||
<h2>Аккаунты</h2>
|
||||
<div className="row-inline">
|
||||
<button className="ghost" type="button" onClick={() => refreshMembership("accounts")}>Проверить участие</button>
|
||||
<button className="ghost" type="button" onClick={refreshIdentity}>Обновить ID</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hint">
|
||||
Отметьте аккаунты для выбранной задачи. При нескольких задачах здесь показываются свободные аккаунты.
|
||||
</div>
|
||||
{!hasSelectedTask && (
|
||||
<div className="hint">Выберите задачу, чтобы управлять аккаунтами.</div>
|
||||
)}
|
||||
{taskNotice && taskNotice.source === "accounts" && (
|
||||
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
|
||||
)}
|
||||
<div className="account-list">
|
||||
{accounts.length === 0 && <div className="empty">Аккаунты не добавлены.</div>}
|
||||
{accountBuckets.freeOrSelected.map((account) => {
|
||||
const assignedTasks = assignedAccountMap.get(account.id) || [];
|
||||
const membership = membershipStatus[account.id];
|
||||
const stats = accountStats.find((item) => item.id === account.id);
|
||||
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
|
||||
const used = stats ? stats.usedToday : 0;
|
||||
const limit = stats ? stats.limit : settings.accountDailyLimit;
|
||||
const cooldownUntil = account.cooldown_until ? new Date(account.cooldown_until).getTime() : 0;
|
||||
const cooldownActive = cooldownUntil > Date.now();
|
||||
const cooldownMinutes = cooldownActive ? Math.ceil((cooldownUntil - Date.now()) / 60000) : 0;
|
||||
const accountLabel = buildAccountLabel(account);
|
||||
const competitorLines = membership && Array.isArray(membership.competitorGroups)
|
||||
? membership.competitorGroups
|
||||
.filter((item) => item.isMember)
|
||||
.map((item) => item.title || item.link)
|
||||
: [];
|
||||
const ourLines = membership && membership.ourGroup && membership.ourGroup.isMember
|
||||
? [membership.ourGroup.title || membership.ourGroup.link || "—"]
|
||||
: [];
|
||||
const competitorInfo = membership
|
||||
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
|
||||
: "В конкурентах: —";
|
||||
const ourInfo = membership
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
|
||||
: "В нашей: —";
|
||||
const selected = selectedAccountIds.includes(account.id);
|
||||
const taskNames = assignedTasks
|
||||
.map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div key={account.id} className="account-row">
|
||||
<div>
|
||||
<div className="account-phone">{account.phone}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
<div className="account-meta">User ID: {account.user_id || "—"}</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{competitorInfo}</strong>
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
|
||||
>
|
||||
Список
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{ourInfo}</strong>
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
|
||||
<div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
|
||||
<div className="account-meta">
|
||||
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
|
||||
</div>
|
||||
<div className="account-meta">Задачи: {assignedTasks.length ? taskNames : "—"}</div>
|
||||
{cooldownActive && (
|
||||
<div className="account-meta">
|
||||
Таймер FLOOD: {cooldownMinutes} мин
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{account.status !== "ok" && account.last_error && (
|
||||
<div className="account-error">{account.last_error}</div>
|
||||
)}
|
||||
<div className="account-actions">
|
||||
{hasSelectedTask && (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleAccountSelection(account.id)}
|
||||
/>
|
||||
В задаче
|
||||
</label>
|
||||
)}
|
||||
{hasSelectedTask && selected && (
|
||||
<button className="ghost" onClick={() => removeAccountFromTask(account.id)}>
|
||||
Убрать из задачи
|
||||
</button>
|
||||
)}
|
||||
{cooldownActive && (
|
||||
<button className="secondary" onClick={() => resetCooldown(account.id)}>
|
||||
Снять ограничение
|
||||
</button>
|
||||
)}
|
||||
<button className="danger" onClick={() => deleteAccount(account.id)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filterFreeAccounts && accountBuckets.busy.length > 0 && (
|
||||
<div className="busy-accounts">
|
||||
<div className="row-header">
|
||||
<h3>Занятые аккаунты</h3>
|
||||
<div className="status-caption">Используются в других задачах</div>
|
||||
</div>
|
||||
<div className="account-list">
|
||||
{accountBuckets.busy.map((account) => {
|
||||
const assignedTasks = assignedAccountMap.get(account.id) || [];
|
||||
const taskNames = assignedTasks
|
||||
.map((taskId) => accountBuckets.taskNameMap.get(taskId) || `Задача #${taskId}`)
|
||||
.join(", ");
|
||||
const membership = membershipStatus[account.id];
|
||||
const stats = accountStats.find((item) => item.id === account.id);
|
||||
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
|
||||
const used = stats ? stats.usedToday : 0;
|
||||
const limit = stats ? stats.limit : settings.accountDailyLimit;
|
||||
const cooldownUntil = account.cooldown_until ? new Date(account.cooldown_until).getTime() : 0;
|
||||
const cooldownActive = cooldownUntil > Date.now();
|
||||
const cooldownMinutes = cooldownActive ? Math.ceil((cooldownUntil - Date.now()) / 60000) : 0;
|
||||
const accountLabel = buildAccountLabel(account);
|
||||
const competitorLines = membership && Array.isArray(membership.competitorGroups)
|
||||
? membership.competitorGroups
|
||||
.filter((item) => item.isMember)
|
||||
.map((item) => item.title || item.link)
|
||||
: [];
|
||||
const ourLines = membership && membership.ourGroup && membership.ourGroup.isMember
|
||||
? [membership.ourGroup.title || membership.ourGroup.link || "—"]
|
||||
: [];
|
||||
const competitorInfo = membership
|
||||
? `В конкурентах: ${membership.competitorCount}/${membership.competitorTotal}`
|
||||
: "В конкурентах: —";
|
||||
const ourInfo = membership
|
||||
? `В нашей: ${membership.ourGroupMember ? "да" : "нет"}`
|
||||
: "В нашей: —";
|
||||
|
||||
return (
|
||||
<div key={account.id} className="account-row">
|
||||
<div>
|
||||
<div className="account-phone">{account.phone}</div>
|
||||
<div className={`status ${account.status}`}>{formatAccountStatus(account.status)}</div>
|
||||
<div className="account-meta">User ID: {account.user_id || "—"}</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{competitorInfo}</strong>
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Конкуренты — ${accountLabel}`, competitorLines)}
|
||||
>
|
||||
Список
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta membership-row">
|
||||
<strong>{ourInfo}</strong>
|
||||
{membership && (
|
||||
<button
|
||||
className="ghost tiny"
|
||||
type="button"
|
||||
onClick={() => openMembershipModal(`Наша группа — ${accountLabel}`, ourLines)}
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">Лимит групп: {account.max_groups || settings.accountMaxGroups}</div>
|
||||
<div className="account-meta">Лимит действий: {account.daily_limit || settings.accountDailyLimit}</div>
|
||||
<div className="account-meta">
|
||||
Осталось действий сегодня: {remaining == null ? "без лимита" : remaining} (использовано: {used}/{limit || "∞"})
|
||||
</div>
|
||||
<div className="account-meta">Задачи: {assignedTasks.length ? taskNames : "—"}</div>
|
||||
{cooldownActive && (
|
||||
<div className="account-meta">
|
||||
Таймер FLOOD: {cooldownMinutes} мин
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{account.status !== "ok" && account.last_error && (
|
||||
<div className="account-error">{account.last_error}</div>
|
||||
)}
|
||||
<div className="account-actions">
|
||||
{cooldownActive && (
|
||||
<button className="secondary" onClick={() => resetCooldown(account.id)}>
|
||||
Снять ограничение
|
||||
</button>
|
||||
)}
|
||||
{hasSelectedTask && (
|
||||
<button className="secondary" onClick={() => moveAccountToTask(account.id)}>
|
||||
Добавить в задачу
|
||||
</button>
|
||||
)}
|
||||
<button className="danger" onClick={() => deleteAccount(account.id)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{membershipModal && (
|
||||
<div className="modal-overlay" onClick={closeMembershipModal}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="row-header">
|
||||
<h3>{membershipModal.title}</h3>
|
||||
<button className="ghost" type="button" onClick={closeMembershipModal}>Закрыть</button>
|
||||
</div>
|
||||
{membershipModal.lines && membershipModal.lines.length > 0 ? (
|
||||
<div className="modal-list">
|
||||
{membershipModal.lines.map((line) => (
|
||||
<div key={line} className="modal-list-item">{line}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="status-text">Нет групп.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
src/renderer/tabs/EventsTab.jsx
Normal file
22
src/renderer/tabs/EventsTab.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
export default function EventsTab({ accountEvents, formatTimestamp }) {
|
||||
return (
|
||||
<section className="card logs">
|
||||
<h2>События аккаунтов</h2>
|
||||
{accountEvents.length === 0 && <div className="empty">Событий нет.</div>}
|
||||
{accountEvents.map((event) => (
|
||||
<div key={event.id} className="log-row">
|
||||
<div className="log-time">
|
||||
<div>{formatTimestamp(event.createdAt)}</div>
|
||||
<div>{event.eventType}</div>
|
||||
</div>
|
||||
<div className="log-details">
|
||||
<div>Аккаунт: {event.phone || event.accountId}</div>
|
||||
<div className="log-errors">{event.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
319
src/renderer/tabs/LogsTab.jsx
Normal file
319
src/renderer/tabs/LogsTab.jsx
Normal file
@ -0,0 +1,319 @@
|
||||
import React from "react";
|
||||
|
||||
export default function LogsTab({
|
||||
logsTab,
|
||||
setLogsTab,
|
||||
taskNotice,
|
||||
hasSelectedTask,
|
||||
exportLogs,
|
||||
clearLogs,
|
||||
exportInvites,
|
||||
clearInvites,
|
||||
logSearch,
|
||||
setLogSearch,
|
||||
logPage,
|
||||
setLogPage,
|
||||
logPageCount,
|
||||
pagedLogs,
|
||||
inviteSearch,
|
||||
setInviteSearch,
|
||||
invitePage,
|
||||
setInvitePage,
|
||||
invitePageCount,
|
||||
inviteFilter,
|
||||
setInviteFilter,
|
||||
pagedInvites,
|
||||
formatTimestamp,
|
||||
explainInviteError,
|
||||
expandedInviteId,
|
||||
setExpandedInviteId
|
||||
}) {
|
||||
const strategyLabel = (strategy) => {
|
||||
switch (strategy) {
|
||||
case "access_hash":
|
||||
return "access_hash (из сообщения)";
|
||||
case "participants":
|
||||
return "участники группы";
|
||||
case "username":
|
||||
return "username";
|
||||
case "entity":
|
||||
return "getEntity(userId)";
|
||||
case "retry":
|
||||
return "повторная попытка";
|
||||
default:
|
||||
return strategy || "—";
|
||||
}
|
||||
};
|
||||
|
||||
const formatStrategies = (meta) => {
|
||||
if (!meta) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(meta);
|
||||
if (!Array.isArray(parsed)) return meta;
|
||||
return parsed
|
||||
.map((item) => `${strategyLabel(item.strategy)}: ${item.ok ? "ok" : "fail"}${item.detail ? ` (${item.detail})` : ""}`)
|
||||
.join(" | ");
|
||||
} catch (error) {
|
||||
return meta;
|
||||
}
|
||||
};
|
||||
|
||||
const hasStrategySuccess = (meta) => {
|
||||
if (!meta) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(meta);
|
||||
return Array.isArray(parsed) && parsed.some((item) => item.ok);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card logs">
|
||||
<div className="row-header">
|
||||
<h2>Логи и история</h2>
|
||||
<div className="row-inline">
|
||||
{logsTab === "logs" ? (
|
||||
<>
|
||||
<button className="secondary" onClick={() => exportLogs("logs")} disabled={!hasSelectedTask}>Выгрузить</button>
|
||||
<button className="danger" onClick={() => clearLogs("logs")} disabled={!hasSelectedTask}>Сбросить</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="secondary" onClick={() => exportInvites("invites")} disabled={!hasSelectedTask}>Выгрузить</button>
|
||||
<button className="danger" onClick={() => clearInvites("invites")} disabled={!hasSelectedTask}>Сбросить</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="log-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${logsTab === "logs" ? "active" : ""}`}
|
||||
onClick={() => setLogsTab("logs")}
|
||||
>
|
||||
Логи
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${logsTab === "invites" ? "active" : ""}`}
|
||||
onClick={() => setLogsTab("invites")}
|
||||
>
|
||||
История инвайтов
|
||||
</button>
|
||||
</div>
|
||||
{taskNotice && (taskNotice.source === "logs" || taskNotice.source === "invites") && (
|
||||
<div className={`notice inline ${taskNotice.tone}`}>{taskNotice.text}</div>
|
||||
)}
|
||||
{logsTab === "logs" && (
|
||||
<>
|
||||
<div className="row-inline">
|
||||
<input
|
||||
type="text"
|
||||
value={logSearch}
|
||||
onChange={(event) => {
|
||||
setLogSearch(event.target.value);
|
||||
setLogPage(1);
|
||||
}}
|
||||
placeholder="Поиск по логам"
|
||||
/>
|
||||
<div className="pager">
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => setLogPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={logPage === 1}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span>{logPage}/{logPageCount}</span>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => setLogPage((prev) => Math.min(logPageCount, prev + 1))}
|
||||
disabled={logPage === logPageCount}
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{pagedLogs.length === 0 && <div className="empty">Логи пока пустые.</div>}
|
||||
{pagedLogs.map((log) => {
|
||||
const successIds = Array.isArray(log.successIds) ? log.successIds : [];
|
||||
const errors = Array.isArray(log.errors) ? log.errors : [];
|
||||
return (
|
||||
<div key={log.id} className="log-row">
|
||||
<div className="log-time">
|
||||
<div>Старт цикла: {formatTimestamp(log.startedAt)}</div>
|
||||
<div>Завершение: {formatTimestamp(log.finishedAt)}</div>
|
||||
</div>
|
||||
<div className="log-details">
|
||||
<div>Добавлено: {log.invitedCount}</div>
|
||||
<div className="log-users wrap">
|
||||
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
|
||||
</div>
|
||||
{log.invitedCount === 0 && errors.length === 0 && (
|
||||
<div className="log-errors">Причина: очередь пуста</div>
|
||||
)}
|
||||
{errors.length > 0 && (
|
||||
<div className="log-errors">Ошибки: {errors.join(" | ")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{logsTab === "invites" && (
|
||||
<>
|
||||
<div className="row-inline">
|
||||
<input
|
||||
type="text"
|
||||
value={inviteSearch}
|
||||
onChange={(event) => {
|
||||
setInviteSearch(event.target.value);
|
||||
setInvitePage(1);
|
||||
}}
|
||||
placeholder="Поиск по инвайтам"
|
||||
/>
|
||||
<div className="pager">
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => setInvitePage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={invitePage === 1}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span>{invitePage}/{invitePageCount}</span>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => setInvitePage((prev) => Math.min(invitePageCount, prev + 1))}
|
||||
disabled={invitePage === invitePageCount}
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-filters">
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${inviteFilter === "all" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setInviteFilter("all");
|
||||
setInvitePage(1);
|
||||
}}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${inviteFilter === "success" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setInviteFilter("success");
|
||||
setInvitePage(1);
|
||||
}}
|
||||
>
|
||||
Успех
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${inviteFilter === "error" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setInviteFilter("error");
|
||||
setInvitePage(1);
|
||||
}}
|
||||
>
|
||||
Ошибка
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip ${inviteFilter === "skipped" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setInviteFilter("skipped");
|
||||
setInvitePage(1);
|
||||
}}
|
||||
>
|
||||
Пропуск
|
||||
</button>
|
||||
</div>
|
||||
{pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>}
|
||||
{pagedInvites.map((invite) => (
|
||||
<div key={invite.id} className="log-row">
|
||||
<div className="log-time">
|
||||
<div>{formatTimestamp(invite.invitedAt)}</div>
|
||||
<div>{invite.status === "success" ? "Успех" : "Ошибка"}</div>
|
||||
</div>
|
||||
<div className="log-details">
|
||||
<div>ID: {invite.userId}</div>
|
||||
<div className="log-users wrap">
|
||||
Ник: {invite.username ? `@${invite.username}` : "—"}
|
||||
</div>
|
||||
<div className="log-users wrap">
|
||||
Источник: {invite.sourceChat || "—"}
|
||||
</div>
|
||||
<div className="log-users">
|
||||
Инвайт: {invite.accountPhone || "—"}
|
||||
{invite.watcherPhone && invite.accountPhone && (
|
||||
<span className={`match-badge ${invite.watcherPhone === invite.accountPhone ? "ok" : "warn"}`}>
|
||||
{invite.watcherPhone === invite.accountPhone
|
||||
? "Инвайт тем же аккаунтом, что наблюдал"
|
||||
: "Инвайт другим аккаунтом (наблюдатель отличается)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="log-users">Наблюдатель: {invite.watcherPhone || "—"}</div>
|
||||
{invite.skippedReason && invite.skippedReason !== "" && (
|
||||
<div className="log-errors">Пропуск: {invite.skippedReason}</div>
|
||||
)}
|
||||
{invite.error && invite.error !== "" && (
|
||||
<div className="log-errors">Причина: {invite.error}</div>
|
||||
)}
|
||||
{invite.error && explainInviteError(invite.error) && (
|
||||
<div className="log-users">Вероятная причина: {explainInviteError(invite.error)}</div>
|
||||
)}
|
||||
{invite.strategy && (
|
||||
<div className="log-users">Стратегия: {invite.strategy}</div>
|
||||
)}
|
||||
{invite.strategyMeta && (
|
||||
<div className="log-users">Стратегии: {formatStrategies(invite.strategyMeta)}</div>
|
||||
)}
|
||||
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
|
||||
<div className="log-errors">Все стратегии не сработали</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => setExpandedInviteId(expandedInviteId === invite.id ? null : invite.id)}
|
||||
>
|
||||
{expandedInviteId === invite.id ? "Скрыть детали" : "Подробнее"}
|
||||
</button>
|
||||
{expandedInviteId === invite.id && (
|
||||
<div className="invite-details">
|
||||
<div>Задача: {invite.taskId}</div>
|
||||
<div>Аккаунт ID: {invite.accountId || "—"}</div>
|
||||
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
|
||||
<div>Наблюдатель: {invite.watcherPhone || "—"}</div>
|
||||
<div>Действие: {invite.action || "invite"}</div>
|
||||
<div>Статус: {invite.status}</div>
|
||||
<div>Пропуск: {invite.skippedReason || "—"}</div>
|
||||
<div>Ошибка: {invite.error || "—"}</div>
|
||||
<div>Вероятная причина: {explainInviteError(invite.error) || "—"}</div>
|
||||
<div>Стратегия: {invite.strategy || "—"}</div>
|
||||
<div>Стратегии: {invite.strategyMeta ? formatStrategies(invite.strategyMeta) : "—"}</div>
|
||||
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
|
||||
<div>Результат: все стратегии не сработали</div>
|
||||
)}
|
||||
<div>Access Hash: {invite.userAccessHash || "—"}</div>
|
||||
<div>Время: {formatTimestamp(invite.invitedAt)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
44
src/renderer/tabs/SettingsTab.jsx
Normal file
44
src/renderer/tabs/SettingsTab.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
export default function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings }) {
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Глобальные настройки аккаунтов</h2>
|
||||
<div className="row">
|
||||
<label>
|
||||
<span className="label-line">Лимит групп на аккаунт</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.accountMaxGroups}
|
||||
onChange={(event) => onSettingsChange("accountMaxGroups", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">Лимит действий в день на аккаунт</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.accountDailyLimit}
|
||||
onChange={(event) => onSettingsChange("accountDailyLimit", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="label-line">Таймер после FLOOD (мин)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.floodCooldownMinutes}
|
||||
onChange={(event) => onSettingsChange("floodCooldownMinutes", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row-inline">
|
||||
<button className="secondary" onClick={saveSettings}>Сохранить настройки</button>
|
||||
</div>
|
||||
{settingsNotice && (
|
||||
<div className={`notice inline ${settingsNotice.tone}`}>{settingsNotice.text}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user