feat(import): batch actions for imported accounts

This commit is contained in:
meoacgx committed 2026-01-14 01:47:43 +08:00
1 parent ffd8c22e2a
commit 2d3e139eb8
1 file changed
+603
@@ -1,7 +1,17 @@
@page "/accounts/import"
@inject AccountImportService AccountImportService
@inject AccountManagementService AccountManagement
@inject AccountCategoryManagementService CategoryManagement
@inject AccountTelegramToolsService AccountTelegramTools
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject IConfiguration Configuration
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Core.Models
@using TelegramPanel.Core.Utils
@using TelegramPanel.Data.Entities
@using TelegramPanel.Web.Components.Dialogs
@implements IDisposable
<PageTitle>导入账号 - Telegram Panel</PageTitle>
@@ -168,6 +178,82 @@
</MudCard>
}
@if (importedAccounts.Any())
{
<MudCard Class="mt-4">
<MudCardHeader>
<MudText Typo="Typo.h6">已导入账号(可批量操作)</MudText>
<MudSpacer />
<MudButton Variant="Variant.Text" Color="Color.Default" Disabled="@Busy" OnClick="@ClearImportedAccounts">
清空
</MudButton>
</MudCardHeader>
<MudCardContent>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="mb-2" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@SelectionToggleIcon"
Disabled="@(Busy || importedAccounts.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small">
@SelectionToggleText
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync"
Disabled="@(Busy || selectedAccounts.Count == 0)" OnClick="BatchRefreshTelegramStatus" Size="Size.Small">
刷新已选状态(可深度)
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Devices"
Disabled="@(Busy || selectedAccounts.Count == 0)" OnClick="BatchKickAllOtherDevices" Size="Size.Small">
踢出其他设备(已选)
</MudButton>
<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="OpenBatchChangeTwoFactorPassword">修改二级密码(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeTwoFactorRecoveryEmail">批量换绑邮箱(找回+登录)(Cloud Mail)(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
</MudMenu>
@if (selectedAccounts.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="ml-2">已选 @selectedAccounts.Count</MudChip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">共 @importedAccounts.Count 个账号</MudText>
}
</MudStack>
<MudTable Items="@importedAccounts" Hover="true" MultiSelection="true" @bind-SelectedItems="selectedAccounts" Dense="true">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>用户ID</MudTh>
<MudTh>用户名</MudTh>
<MudTh>分类</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@context.DisplayPhone</MudTd>
<MudTd DataLabel="用户ID">@context.UserId</MudTd>
<MudTd DataLabel="用户名">@(context.Username ?? "-")</MudTd>
<MudTd DataLabel="分类">
@if (context.Category != null)
{
<MudChip T="string" Size="Size.Small" Style="@($"background-color: {context.Category.Color ?? "#9E9E9E"}")">
@context.Category.Name
</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">未分类</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
</MudCardContent>
</MudCard>
}
@code {
private List<IBrowserFile> selectedFiles = new();
private bool importing = false;
@@ -181,6 +267,32 @@
private List<ImportResult> importResults = new();
private bool actionLoading = false;
private bool Busy => importing || importingZip || importingString || actionLoading;
private readonly HashSet<string> importedPhones = new(StringComparer.Ordinal);
private List<Account> importedAccounts = new();
private HashSet<Account> selectedAccounts = new();
private List<AccountCategory> categories = new();
private readonly Dictionary<int, TelegramAccountStatusResult> telegramStatus = new();
private readonly CancellationTokenSource _pageCts = new();
public void Dispose()
{
try
{
_pageCts.Cancel();
}
catch
{
// ignore
}
finally
{
_pageCts.Dispose();
}
}
private bool TryGetTelegramApi(out int apiId, out string apiHash)
{
apiHash = Configuration["Telegram:ApiHash"] ?? "";
@@ -215,6 +327,7 @@
var twoFactorPwd = string.IsNullOrWhiteSpace(zipTwoFactorPassword) ? null : zipTwoFactorPassword.Trim();
var results = await AccountImportService.ImportFromZipAsync(zipFile, twoFactorPassword: twoFactorPwd);
importResults.AddRange(results);
await MergeImportedFromResultsAsync(results);
var successCount = results.Count(r => r.Success);
var failureCount = results.Count - successCount;
@@ -257,6 +370,7 @@
);
importResults.AddRange(results);
await MergeImportedFromResultsAsync(results);
var successCount = results.Count(r => r.Success);
var failureCount = results.Count - successCount;
@@ -303,6 +417,7 @@
);
importResults.Add(result);
await MergeImportedFromResultsAsync(new[] { result });
if (result.Success)
{
@@ -323,4 +438,492 @@
importingString = false;
}
}
private async Task MergeImportedFromResultsAsync(IEnumerable<ImportResult> results)
{
var changed = false;
foreach (var r in results)
{
if (!r.Success)
continue;
if (string.IsNullOrWhiteSpace(r.Phone))
continue;
var digits = PhoneNumberFormatter.NormalizeToDigits(r.Phone);
if (string.IsNullOrWhiteSpace(digits))
continue;
if (importedPhones.Add(digits))
changed = true;
}
if (changed)
await ReloadImportedAccountsAsync();
}
private async Task ReloadImportedAccountsAsync()
{
var list = new List<Account>();
foreach (var phone in importedPhones)
{
var a = await AccountManagement.GetAccountByPhoneAsync(phone);
if (a != null)
list.Add(a);
}
importedAccounts = list
.Where(a => a.Id > 0)
.GroupBy(a => a.Id)
.Select(g => g.First())
.OrderByDescending(a => a.Id)
.ToList();
selectedAccounts.Clear();
StateHasChanged();
}
private Task ClearImportedAccounts()
{
importedPhones.Clear();
importedAccounts.Clear();
selectedAccounts.Clear();
telegramStatus.Clear();
return Task.CompletedTask;
}
private Task SelectAllImported()
{
selectedAccounts = importedAccounts.ToHashSet();
return Task.CompletedTask;
}
private Task InvertSelection()
{
var targets = importedAccounts.ToList();
foreach (var a in targets)
{
if (selectedAccounts.Contains(a))
selectedAccounts.Remove(a);
else
selectedAccounts.Add(a);
}
return Task.CompletedTask;
}
private Task ClearSelection()
{
selectedAccounts.Clear();
return Task.CompletedTask;
}
private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
private string SelectionToggleText => _selectionToggleMode switch
{
SelectionToggleMode.SelectAll => "全选本页",
SelectionToggleMode.Invert => "反选本页",
SelectionToggleMode.Clear => "清空选择",
_ => "全选本页"
};
private string SelectionToggleIcon => _selectionToggleMode switch
{
SelectionToggleMode.SelectAll => Icons.Material.Filled.SelectAll,
SelectionToggleMode.Invert => Icons.Material.Filled.Flip,
SelectionToggleMode.Clear => Icons.Material.Filled.Clear,
_ => Icons.Material.Filled.SelectAll
};
private async Task CycleSelectionToggle()
{
switch (_selectionToggleMode)
{
case SelectionToggleMode.SelectAll:
await SelectAllImported();
_selectionToggleMode = SelectionToggleMode.Invert;
break;
case SelectionToggleMode.Invert:
await InvertSelection();
_selectionToggleMode = SelectionToggleMode.Clear;
break;
case SelectionToggleMode.Clear:
await ClearSelection();
_selectionToggleMode = SelectionToggleMode.SelectAll;
break;
default:
_selectionToggleMode = SelectionToggleMode.SelectAll;
break;
}
}
private enum SelectionToggleMode
{
SelectAll = 0,
Invert = 1,
Clear = 2
}
private async Task LoadCategories()
{
categories = (await CategoryManagement.GetAllCategoriesAsync()).ToList();
}
private async Task OpenBatchChangeTwoFactorPassword()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.ChangeTwoFactorPasswordDialog>("批量修改二级密码", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchChangeTwoFactorRecoveryEmail()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeTwoFactorRecoveryEmailDialog>("批量换绑邮箱(找回+登录)", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchSetAccountCategory()
{
if (Busy)
return;
var targets = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (targets.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
if (categories.Count == 0)
await LoadCategories();
var parameters = new DialogParameters
{
["Categories"] = categories,
["SelectedCount"] = targets.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchSetAccountCategoryDialog>("批量修改分类", parameters, options)!;
var result = await dialog.Result;
if (result is not { Canceled: false })
return;
var categoryIdRaw = result.Data is int x ? x : 0;
int? categoryId = categoryIdRaw <= 0 ? null : categoryIdRaw;
actionLoading = true;
try
{
foreach (var id in targets)
await AccountManagement.UpdateAccountCategoryAsync(id, categoryId);
await ReloadImportedAccountsAsync();
Snackbar.Add($"分类已更新:{targets.Count} 个账号", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
}
finally
{
actionLoading = false;
}
}
private async Task OpenBatchChangeNickname()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeNicknameDialog>("批量修改昵称", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeUsername()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeUsernameDialog>("批量修改用户名", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeBio()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeBioDialog>("批量修改 Bio", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchJoinSubscribe()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids,
["Operation"] = "join"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量加群/订阅", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchLeaveUnsubscribe()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids,
["Operation"] = "leave"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量退群/退订", parameters, options);
await Task.CompletedTask;
}
private async Task BatchRefreshTelegramStatus()
{
var targets = selectedAccounts
.Where(a => a.Id > 0)
.GroupBy(a => a.Id)
.Select(g => g.First())
.ToList();
if (targets.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Warning);
return;
}
actionLoading = true;
try
{
bool? probeResult = await DialogService.ShowMessageBox(
"刷新已选 Telegram 状态",
$"将刷新已选 {targets.Count} 个账号的 Telegram 状态。\n\n是否进行深度探测(将对每个账号创建并删除一个测试频道,用于判断【创建频道接口是否被冻结】)?",
yesText: "深度探测",
cancelText: "普通刷新");
var probe = probeResult == true;
// 批量风控检查:深度探测是敏感操作
if (probe && targets.Count > 0)
{
var batchCheck = RiskService.CheckBatchAccounts(targets);
if (batchCheck.HasRiskyAccounts)
{
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 dialogResult = await dialog.Result;
if (dialogResult.Canceled)
{
actionLoading = false;
return;
}
if (dialogResult.Data is TelegramPanel.Core.Services.RiskWarningAction action
&& action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
{
targets = batchCheck.SafeAccounts.ToList();
if (targets.Count == 0)
{
Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
actionLoading = false;
return;
}
}
}
}
foreach (var a in targets)
{
try
{
var result = await AccountTelegramTools.RefreshAccountStatusAsync(a.Id, probeCreateChannel: probe, cancellationToken: _pageCts.Token);
telegramStatus[a.Id] = result;
}
catch (Exception ex)
{
telegramStatus[a.Id] = new TelegramAccountStatusResult(
Ok: false,
Summary: "刷新失败",
Details: ex.Message,
CheckedAtUtc: DateTime.UtcNow);
}
StateHasChanged();
}
await ReloadImportedAccountsAsync();
Snackbar.Add($"批量刷新完成:{targets.Count} 个账号", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"批量刷新失败:{ex.Message}", Severity.Error);
}
finally
{
actionLoading = false;
}
}
private async Task BatchKickAllOtherDevices()
{
if (actionLoading)
return;
var targets = selectedAccounts.ToList();
if (targets.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认踢出",
$"将对 {targets.Count} 个账号执行【踢出所有其他设备】(会保留面板当前会话)。是否继续?",
yesText: "继续", cancelText: "取消");
if (result != true)
return;
actionLoading = true;
try
{
var success = 0;
var failures = new List<string>();
foreach (var a in targets)
{
try
{
var ok = await AccountTelegramTools.KickAllOtherAuthorizationsAsync(a.Id);
if (ok) success++;
else failures.Add($"{a.DisplayPhone}:返回 false");
}
catch (Exception ex)
{
failures.Add($"{a.DisplayPhone}:{ex.Message}");
}
}
selectedAccounts.Clear();
if (failures.Count == 0)
{
Snackbar.Add($"踢出完成:成功 {success}/{targets.Count}", Severity.Success);
return;
}
Snackbar.Add($"踢出完成:成功 {success}/{targets.Count},失败 {failures.Count}", Severity.Warning);
var details = string.Join(Environment.NewLine, failures.Take(80));
if (failures.Count > 80)
details += Environment.NewLine + $"... 仅展示前 80 条(共 {failures.Count} 条)";
await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭");
}
finally
{
actionLoading = false;
}
}
}