diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index 7082651..5af1bcf 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -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 导入账号 - Telegram Panel @@ -168,6 +178,82 @@ } +@if (importedAccounts.Any()) +{ + + + 已导入账号(可批量操作) + + + 清空 + + + + + + @SelectionToggleText + + + 刷新已选状态(可深度) + + + 踢出其他设备(已选) + + + + 批量加群/订阅(已选) + 批量退群/退订(已选) + 修改二级密码(已选) + 批量换绑邮箱(找回+登录)(Cloud Mail)(已选) + 批量修改分类(已选) + 批量改昵称(已选) + 批量改用户名(已选) + 批量改Bio(已选) + + + @if (selectedAccounts.Count > 0) + { + 已选 @selectedAccounts.Count + } + else + { + 共 @importedAccounts.Count 个账号 + } + + + + + 手机号 + 用户ID + 用户名 + 分类 + + + @context.DisplayPhone + @context.UserId + @(context.Username ?? "-") + + @if (context.Category != null) + { + + @context.Category.Name + + } + else + { + 未分类 + } + + + + + +} + @code { private List selectedFiles = new(); private bool importing = false; @@ -181,6 +267,32 @@ private List importResults = new(); + private bool actionLoading = false; + private bool Busy => importing || importingZip || importingString || actionLoading; + + private readonly HashSet importedPhones = new(StringComparer.Ordinal); + private List importedAccounts = new(); + private HashSet selectedAccounts = new(); + private List categories = new(); + private readonly Dictionary 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 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(); + 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("批量修改二级密码", 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("批量换绑邮箱(找回+登录)", 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("批量修改分类", 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("批量修改昵称", 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("批量修改用户名", 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("批量修改 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("批量加群/订阅", 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("批量退群/退订", 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("批量操作风控警告", 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(); + + 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; + } + } }