feat(accounts): 批量加退群整合 Bot 批量启停

- 批量加群/订阅流程可选批量启用 Bot,批量退群/退订可选批量停用 Bot

- 支持 Bot 多选、推荐选择、全选/清空,进度统计合并账号与 Bot 操作

- 批量入口文案改为 批量加群/订阅/启用Bot 与 批量退群/退订/停用Bot
This commit is contained in:
meoacgx committed 2026-03-03 04:38:44 +08:00
1 parent 9f1bdf5add
commit 1dfb80cdd2
3 files changed
+243 -59

No files matched your search

@@ -1,5 +1,6 @@
@inject AccountManagementService AccountManagement
@inject AccountTelegramToolsService AccountTelegramTools
@inject BotManagementService BotManagement
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -11,11 +12,11 @@
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
@if (IsJoin)
{
<span>将使用所选账号订阅频道/加入群组。支持输入多个链接/用户名,换行分隔。</span>
<span>将使用所选账号订阅频道/加入群组,并可选批量启用 Bot。</span>
}
else
{
<span>将使用所选账号退出群组/取消订阅频道。支持输入多个链接/用户名,换行分隔。</span>
<span>将使用所选账号退出群组/取消订阅频道,并可选批量停用 Bot。</span>
}
<MudText Typo="Typo.body2" Class="mt-1">
支持:<b>https://t.me/xxx</b>、<b>t.me/+hash</b>、<b>@@username</b>、<b>username</b>、<b>tg://join?invite=hash</b>
@@ -25,11 +26,65 @@
<MudText Typo="Typo.subtitle2">选中账号:@accounts.Count 个</MudText>
<MudTextField @bind-Value="linksText" Label="频道/群链接列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
HelperText="每行一个链接或用户名;将逐个账号依次执行。" />
HelperText="每行一个链接或用户名;如仅执行 Bot 启停可留空。" />
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="10000"
HelperText="建议设置 1500-4000ms,避免触发风控(会额外加少量随机抖动)。" />
<MudDivider />
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Bot 操作(可选)</MudText>
<MudChip T="string" Size="Size.Small" Color="@(IsJoin ? Color.Success : Color.Warning)">
@(IsJoin ? "批量启用" : "批量停用")
</MudChip>
</MudStack>
@if (bots.Count == 0)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">暂无 Bot,已自动跳过 Bot 批量操作。</MudText>
}
else
{
<MudCheckBox T="bool"
@bind-Value="enableBotOperation"
Label="同时执行 Bot 批量操作"
Disabled="@running" />
@if (enableBotOperation)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<MudText Typo="Typo.caption">已选 Bot:@selectedBotIds.Count / @bots.Count</MudText>
<MudButton Variant="Variant.Text" Size="Size.Small" Disabled="@running" OnClick="SelectRecommendedBots">选中推荐</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Disabled="@running" OnClick="SelectAllBots">全选</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Disabled="@running" OnClick="ClearSelectedBots">清空</MudButton>
</MudStack>
<MudPaper Outlined="true" Class="pa-2">
<MudGrid>
@foreach (var bot in orderedBots)
{
<MudItem xs="12" sm="6" md="4">
<MudCheckBox T="bool"
Value="@selectedBotIds.Contains(bot.Id)"
ValueChanged="@(v => SetBotSelection(bot.Id, v))"
Disabled="@running"
Label="@BuildBotLabel(bot)" />
</MudItem>
}
</MudGrid>
</MudPaper>
@if (IsJoin)
{
<MudTextField @bind-Value="botStartParameters"
Label="Bot 启动参数(可选)"
Variant="Variant.Outlined"
HelperText="当前版本仅执行启用动作,启动参数暂不生效。"
Disabled="@running" />
}
}
}
@if (running)
{
<MudDivider />
@@ -60,7 +115,12 @@
[Parameter] public string Operation { get; set; } = "join"; // join|leave
private List<Account> accounts = new();
private List<Bot> bots = new();
private List<Bot> orderedBots = new();
private string linksText = "";
private string botStartParameters = "";
private bool enableBotOperation;
private HashSet<int> selectedBotIds = new();
private int delayMs = 2000;
private bool running;
private int total;
@@ -76,11 +136,25 @@
protected override async Task OnInitializedAsync()
{
var ids = (AccountIds ?? new List<int>()).Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
return;
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
accounts = all.Where(a => ids.Contains(a.Id)).ToList();
var loadAccountsTask = AccountManagement.GetAllAccountsAsync();
var loadBotsTask = BotManagement.GetAllBotsAsync();
await Task.WhenAll(loadAccountsTask, loadBotsTask);
if (ids.Count > 0)
{
var all = loadAccountsTask.Result.ToList();
accounts = all.Where(a => ids.Contains(a.Id)).ToList();
}
bots = loadBotsTask.Result.ToList();
orderedBots = bots
.OrderBy(b => b.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(b => b.Id)
.ToList();
SelectRecommendedBots();
}
private async Task Submit()
@@ -89,52 +163,82 @@
return;
var links = ParseLines(linksText);
if (links.Count == 0)
var targetAccounts = accounts.ToList();
var runMembershipOps = links.Count > 0;
var selectedBots = GetSelectedBots();
var runBotOps = enableBotOperation && selectedBots.Count > 0;
if (enableBotOperation && bots.Count > 0 && selectedBots.Count == 0)
{
Snackbar.Add("请至少输入一个频道/群链接", Severity.Warning);
Snackbar.Add("已启用 Bot 批量操作,请至少选择 1 个 Bot", Severity.Warning);
return;
}
// 风控检查:加群/退群 都是敏感操作
var batchCheck = RiskService.CheckBatchAccounts(accounts);
if (batchCheck.HasRiskyAccounts)
if (!runMembershipOps && !runBotOps)
{
var parameters = new DialogParameters
{
["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
["DetailedMessage"] = batchCheck.GetRiskySummary(),
["ShowRecommendations"] = true,
["ShowExcludeOption"] = true,
["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
var dialog = DialogService.Show<RiskWarningDialog>("批量操作风控警告", parameters, options);
var r = await dialog.Result;
if (r.Canceled)
return;
Snackbar.Add("请至少输入一个频道/群链接,或选择 Bot 批量操作", Severity.Warning);
return;
}
if (r.Data is TelegramPanel.Core.Services.RiskWarningAction action
&& action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
// 风控检查:加群/退群是敏感操作(仅在执行账号加入/退出时检查)
if (runMembershipOps)
{
var batchCheck = RiskService.CheckBatchAccounts(targetAccounts);
if (batchCheck.HasRiskyAccounts)
{
accounts = batchCheck.SafeAccounts.ToList();
if (accounts.Count == 0)
var parameters = new DialogParameters
{
Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
["DetailedMessage"] = batchCheck.GetRiskySummary(),
["ShowRecommendations"] = true,
["ShowExcludeOption"] = true,
["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
var dialog = DialogService.Show<RiskWarningDialog>("批量操作风控警告", parameters, options);
var r = await dialog.Result;
if (r is null || r.Canceled)
return;
if (r.Data is TelegramPanel.Core.Services.RiskWarningAction action
&& action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
{
targetAccounts = batchCheck.SafeAccounts.ToList();
if (targetAccounts.Count == 0)
{
Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
return;
}
}
}
}
var membershipTotal = runMembershipOps ? targetAccounts.Count * links.Count : 0;
var botTotal = runBotOps ? selectedBots.Count : 0;
var confirmLines = new List<string>();
if (membershipTotal > 0)
confirmLines.Add($"{(IsJoin ? "加群/订阅" : "退群/退订")}:{targetAccounts.Count} 个账号 × {links.Count} 个目标(共 {membershipTotal} 次操作)");
if (botTotal > 0)
confirmLines.Add($"{(IsJoin ? "启用" : "停用")} Bot:{selectedBots.Count} 个");
if (IsJoin && runBotOps && !string.IsNullOrWhiteSpace(botStartParameters))
confirmLines.Add($"启动参数:{botStartParameters.Trim()}(当前版本暂不生效)");
bool? confirm = await DialogService.ShowMessageBox(
"确认执行",
$"{(IsJoin ? "加群/订阅" : "退群/退订")}:{accounts.Count} 个账号 × {links.Count} 个目标(共 {accounts.Count * links.Count} 次操作)。是否继续?",
$"{string.Join(Environment.NewLine, confirmLines)}{Environment.NewLine}{Environment.NewLine}是否继续?",
yesText: "继续", cancelText: "取消");
if (confirm != true)
return;
if (IsJoin && runBotOps && !string.IsNullOrWhiteSpace(botStartParameters))
Snackbar.Add("已填写启动参数,但当前版本仅执行 Bot 启用,参数暂不生效。", Severity.Info);
running = true;
total = accounts.Count * links.Count;
total = membershipTotal + botTotal;
done = 0;
failed = 0;
currentHint = null;
@@ -144,35 +248,65 @@
try
{
foreach (var a in accounts)
if (runMembershipOps)
{
foreach (var link in links)
foreach (var a in targetAccounts)
{
foreach (var link in links)
{
_cts.Token.ThrowIfCancellationRequested();
currentHint = $"{a.DisplayPhone} => {link}";
StateHasChanged();
(bool Success, string? Error, string? Title) result;
if (IsJoin)
result = await AccountTelegramTools.JoinChatOrChannelAsync(a.Id, link, _cts.Token);
else
result = await AccountTelegramTools.LeaveChatOrChannelAsync(a.Id, link, _cts.Token);
done++;
if (!result.Success)
{
failed++;
failures.Add($"{a.DisplayPhone} => {link}:{result.Error}");
}
StateHasChanged();
var wait = delayMs;
if (wait < 0) wait = 0;
if (wait > 10000) wait = 10000;
var jitter = _rand.Next(0, 350);
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
}
}
}
if (runBotOps)
{
foreach (var bot in selectedBots)
{
_cts.Token.ThrowIfCancellationRequested();
currentHint = $"{a.DisplayPhone} => {link}";
currentHint = $"{(IsJoin ? "启用" : "停用")} Bot => {bot.Name}";
StateHasChanged();
(bool Success, string? Error, string? Title) result;
if (IsJoin)
result = await AccountTelegramTools.JoinChatOrChannelAsync(a.Id, link, _cts.Token);
else
result = await AccountTelegramTools.LeaveChatOrChannelAsync(a.Id, link, _cts.Token);
done++;
if (!result.Success)
try
{
await BotManagement.SetBotActiveStatusAsync(bot.Id, IsJoin);
}
catch (Exception ex)
{
failed++;
failures.Add($"{a.DisplayPhone} => {link}:{result.Error}");
failures.Add($"{(IsJoin ? "启用" : "停用")} Bot {bot.Name} 失败:{ex.Message}");
}
finally
{
done++;
}
StateHasChanged();
var wait = delayMs;
if (wait < 0) wait = 0;
if (wait > 10000) wait = 10000;
var jitter = _rand.Next(0, 350);
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
}
}
@@ -216,6 +350,57 @@
MudDialog.Close();
}
private List<Bot> GetSelectedBots()
{
if (selectedBotIds.Count == 0 || bots.Count == 0)
return new List<Bot>();
return bots
.Where(b => selectedBotIds.Contains(b.Id))
.OrderBy(b => b.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(b => b.Id)
.ToList();
}
private void SelectRecommendedBots()
{
if (bots.Count == 0)
{
selectedBotIds.Clear();
return;
}
selectedBotIds = bots
.Where(b => IsJoin ? !b.IsActive : b.IsActive)
.Select(b => b.Id)
.ToHashSet();
}
private void SelectAllBots()
{
selectedBotIds = bots.Select(b => b.Id).ToHashSet();
}
private void ClearSelectedBots()
{
selectedBotIds.Clear();
}
private void SetBotSelection(int botId, bool selected)
{
if (selected)
selectedBotIds.Add(botId);
else
selectedBotIds.Remove(botId);
}
private static string BuildBotLabel(Bot bot)
{
var username = string.IsNullOrWhiteSpace(bot.Username) ? "-" : $"@{bot.Username}";
var status = bot.IsActive ? "启用" : "停用";
return $"{bot.Name}({username},当前{status})";
}
private static List<string> ParseLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
@@ -229,4 +414,3 @@
.ToList();
}
}
@@ -207,8 +207,8 @@
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@(Busy || selectedAccounts.Count == 0)" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem OnClick="OpenBatchJoinSubscribe">批量加群/订阅(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchLeaveUnsubscribe">批量退群/退订(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchJoinSubscribe">批量加群/订阅/启用Bot(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchLeaveUnsubscribe">批量退群/退订/停用Bot(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeTwoFactorPassword">修改二级密码(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeTwoFactorRecoveryEmail">批量换绑邮箱(找回+登录)(Cloud Mail)(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
@@ -746,7 +746,7 @@
["Operation"] = "join"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量加群/订阅", parameters, options);
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量加群/订阅/启用Bot", parameters, options);
await Task.CompletedTask;
}
@@ -768,7 +768,7 @@
["Operation"] = "leave"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量退群/退订", parameters, options);
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量退群/退订/停用Bot", parameters, options);
await Task.CompletedTask;
}
@@ -77,8 +77,8 @@
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@loading" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchJoinSubscribe">批量加群/订阅(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchLeaveUnsubscribe">批量退群/退订(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchJoinSubscribe">批量加群/订阅/启用Bot(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchLeaveUnsubscribe">批量退群/退订/停用Bot(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeTwoFactorPassword">修改二级密码(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeTwoFactorRecoveryEmail">批量换绑邮箱(找回+登录)(Cloud Mail)(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="BatchKickAllOtherDevices">批量踢出所有其他设备(已选)</MudMenuItem>
@@ -560,7 +560,7 @@
["Operation"] = "join"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量加群/订阅", parameters, options);
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量加群/订阅/启用Bot", parameters, options);
await Task.CompletedTask;
}
@@ -582,7 +582,7 @@
["Operation"] = "leave"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量退群/退订", parameters, options);
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量退群/退订/停用Bot", parameters, options);
await Task.CompletedTask;
}