diff --git a/Directory.Build.props b/Directory.Build.props index 537a8fa..7cb3e26 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.2.6 - 1.2.6.0 - 1.2.6.0 - 1.2.6 + 1.2.7 + 1.2.7.0 + 1.2.7.0 + 1.2.7 Telegram Panel Contributors diff --git a/README.md b/README.md index f22c713..d6fdb21 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ - 🔐 **二级密码(2FA)与找回邮箱**:支持单个/批量修改二级密码;支持绑定/换绑 2FA 找回邮箱(验证码确认) - 🧯 **忘记二级密码可申请重置**:支持单个或批量向 Telegram 提交“忘记密码重置”申请(通常等待 7 天后可重新设置) - 🪪 **账号资料管理**:支持单账号编辑昵称/Bio/用户名/头像;支持批量修改昵称(自动追加手机号后 4 位便于区分)与批量修改 Bio -- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量设置管理员;支持对每个 Bot 一键启用/停用(停用后相关后台不再轮询 getUpdates) +- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量邀请成员/设置管理员(可按管理员列表匹配系统账号执行);支持对每个 Bot 一键启用/停用(停用后相关后台不再轮询 getUpdates) +- 🏷️ **账号分类(排除操作)**:支持把“卖号/不参与运营”的账号分类标记为“排除操作”,不会出现在创建频道/批量任务的执行账号下拉里 ## 🧊 防冻结指南(新号必看) diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index d0f249e..abe16ca 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -60,6 +60,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Name).IsRequired().HasMaxLength(100); entity.Property(e => e.Color).HasMaxLength(20); entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.ExcludeFromOperations).HasDefaultValue(false); entity.HasIndex(e => e.Name).IsUnique(); }); diff --git a/src/TelegramPanel.Data/Entities/AccountCategory.cs b/src/TelegramPanel.Data/Entities/AccountCategory.cs index 3d5a197..65c0056 100644 --- a/src/TelegramPanel.Data/Entities/AccountCategory.cs +++ b/src/TelegramPanel.Data/Entities/AccountCategory.cs @@ -9,6 +9,10 @@ public class AccountCategory public string Name { get; set; } = null!; public string? Color { get; set; } public string? Description { get; set; } + /// + /// 排除操作:该分类下的账号不出现在“创建频道/批量邀请/批量设置管理员”等操作的执行账号选择中 + /// + public bool ExcludeFromOperations { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 导航属性 diff --git a/src/TelegramPanel.Data/Migrations/20260102000000_AddAccountCategoryExcludeFromOperations.cs b/src/TelegramPanel.Data/Migrations/20260102000000_AddAccountCategoryExcludeFromOperations.cs new file mode 100644 index 0000000..fea1cb6 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260102000000_AddAccountCategoryExcludeFromOperations.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260102000000_AddAccountCategoryExcludeFromOperations")] + /// + public partial class AddAccountCategoryExcludeFromOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExcludeFromOperations", + table: "AccountCategories", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExcludeFromOperations", + table: "AccountCategories"); + } + } +} + diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 6ad7565..1d6e917 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -112,6 +112,10 @@ namespace TelegramPanel.Data.Migrations .HasMaxLength(500) .HasColumnType("TEXT"); + b.Property("ExcludeFromOperations") + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditDialog.razor index 3c7b528..12c92ba 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditDialog.razor @@ -9,6 +9,8 @@ + @@ -28,12 +30,14 @@ private string name = ""; private MudColor color = "#9E9E9E"; private string? description; + private bool excludeFromOperations; protected override void OnParametersSet() { name = (Category?.Name ?? "").Trim(); description = (Category?.Description ?? "").Trim(); color = string.IsNullOrWhiteSpace(Category?.Color) ? "#9E9E9E" : Category!.Color!; + excludeFromOperations = Category?.ExcludeFromOperations ?? false; } private void Cancel() @@ -46,9 +50,9 @@ var model = new AccountCategoryEditModel( Name: name.Trim(), Color: string.IsNullOrWhiteSpace(color.Value) ? null : color.Value, - Description: string.IsNullOrWhiteSpace(description) ? null : description!.Trim()); + Description: string.IsNullOrWhiteSpace(description) ? null : description!.Trim(), + ExcludeFromOperations: excludeFromOperations); MudDialog.Close(DialogResult.Ok(model)); } } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditModel.cs b/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditModel.cs index 837a34c..12464f3 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditModel.cs +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditModel.cs @@ -1,4 +1,3 @@ namespace TelegramPanel.Web.Components.Dialogs; -public sealed record AccountCategoryEditModel(string Name, string? Color, string? Description); - +public sealed record AccountCategoryEditModel(string Name, string? Color, string? Description, bool ExcludeFromOperations); diff --git a/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchAdminsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchAdminsDialog.razor new file mode 100644 index 0000000..4ed6de8 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchAdminsDialog.razor @@ -0,0 +1,609 @@ +@inject AccountManagementService AccountManagement +@inject IChannelService ChannelService +@inject BotTelegramService BotTelegram +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject TelegramPanel.Web.Services.ChannelAdminDefaultsService DefaultsService +@inject TelegramPanel.Web.Services.ChannelAdminPresetsService PresetsService +@using TelegramPanel.Data.Entities + + + + + + 将使用“执行账号”为所选 Bot 频道批量设置管理员。执行账号必须是该频道管理员(通过 Bot API 获取管理员列表并与系统账号匹配)。 + + 支持:@@usernameusername + + + + 目标频道:@channels.Count 个 + + @if (loadingAdmins) + { + + 正在读取频道管理员列表(Bot API)... + } + + @if (!string.IsNullOrWhiteSpace(adminSummary)) + { + @adminSummary + } + + + + + (不使用预设) + @foreach (var p in Presets) + { + @p.Name + } + + + + + 删除预设 + + + + + + 自动选择(按频道) + @foreach (var a in eligibleAccounts) + { + @FormatAccountLabel(a) + } + + + @if (eligibleAccounts.Count == 0 && !loadingAdmins && channels.Count > 0) + { + + 当前所选频道中未发现“既是频道管理员又在系统中的账号”。你仍可继续执行,但这些频道会被跳过。 + + } + + + + + + + + + + 保存为预设 + + + + + + + 权限(可多选) + + 说明:这里选择的是“你希望目标账号拥有的权限”。如果设置后发现某些权限未生效,面板会提示缺失的权限名称。 + + + + 常用权限 + 全选权限 + 清空 + 保存为默认权限 + + + + + + + + + + + + + + + + + + + + + @if (running) + { + + 进度:@done / @total(失败:@failed) + + @if (!string.IsNullOrWhiteSpace(currentHint)) + { + @currentHint + } + } + + + + + @(running ? "停止" : "关闭") + + + 开始设置 + + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public int BotId { get; set; } + [Parameter] public List Channels { get; set; } = new(); + + private readonly Random _rand = new(); + private readonly List eligibleAccounts = new(); + private readonly Dictionary accountsByUserId = new(); + private readonly Dictionary> channelAdminUserIds = new(); + + private List channels = new(); + private int selectedAccountId; + private string usernamesText = ""; + private string adminTitle = "Admin"; + private int delayMs = 1500; + private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。"; + + private bool loadingAdmins; + private string? adminSummary; + + private bool loadingPresets; + private List Presets { get; set; } = new(); + private string selectedPresetName = ""; + private string presetNameToSave = ""; + + private RightsModel rights = new(); + private static readonly AdminRights FullRights = + AdminRights.ChangeInfo + | AdminRights.PostMessages + | AdminRights.EditMessages + | AdminRights.DeleteMessages + | AdminRights.BanUsers + | AdminRights.InviteUsers + | AdminRights.PinMessages + | AdminRights.ManageCall + | AdminRights.AddAdmins + | AdminRights.Anonymous + | AdminRights.ManageTopics; + + private bool running; + private int total; + private int done; + private int failed; + private string? currentHint; + private CancellationTokenSource? _cts; + + private int Progress => total <= 0 ? 0 : (int)Math.Round(done * 100d / total); + + private AdminRights SelectedRights => rights.ToRights(); + + protected override async Task OnInitializedAsync() + { + channels = (Channels ?? new List()) + .Where(x => x != null) + .GroupBy(x => x.TelegramId) + .Select(x => x.First()) + .ToList(); + + await LoadAccountsAsync(); + + // 默认权限:优先读取上次保存的默认值;否则使用“常用权限” + var defaults = await DefaultsService.GetAsync(); + ApplyRights(defaults?.Rights ?? AdminRights.BasicAdmin); + + await ReloadPresetsAsync(); + + if (BotId > 0 && channels.Count > 0) + await LoadAdminsAndEligibleAccountsAsync(); + } + + private async Task LoadAccountsAsync() + { + var all = (await AccountManagement.GetAllAccountsAsync()).ToList(); + accountsByUserId.Clear(); + foreach (var a in all.Where(x => x.IsActive && x.Category?.ExcludeFromOperations != true)) + { + if (a.UserId <= 0) + continue; + if (!accountsByUserId.ContainsKey(a.UserId)) + accountsByUserId[a.UserId] = a; + } + } + + private async Task LoadAdminsAndEligibleAccountsAsync() + { + loadingAdmins = true; + adminSummary = null; + eligibleAccounts.Clear(); + channelAdminUserIds.Clear(); + try + { + var eligibleUserIds = new HashSet(); + var matchedChannels = 0; + + foreach (var ch in channels) + { + var adminIds = new HashSet(); + try + { + var admins = await BotTelegram.GetChatAdminsAsync(BotId, ch.TelegramId, CancellationToken.None); + foreach (var a in admins) + { + adminIds.Add(a.UserId); + if (accountsByUserId.ContainsKey(a.UserId)) + eligibleUserIds.Add(a.UserId); + } + + if (admins.Any(a => accountsByUserId.ContainsKey(a.UserId))) + matchedChannels++; + } + catch + { + // ignore + } + + channelAdminUserIds[ch.TelegramId] = adminIds; + } + + foreach (var uid in eligibleUserIds) + { + if (accountsByUserId.TryGetValue(uid, out var acc)) + eligibleAccounts.Add(acc); + } + + eligibleAccounts.Sort((a, b) => string.Compare(FormatAccountLabel(a), FormatAccountLabel(b), StringComparison.OrdinalIgnoreCase)); + adminSummary = $"可用执行账号:{eligibleAccounts.Count} 个;可执行频道:{matchedChannels}/{channels.Count}。"; + } + finally + { + loadingAdmins = false; + } + } + + private async Task ReloadPresetsAsync() + { + loadingPresets = true; + try + { + Presets = (await PresetsService.GetPresetsAsync()).ToList(); + } + finally + { + loadingPresets = false; + } + } + + private Task OnPresetChanged(string? name) + { + selectedPresetName = name ?? ""; + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return Task.CompletedTask; + + var p = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase)); + if (p != null) + usernamesText = string.Join(Environment.NewLine, p.Usernames.Select(x => $"@{x}")); + + return Task.CompletedTask; + } + + private async Task SavePreset() + { + var name = (presetNameToSave ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + Snackbar.Add("请输入预设名称", Severity.Warning); + return; + } + + var usernames = ParseUsernames(usernamesText); + if (usernames.Count == 0) + { + Snackbar.Add("请至少输入一个用户名", Severity.Warning); + return; + } + + try + { + await PresetsService.SavePresetAsync(name, usernames); + Snackbar.Add("已保存预设", Severity.Success); + presetNameToSave = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"保存预设失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteSelectedPreset() + { + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return; + + bool? ok = await DialogService.ShowMessageBox( + title: "删除预设", + message: $"确定删除预设“{selectedPresetName}”吗?", + yesText: "删除", + cancelText: "取消"); + + if (ok != true) + return; + + try + { + await PresetsService.DeletePresetAsync(selectedPresetName); + Snackbar.Add("已删除预设", Severity.Success); + selectedPresetName = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error); + } + } + + private void ApplyRights(AdminRights selected) + { + rights = RightsModel.FromRights(selected); + } + + private async Task SaveAsDefaultRightsAsync() + { + try + { + await DefaultsService.SaveAsync(new TelegramPanel.Web.Services.ChannelAdminDefaults(SelectedRights)); + Snackbar.Add("已保存默认权限", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + } + + private async Task Submit() + { + if (running) + return; + + if (BotId <= 0) + { + Snackbar.Add("BotId 无效", Severity.Error); + return; + } + + var usernames = ParseUsernames(usernamesText); + if (usernames.Count == 0) + { + Snackbar.Add("请至少输入一个用户名", Severity.Warning); + return; + } + + if (channels.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + var title = string.IsNullOrWhiteSpace(adminTitle) ? "Admin" : adminTitle.Trim(); + + bool? confirm = await DialogService.ShowMessageBox( + "确认执行", + $"设置管理员:{channels.Count} 个频道 × {usernames.Count} 个用户(共 {channels.Count * usernames.Count} 次操作)。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + running = true; + total = channels.Count * usernames.Count; + done = 0; + failed = 0; + currentHint = null; + _cts = new CancellationTokenSource(); + + var failures = new List(); + + try + { + foreach (var ch in channels) + { + var executorId = ResolveExecutorAccountId(ch); + if (executorId == null) + { + foreach (var _ in usernames) + { + done++; + failed++; + } + + failures.Add($"{ch.Title}:无可用执行账号(需要该频道管理员且存在于系统账号)"); + StateHasChanged(); + continue; + } + + foreach (var username in usernames) + { + _cts.Token.ThrowIfCancellationRequested(); + + currentHint = $"{ch.Title} => {username}"; + StateHasChanged(); + + try + { + var ok = await ChannelService.SetAdminAsync(executorId.Value, ch.TelegramId, username, SelectedRights, title); + if (!ok) + { + failed++; + failures.Add($"{ch.Title} => {username}:失败"); + } + } + catch (Exception ex) + { + failed++; + failures.Add($"{ch.Title} => {username}:{ex.Message}"); + } + finally + { + done++; + } + + StateHasChanged(); + + var wait = delayMs; + if (wait < 0) wait = 0; + if (wait > 30000) wait = 30000; + var jitter = _rand.Next(500, 1000); + await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token); + } + } + + var summary = $"完成:{done}/{total}(失败:{failed})"; + Snackbar.Add(summary, failed == 0 ? Severity.Success : Severity.Warning); + + if (failures.Count > 0) + { + var details = string.Join(Environment.NewLine, failures.Take(80)); + await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭"); + } + + MudDialog.Close(DialogResult.Ok(new { Done = done, Failed = failed })); + } + catch (OperationCanceledException) + { + Snackbar.Add("已停止执行", Severity.Info); + } + catch (Exception ex) + { + Snackbar.Add($"执行异常:{ex.Message}", Severity.Error); + } + finally + { + running = false; + currentHint = null; + _cts?.Dispose(); + _cts = null; + StateHasChanged(); + } + } + + private int? ResolveExecutorAccountId(BotChannel channel) + { + if (!channelAdminUserIds.TryGetValue(channel.TelegramId, out var adminIds) || adminIds.Count == 0) + return null; + + if (selectedAccountId > 0) + { + var selected = eligibleAccounts.FirstOrDefault(x => x.Id == selectedAccountId); + if (selected == null || selected.UserId <= 0) + return null; + + return adminIds.Contains(selected.UserId) ? selected.Id : null; + } + + foreach (var uid in adminIds) + { + if (accountsByUserId.TryGetValue(uid, out var acc)) + return acc.Id; + } + + return null; + } + + private void CancelOrClose() + { + if (running) + { + _cts?.Cancel(); + return; + } + + MudDialog.Close(); + } + + private static List ParseUsernames(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.StartsWith("@", StringComparison.Ordinal) ? x.Substring(1) : x) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string FormatAccountLabel(Account account) + { + var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim(); + var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim(); + + var namePart = nickname ?? account.Phone; + if (!string.IsNullOrWhiteSpace(username)) + return $"{namePart} (@{username})"; + return namePart; + } + + private sealed class RightsModel + { + public bool ChangeInfo { get; set; } + public bool PostMessages { get; set; } + public bool EditMessages { get; set; } + public bool DeleteMessages { get; set; } + public bool BanUsers { get; set; } + public bool InviteUsers { get; set; } + public bool PinMessages { get; set; } + public bool ManageCall { get; set; } + public bool AddAdmins { get; set; } + public bool Anonymous { get; set; } + public bool ManageTopics { get; set; } + + public AdminRights ToRights() + { + var r = AdminRights.None; + if (ChangeInfo) r |= AdminRights.ChangeInfo; + if (PostMessages) r |= AdminRights.PostMessages; + if (EditMessages) r |= AdminRights.EditMessages; + if (DeleteMessages) r |= AdminRights.DeleteMessages; + if (BanUsers) r |= AdminRights.BanUsers; + if (InviteUsers) r |= AdminRights.InviteUsers; + if (PinMessages) r |= AdminRights.PinMessages; + if (ManageCall) r |= AdminRights.ManageCall; + if (AddAdmins) r |= AdminRights.AddAdmins; + if (Anonymous) r |= AdminRights.Anonymous; + if (ManageTopics) r |= AdminRights.ManageTopics; + return r; + } + + public static RightsModel FromRights(AdminRights rights) + { + return new RightsModel + { + ChangeInfo = rights.HasFlag(AdminRights.ChangeInfo), + PostMessages = rights.HasFlag(AdminRights.PostMessages), + EditMessages = rights.HasFlag(AdminRights.EditMessages), + DeleteMessages = rights.HasFlag(AdminRights.DeleteMessages), + BanUsers = rights.HasFlag(AdminRights.BanUsers), + InviteUsers = rights.HasFlag(AdminRights.InviteUsers), + PinMessages = rights.HasFlag(AdminRights.PinMessages), + ManageCall = rights.HasFlag(AdminRights.ManageCall), + AddAdmins = rights.HasFlag(AdminRights.AddAdmins), + Anonymous = rights.HasFlag(AdminRights.Anonymous), + ManageTopics = rights.HasFlag(AdminRights.ManageTopics) + }; + } + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchInviteDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchInviteDialog.razor new file mode 100644 index 0000000..94a02a2 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchInviteDialog.razor @@ -0,0 +1,490 @@ +@inject AccountManagementService AccountManagement +@inject IChannelService ChannelService +@inject BotTelegramService BotTelegram +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject TelegramPanel.Web.Services.ChannelInvitePresetsService PresetsService +@using TelegramPanel.Data.Entities + + + + + + 将使用“执行账号”邀请用户加入所选 Bot 频道。执行账号必须是该频道管理员(通过 Bot API 获取管理员列表并与系统账号匹配)。 + + 支持:@@usernameusername + + + + 目标频道:@channels.Count 个 + + @if (loadingAdmins) + { + + 正在读取频道管理员列表(Bot API)... + } + + @if (!string.IsNullOrWhiteSpace(adminSummary)) + { + @adminSummary + } + + + + + (不使用预设) + @foreach (var p in Presets) + { + @p.Name + } + + + + + 删除预设 + + + + + + 自动选择(按频道) + @foreach (var a in eligibleAccounts) + { + @FormatAccountLabel(a) + } + + + @if (eligibleAccounts.Count == 0 && !loadingAdmins && channels.Count > 0) + { + + 当前所选频道中未发现“既是频道管理员又在系统中的账号”。你仍可继续执行,但这些频道会被跳过。 + + } + + + + + + + + + + 保存为预设 + + + + + + + @if (running) + { + + 进度:@done / @total(失败:@failed) + + @if (!string.IsNullOrWhiteSpace(currentHint)) + { + @currentHint + } + } + + + + + @(running ? "停止" : "关闭") + + + 开始邀请 + + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public int BotId { get; set; } + [Parameter] public List Channels { get; set; } = new(); + + private readonly Random _rand = new(); + private readonly List eligibleAccounts = new(); + private readonly Dictionary accountsByUserId = new(); + private readonly Dictionary> channelAdminUserIds = new(); + + private List channels = new(); + private int selectedAccountId; + private string usernamesText = ""; + private int delayMs = 2000; + private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。"; + + private bool loadingAdmins; + private string? adminSummary; + + private bool loadingPresets; + private List Presets { get; set; } = new(); + private string selectedPresetName = ""; + private string presetNameToSave = ""; + + private bool running; + private int total; + private int done; + private int failed; + private string? currentHint; + private CancellationTokenSource? _cts; + + private int Progress => total <= 0 ? 0 : (int)Math.Round(done * 100d / total); + + protected override async Task OnInitializedAsync() + { + channels = (Channels ?? new List()) + .Where(x => x != null) + .GroupBy(x => x.TelegramId) + .Select(x => x.First()) + .ToList(); + + await LoadAccountsAsync(); + await ReloadPresetsAsync(); + + if (BotId > 0 && channels.Count > 0) + await LoadAdminsAndEligibleAccountsAsync(); + } + + private async Task LoadAccountsAsync() + { + var all = (await AccountManagement.GetAllAccountsAsync()).ToList(); + accountsByUserId.Clear(); + foreach (var a in all) + { + if (!a.IsActive) + continue; + if (a.Category?.ExcludeFromOperations == true) + continue; + + if (a.UserId <= 0) + continue; + if (!accountsByUserId.ContainsKey(a.UserId)) + accountsByUserId[a.UserId] = a; + } + } + + private async Task LoadAdminsAndEligibleAccountsAsync() + { + loadingAdmins = true; + adminSummary = null; + eligibleAccounts.Clear(); + channelAdminUserIds.Clear(); + try + { + var eligibleUserIds = new HashSet(); + var matchedChannels = 0; + + foreach (var ch in channels) + { + var adminIds = new HashSet(); + try + { + var admins = await BotTelegram.GetChatAdminsAsync(BotId, ch.TelegramId, CancellationToken.None); + foreach (var a in admins) + { + adminIds.Add(a.UserId); + if (accountsByUserId.ContainsKey(a.UserId)) + eligibleUserIds.Add(a.UserId); + } + + if (admins.Any(a => accountsByUserId.ContainsKey(a.UserId))) + matchedChannels++; + } + catch + { + // ignore: 无权限/风控/频道已失效等,后续按“无可用执行账号”处理 + } + + channelAdminUserIds[ch.TelegramId] = adminIds; + } + + foreach (var uid in eligibleUserIds) + { + if (accountsByUserId.TryGetValue(uid, out var acc)) + eligibleAccounts.Add(acc); + } + + eligibleAccounts.Sort((a, b) => string.Compare(FormatAccountLabel(a), FormatAccountLabel(b), StringComparison.OrdinalIgnoreCase)); + adminSummary = $"可用执行账号:{eligibleAccounts.Count} 个;可执行频道:{matchedChannels}/{channels.Count}。"; + } + finally + { + loadingAdmins = false; + } + } + + private async Task ReloadPresetsAsync() + { + loadingPresets = true; + try + { + Presets = (await PresetsService.GetPresetsAsync()).ToList(); + } + finally + { + loadingPresets = false; + } + } + + private Task OnPresetChanged(string value) + { + selectedPresetName = value ?? ""; + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return Task.CompletedTask; + + var preset = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase)); + if (preset != null) + usernamesText = string.Join(Environment.NewLine, preset.Usernames); + + return Task.CompletedTask; + } + + private async Task SavePreset() + { + var name = (presetNameToSave ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + Snackbar.Add("请输入预设名称", Severity.Warning); + return; + } + + var usernames = ParseUsernames(usernamesText); + if (usernames.Count == 0) + { + Snackbar.Add("请至少输入一个用户名", Severity.Warning); + return; + } + + try + { + await PresetsService.SavePresetAsync(name, usernames); + Snackbar.Add("已保存预设", Severity.Success); + presetNameToSave = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"保存预设失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteSelectedPreset() + { + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return; + + bool? ok = await DialogService.ShowMessageBox( + title: "删除预设", + message: $"确定删除预设“{selectedPresetName}”吗?", + yesText: "删除", + cancelText: "取消"); + + if (ok != true) + return; + + try + { + await PresetsService.DeletePresetAsync(selectedPresetName); + Snackbar.Add("已删除预设", Severity.Success); + selectedPresetName = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error); + } + } + + private async Task Submit() + { + if (running) + return; + + if (BotId <= 0) + { + Snackbar.Add("BotId 无效", Severity.Error); + return; + } + + var usernames = ParseUsernames(usernamesText); + if (usernames.Count == 0) + { + Snackbar.Add("请至少输入一个用户名", Severity.Warning); + return; + } + + if (channels.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + bool? confirm = await DialogService.ShowMessageBox( + "确认执行", + $"邀请:{channels.Count} 个频道 × {usernames.Count} 个用户(共 {channels.Count * usernames.Count} 次操作)。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + running = true; + total = channels.Count * usernames.Count; + done = 0; + failed = 0; + currentHint = null; + _cts = new CancellationTokenSource(); + + var failures = new List(); + + try + { + foreach (var ch in channels) + { + var executorId = ResolveExecutorAccountId(ch); + if (executorId == null) + { + foreach (var _ in usernames) + { + done++; + failed++; + } + + failures.Add($"{ch.Title}:无可用执行账号(需要该频道管理员且存在于系统账号)"); + StateHasChanged(); + continue; + } + + foreach (var username in usernames) + { + _cts.Token.ThrowIfCancellationRequested(); + + currentHint = $"{ch.Title} => {username}"; + StateHasChanged(); + + try + { + var result = await ChannelService.InviteUserAsync(executorId.Value, ch.TelegramId, username); + if (!result.Success) + { + failed++; + failures.Add($"{ch.Title} => {username}:{result.Error}"); + } + } + catch (Exception ex) + { + failed++; + failures.Add($"{ch.Title} => {username}:{ex.Message}"); + } + finally + { + done++; + } + + StateHasChanged(); + + var wait = delayMs; + if (wait < 0) wait = 0; + if (wait > 30000) wait = 30000; + var jitter = _rand.Next(500, 1500); + await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token); + } + } + + var summary = $"完成:{done}/{total}(失败:{failed})"; + Snackbar.Add(summary, failed == 0 ? Severity.Success : Severity.Warning); + + if (failures.Count > 0) + { + var details = string.Join(Environment.NewLine, failures.Take(80)); + await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭"); + } + + MudDialog.Close(DialogResult.Ok(new { Done = done, Failed = failed })); + } + catch (OperationCanceledException) + { + Snackbar.Add("已停止执行", Severity.Info); + } + catch (Exception ex) + { + Snackbar.Add($"执行异常:{ex.Message}", Severity.Error); + } + finally + { + running = false; + currentHint = null; + _cts?.Dispose(); + _cts = null; + StateHasChanged(); + } + } + + private int? ResolveExecutorAccountId(BotChannel channel) + { + if (!channelAdminUserIds.TryGetValue(channel.TelegramId, out var adminIds) || adminIds.Count == 0) + return null; + + if (selectedAccountId > 0) + { + var selected = eligibleAccounts.FirstOrDefault(x => x.Id == selectedAccountId); + if (selected == null || selected.UserId <= 0) + return null; + + return adminIds.Contains(selected.UserId) ? selected.Id : null; + } + + foreach (var uid in adminIds) + { + if (accountsByUserId.TryGetValue(uid, out var acc)) + return acc.Id; + } + + return null; + } + + private void CancelOrClose() + { + if (running) + { + _cts?.Cancel(); + return; + } + + MudDialog.Close(); + } + + private static List ParseUsernames(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.StartsWith("@", StringComparison.Ordinal) ? x.Substring(1) : x) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string FormatAccountLabel(Account account) + { + var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim(); + var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim(); + + var namePart = nickname ?? account.Phone; + if (!string.IsNullOrWhiteSpace(username)) + return $"{namePart} (@{username})"; + return namePart; + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor index 70d6bbe..6a5fb06 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor @@ -11,7 +11,7 @@ - 将使用“执行账号”为所选频道批量设置管理员。支持输入多个用户名,换行分隔。 + 将使用“执行账号”为所选频道批量设置管理员(默认:每个频道的创建账号)。支持输入多个用户名,换行分隔。 支持:@@usernameusername @@ -39,6 +39,7 @@ + 每个频道创建账号(默认) @foreach (var a in accounts) { @FormatAccountLabel(a) @@ -173,12 +174,8 @@ .ToList(); var all = (await AccountManagement.GetAllAccountsAsync()).ToList(); - accounts.AddRange(all.Where(a => a.IsActive)); - - if (DefaultAccountId > 0 && accounts.Any(a => a.Id == DefaultAccountId)) - selectedAccountId = DefaultAccountId; - else - selectedAccountId = accounts.FirstOrDefault()?.Id ?? 0; + accounts.AddRange(all.Where(a => a.IsActive && a.Category?.ExcludeFromOperations != true)); + selectedAccountId = 0; // 默认权限:优先读取上次保存的默认值;否则使用“常用权限” var defaults = await DefaultsService.GetAsync(); @@ -187,6 +184,21 @@ await ReloadPresetsAsync(); } + private int? ResolveExecuteAccountId(Channel channel) + { + if (channel == null) + return null; + + if (selectedAccountId > 0) + return selectedAccountId; + + var creatorId = channel.CreatorAccountId; + if (creatorId == null || creatorId.Value <= 0) + return null; + + return accounts.Any(a => a.Id == creatorId.Value) ? creatorId.Value : null; + } + private async Task ReloadPresetsAsync() { loadingPresets = true; @@ -291,12 +303,6 @@ if (running) return; - if (selectedAccountId <= 0) - { - Snackbar.Add("请选择一个执行账号", Severity.Warning); - return; - } - var usernames = ParseUsernames(usernamesText); if (usernames.Count == 0) { @@ -335,6 +341,16 @@ { foreach (var ch in channels) { + var executeAccountId = ResolveExecuteAccountId(ch); + if (executeAccountId == null) + { + done += usernames.Count; + failed += usernames.Count; + failures.Add($"{ch.Title}:无可用执行账号(将使用创建账号执行;但该频道无创建账号/创建账号不可用)"); + StateHasChanged(); + continue; + } + foreach (var username in usernames) { _cts.Token.ThrowIfCancellationRequested(); @@ -344,7 +360,7 @@ try { - var ok = await ChannelService.SetAdminAsync(selectedAccountId, ch.TelegramId, username, SelectedRights, title); + var ok = await ChannelService.SetAdminAsync(executeAccountId.Value, ch.TelegramId, username, SelectedRights, title); if (!ok) { failed++; diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor index 71b8db8..9b44ae8 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor @@ -10,7 +10,7 @@ - 将使用“执行账号”邀请用户加入所选频道。支持输入多个用户名,换行分隔。 + 将使用“执行账号”邀请用户加入所选频道(默认:每个频道的创建账号)。支持输入多个用户名,换行分隔。 支持:@@usernameusername @@ -38,6 +38,7 @@ + 每个频道创建账号(默认) @foreach (var a in accounts) { @FormatAccountLabel(a) @@ -123,16 +124,27 @@ .ToList(); var all = (await AccountManagement.GetAllAccountsAsync()).ToList(); - accounts.AddRange(all.Where(a => a.IsActive)); - - if (DefaultAccountId > 0 && accounts.Any(a => a.Id == DefaultAccountId)) - selectedAccountId = DefaultAccountId; - else - selectedAccountId = accounts.FirstOrDefault()?.Id ?? 0; + accounts.AddRange(all.Where(a => a.IsActive && a.Category?.ExcludeFromOperations != true)); + selectedAccountId = 0; await ReloadPresetsAsync(); } + private int? ResolveExecuteAccountId(Channel channel) + { + if (channel == null) + return null; + + if (selectedAccountId > 0) + return selectedAccountId; + + var creatorId = channel.CreatorAccountId; + if (creatorId == null || creatorId.Value <= 0) + return null; + + return accounts.Any(a => a.Id == creatorId.Value) ? creatorId.Value : null; + } + private async Task ReloadPresetsAsync() { loadingPresets = true; @@ -218,12 +230,6 @@ if (running) return; - if (selectedAccountId <= 0) - { - Snackbar.Add("请选择一个执行账号", Severity.Warning); - return; - } - var usernames = ParseUsernames(usernamesText); if (usernames.Count == 0) { @@ -258,6 +264,16 @@ { foreach (var ch in channels) { + var executeAccountId = ResolveExecuteAccountId(ch); + if (executeAccountId == null) + { + done += usernames.Count; + failed += usernames.Count; + failures.Add($"{ch.Title}:无可用执行账号(将使用创建账号执行;但该频道无创建账号/创建账号不可用)"); + StateHasChanged(); + continue; + } + foreach (var username in usernames) { _cts.Token.ThrowIfCancellationRequested(); @@ -267,7 +283,7 @@ try { - var result = await ChannelService.InviteUserAsync(selectedAccountId, ch.TelegramId, username); + var result = await ChannelService.InviteUserAsync(executeAccountId.Value, ch.TelegramId, username); if (!result.Success) { failed++; diff --git a/src/TelegramPanel.Web/Components/Pages/AccountCategories.razor b/src/TelegramPanel.Web/Components/Pages/AccountCategories.razor index 398a32d..d0f0917 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountCategories.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountCategories.razor @@ -19,6 +19,8 @@ + 分类名称 颜色 描述 + 排除操作 账号数量 操作 @@ -51,6 +54,16 @@
@(context.Description ?? "-") + + @if (context.ExcludeFromOperations) + { + + } + else + { + - + } + @context.Accounts.Count categories = new(); private bool loading = true; @@ -215,6 +229,7 @@ Name = newCategoryName, Color = newCategoryColor.Value, Description = newCategoryDesc, + ExcludeFromOperations = newCategoryExcludeFromOperations, CreatedAt = DateTime.UtcNow }; @@ -226,6 +241,7 @@ newCategoryName = ""; newCategoryColor = "#1976d2"; newCategoryDesc = ""; + newCategoryExcludeFromOperations = false; } catch (Exception ex) { @@ -290,6 +306,7 @@ category.Name = model.Name.Trim(); category.Color = model.Color; category.Description = model.Description; + category.ExcludeFromOperations = model.ExcludeFromOperations; await CategoryManagement.UpdateCategoryAsync(category); await LoadCategories(); diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor index c3d0225..1157541 100644 --- a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor +++ b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor @@ -65,7 +65,9 @@ 导出邀请(已选) - 批量设置管理员(已选) + 批量邀请成员(已选) + 批量设置管理员(账号执行) + 批量设置管理员(机器人/ID) 批量设置分类(已选) 踢出用户(已选) @@ -450,6 +452,58 @@ } } + private async Task OpenBatchInviteMembers() + { + if (selectedBotId <= 0) + return; + + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["BotId"] = selectedBotId, + ["Channels"] = selected.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量邀请成员", parameters, options); + var result = await dialog.Result; + if (!result.Canceled) + { + // 邀请不影响本地数据,但用户可能期望刷新一下 + await LoadBotData(); + } + } + + private async Task OpenBatchSetAdminsByAccount() + { + if (selectedBotId <= 0) + return; + + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["BotId"] = selectedBotId, + ["Channels"] = selected.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量设置管理员(账号执行)", parameters, options); + var result = await dialog.Result; + if (!result.Canceled) + { + // 管理员设置后不影响本地数据,但用户可能期望刷新一下 + await LoadBotData(); + } + } + private async Task OpenBatchSetCategory() { if (selectedBotId <= 0) diff --git a/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor b/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor index 2c51de0..f31c624 100644 --- a/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor +++ b/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor @@ -101,7 +101,9 @@ try { var accountList = await AccountManagement.GetActiveAccountsAsync(); - accounts = accountList.ToList(); + accounts = accountList + .Where(a => a.Category?.ExcludeFromOperations != true) + .ToList(); } catch (Exception ex) {