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;
+ }
+ }
}