diff --git a/Directory.Build.props b/Directory.Build.props index 099b50e..d4d98a6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.1.1 - 1.1.1.0 - 1.1.1.0 - 1.1.1 + 1.2.0 + 1.2.0.0 + 1.2.0.0 + 1.2.0 Telegram Panel Contributors diff --git a/src/TelegramPanel.Core/Services/AccountManagementService.cs b/src/TelegramPanel.Core/Services/AccountManagementService.cs index 1f22983..b06a17e 100644 --- a/src/TelegramPanel.Core/Services/AccountManagementService.cs +++ b/src/TelegramPanel.Core/Services/AccountManagementService.cs @@ -58,6 +58,30 @@ public class AccountManagementService return list; } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryAccountsPagedAsync( + int? categoryId, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + var (items, total) = await _accountRepository.QueryPagedAsync(categoryId, search, pageIndex, pageSize, cancellationToken); + foreach (var a in items) + FormatPhoneForDisplay(a); + return (items, total); + } + + public async Task> QueryAccountsAsync( + int? categoryId, + string? search, + CancellationToken cancellationToken = default) + { + var list = await _accountRepository.QueryAsync(categoryId, search, cancellationToken); + foreach (var a in list) + FormatPhoneForDisplay(a); + return list; + } + public async Task> GetActiveAccountsAsync() { var list = (await _accountRepository.GetActiveAccountsAsync()).ToList(); diff --git a/src/TelegramPanel.Core/Services/BotManagementService.cs b/src/TelegramPanel.Core/Services/BotManagementService.cs index a8c8a3e..acd9f87 100644 --- a/src/TelegramPanel.Core/Services/BotManagementService.cs +++ b/src/TelegramPanel.Core/Services/BotManagementService.cs @@ -132,6 +132,24 @@ public class BotManagementService return list.Where(x => x.IsBroadcast); } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryChannelsPagedAsync( + int botId, + int? categoryId, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + return await _botChannelRepository.QueryPagedAsync( + botId: botId, + categoryId: categoryId, + broadcastOnly: true, + search: search, + pageIndex: pageIndex, + pageSize: pageSize, + cancellationToken: cancellationToken); + } + /// /// 获取 Bot 可管理的聊天列表(包含频道/群组/超级群组)。 /// diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index e48a5e8..4741f47 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -43,6 +43,17 @@ public class ChannelManagementService return await _channelRepository.GetByCreatorAccountAsync(accountId); } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryChannelsForViewPagedAsync( + int creatorAccountId, + string? filterType, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + return await _channelRepository.QueryForViewPagedAsync(creatorAccountId, filterType, search, pageIndex, pageSize, cancellationToken); + } + public async Task> GetChannelsByCreatorAsync(int accountId) { return await _channelRepository.GetByCreatorAccountAsync(accountId); diff --git a/src/TelegramPanel.Data/Repositories/AccountRepository.cs b/src/TelegramPanel.Data/Repositories/AccountRepository.cs index 6c369e8..58d69ba 100644 --- a/src/TelegramPanel.Data/Repositories/AccountRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountRepository.cs @@ -12,6 +12,41 @@ public class AccountRepository : Repository, IAccountRepository { } + private IQueryable BuildQuery(int? categoryId, string? search) + { + var query = _dbSet + .AsNoTracking() + .Include(a => a.Category) + .AsQueryable(); + + if (categoryId.HasValue && categoryId.Value > 0) + query = query.Where(a => a.CategoryId == categoryId.Value); + + search = (search ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(search)) + { + // 注意:SQLite LIKE 在默认 collation 下对 ASCII 通常不区分大小写;这里用 Like 保持可翻译性与可读性 + var like = $"%{search}%"; + if (long.TryParse(search, out var uid) && uid > 0) + { + query = query.Where(a => + a.UserId == uid + || EF.Functions.Like(a.Phone, like) + || (a.Nickname != null && EF.Functions.Like(a.Nickname, like)) + || (a.Username != null && EF.Functions.Like(a.Username, like))); + } + else + { + query = query.Where(a => + EF.Functions.Like(a.Phone, like) + || (a.Nickname != null && EF.Functions.Like(a.Nickname, like)) + || (a.Username != null && EF.Functions.Like(a.Username, like))); + } + } + + return query.OrderByDescending(a => a.Id); + } + public override async Task GetByIdAsync(int id) { return await _dbSet @@ -57,4 +92,34 @@ public class AccountRepository : Repository, IAccountRepository .Where(a => a.IsActive) .ToListAsync(); } + + public async Task<(IReadOnlyList Items, int TotalCount)> QueryPagedAsync( + int? categoryId, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + if (pageIndex < 0) pageIndex = 0; + if (pageSize <= 0) pageSize = 20; + if (pageSize > 500) pageSize = 500; + + var query = BuildQuery(categoryId, search); + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } + + public async Task> QueryAsync( + int? categoryId, + string? search, + CancellationToken cancellationToken = default) + { + var query = BuildQuery(categoryId, search); + return await query.ToListAsync(cancellationToken); + } } diff --git a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs index 23459ae..698e621 100644 --- a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs @@ -9,6 +9,37 @@ public class BotChannelRepository : Repository, IBotChannelRepositor { } + private IQueryable BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search) + { + var query = _dbSet + .AsNoTracking() + .Include(x => x.Category) + .Where(x => x.BotId == botId); + + // 约定:categoryId = -1 表示“未分类”(CategoryId == null) + if (categoryId.HasValue) + { + if (categoryId.Value == -1) + query = query.Where(x => x.CategoryId == null); + else + query = query.Where(x => x.CategoryId == categoryId.Value); + } + + if (broadcastOnly) + query = query.Where(x => x.IsBroadcast); + + search = (search ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(search)) + { + var like = $"%{search}%"; + query = query.Where(x => + EF.Functions.Like(x.Title, like) + || (x.Username != null && EF.Functions.Like(x.Username, like))); + } + + return query.OrderByDescending(x => x.SyncedAt); + } + public async Task GetByTelegramIdAsync(int botId, long telegramId) { return await _dbSet @@ -29,5 +60,27 @@ public class BotChannelRepository : Repository, IBotChannelRepositor .OrderByDescending(x => x.SyncedAt) .ToListAsync(); } -} + public async Task<(IReadOnlyList Items, int TotalCount)> QueryPagedAsync( + int botId, + int? categoryId, + bool broadcastOnly, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + if (pageIndex < 0) pageIndex = 0; + if (pageSize <= 0) pageSize = 20; + if (pageSize > 500) pageSize = 500; + + var query = BuildQuery(botId, categoryId, broadcastOnly, search); + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } +} diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index 23846e4..952fda9 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -12,6 +12,38 @@ public class ChannelRepository : Repository, IChannelRepository { } + private IQueryable BuildForViewQuery(int creatorAccountId, string? filterType, string? search) + { + var query = _dbSet + .AsNoTracking() + .Include(c => c.CreatorAccount) + .Include(c => c.Group) + .AsQueryable(); + + // 列表只展示“系统创建”的频道(有 CreatorAccountId) + if (creatorAccountId <= 0) + query = query.Where(c => c.CreatorAccountId != null); + else + query = query.Where(c => c.CreatorAccountId == creatorAccountId); + + filterType = (filterType ?? "all").Trim().ToLowerInvariant(); + if (filterType == "public") + query = query.Where(c => c.Username != null && c.Username != ""); + else if (filterType == "private") + query = query.Where(c => c.Username == null || c.Username == ""); + + search = (search ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(search)) + { + var like = $"%{search}%"; + query = query.Where(c => + EF.Functions.Like(c.Title, like) + || (c.Username != null && EF.Functions.Like(c.Username, like))); + } + + return query.OrderByDescending(c => c.SyncedAt); + } + public override async Task GetByIdAsync(int id) { return await _dbSet @@ -89,4 +121,26 @@ public class ChannelRepository : Repository, IChannelRepository .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } + + public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( + int creatorAccountId, + string? filterType, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + if (pageIndex < 0) pageIndex = 0; + if (pageSize <= 0) pageSize = 20; + if (pageSize > 500) pageSize = 500; + + var query = BuildForViewQuery(creatorAccountId, filterType, search); + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } } diff --git a/src/TelegramPanel.Data/Repositories/IAccountRepository.cs b/src/TelegramPanel.Data/Repositories/IAccountRepository.cs index 7afe133..400ef79 100644 --- a/src/TelegramPanel.Data/Repositories/IAccountRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IAccountRepository.cs @@ -11,4 +11,16 @@ public interface IAccountRepository : IRepository Task GetByUserIdAsync(long userId); Task> GetByCategoryAsync(int categoryId); Task> GetActiveAccountsAsync(); + + Task<(IReadOnlyList Items, int TotalCount)> QueryPagedAsync( + int? categoryId, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default); + + Task> QueryAsync( + int? categoryId, + string? search, + CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs index 37c38bd..cb88f97 100644 --- a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs @@ -6,5 +6,13 @@ public interface IBotChannelRepository : IRepository { Task GetByTelegramIdAsync(int botId, long telegramId); Task> GetForBotAsync(int botId, int? categoryId = null); -} + Task<(IReadOnlyList Items, int TotalCount)> QueryPagedAsync( + int botId, + int? categoryId, + bool broadcastOnly, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs index ce96c66..f419db6 100644 --- a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs @@ -13,4 +13,12 @@ public interface IChannelRepository : IRepository Task> GetForAccountAsync(int accountId, bool includeNonCreator); Task> GetByGroupAsync(int groupId); Task> GetBroadcastChannelsAsync(); + + Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( + int creatorAccountId, + string? filterType, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor new file mode 100644 index 0000000..42a086f --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor @@ -0,0 +1,41 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@using TelegramPanel.Data.Entities + + + + + + 将为 @SelectedCount 个频道设置分组 + + + + 未分组 + @foreach (var g in Groups) + { + @g.Name + } + + + + + 取消 + 确定 + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public List Groups { get; set; } = new(); + [Parameter] public int SelectedCount { get; set; } + + private int selectedGroupId; + + private void Cancel() => MudDialog.Cancel(); + + private void Confirm() + { + MudDialog.Close(DialogResult.Ok(selectedGroupId)); + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/BotSetAdminsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BotSetAdminsDialog.razor index 303825c..4b93ba7 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BotSetAdminsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BotSetAdminsDialog.razor @@ -1,6 +1,8 @@ @namespace TelegramPanel.Web.Components.Dialogs @inject TelegramPanel.Core.Services.Telegram.BotTelegramService BotTelegram @inject ISnackbar Snackbar +@inject TelegramPanel.Web.Services.BotAdminPresetsService PresetsService +@inject IDialogService DialogService @using System.Linq @@ -10,9 +12,42 @@ 请输入要设置为管理员的 Telegram 用户 ID(不是手机号/用户名)。机器人需要在目标频道具备"添加管理员/添加新管理员"权限,否则会失败。 - + + + + (不使用预设) + @foreach (var p in Presets) + { + @p.Name + } + + + + + 删除预设 + + + + + + + + + + + + + 保存为预设 + + + 如何获取用户 ID? @@ -79,15 +114,98 @@ [Parameter] public IReadOnlyList ChannelTelegramIds { get; set; } = Array.Empty(); private string userIdInput = ""; + private string selectedPresetName = ""; + private string presetNameToSave = ""; private bool saving; private RightsModel rights = new(); + private List Presets { get; set; } = new(); - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { // 默认给出一个"常用"权限组合,减少操作成本 rights.ManageChat = true; rights.InviteUsers = true; + + await ReloadPresetsAsync(); + } + + private async Task ReloadPresetsAsync() + { + Presets = (await PresetsService.GetPresetsAsync()) + .Where(x => !string.IsNullOrWhiteSpace(x.Name) && x.UserIds.Count > 0) + .ToList(); + } + + private Task OnPresetChanged(string value) + { + selectedPresetName = value ?? ""; + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return Task.CompletedTask; + + var preset = Presets.FirstOrDefault(p => string.Equals(p.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase)); + if (preset != null) + { + userIdInput = string.Join(Environment.NewLine, preset.UserIds); + } + + return Task.CompletedTask; + } + + private async Task SavePreset() + { + var name = (presetNameToSave ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + Snackbar.Add("请输入预设名称", Severity.Warning); + return; + } + + var userIds = ParseUserIds(userIdInput); + if (userIds.Count == 0) + { + Snackbar.Add("请输入用户 ID", Severity.Warning); + return; + } + + try + { + await PresetsService.SavePresetAsync(name, userIds); + await ReloadPresetsAsync(); + selectedPresetName = name; + Snackbar.Add($"已保存预设:{name}", Severity.Success); + } + 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); + selectedPresetName = ""; + Snackbar.Add("已删除预设", Severity.Success); + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error); + } } private async Task Save() @@ -107,57 +225,65 @@ return; } - var input = (userIdInput ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(input)) + var userIds = ParseUserIds(userIdInput); + if (userIds.Count == 0) { Snackbar.Add("请输入用户 ID", Severity.Warning); return; } - if (!long.TryParse(input, out var userId) || userId <= 0) - { - Snackbar.Add("用户 ID 格式不正确(请输入纯数字)", Severity.Warning); - return; - } - saving = true; try { - var result = await BotTelegram.PromoteChatMemberWithResultAsync( - botId: BotId, - channelTelegramIds: ChannelTelegramIds, - userId: userId, - rights: new TelegramPanel.Core.Services.Telegram.BotTelegramService.BotAdminRights( - ManageChat: rights.ManageChat, - PostMessages: rights.PostMessages, - EditMessages: rights.EditMessages, - DeleteMessages: rights.DeleteMessages, - InviteUsers: rights.InviteUsers, - RestrictMembers: rights.RestrictMembers, - PinMessages: rights.PinMessages, - PromoteMembers: rights.PromoteMembers - ), - cancellationToken: CancellationToken.None + var channelIds = ChannelTelegramIds.Distinct().ToList(); + var totalOps = channelIds.Count * userIds.Count; + var okOps = 0; + var failOps = 0; + string? firstFailure = null; + + var rightsArg = new TelegramPanel.Core.Services.Telegram.BotTelegramService.BotAdminRights( + ManageChat: rights.ManageChat, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + InviteUsers: rights.InviteUsers, + RestrictMembers: rights.RestrictMembers, + PinMessages: rights.PinMessages, + PromoteMembers: rights.PromoteMembers ); - if (result.SuccessCount == 0) + foreach (var uid in userIds) { - var first = result.Failures.Values.FirstOrDefault() ?? "未知原因"; - Snackbar.Add($"设置失败:{first}", Severity.Error); + var result = await BotTelegram.PromoteChatMemberWithResultAsync( + botId: BotId, + channelTelegramIds: channelIds, + userId: uid, + rights: rightsArg, + cancellationToken: CancellationToken.None + ); + + okOps += result.SuccessCount; + failOps += result.Failures.Count; + if (firstFailure == null && result.Failures.Count > 0) + firstFailure = result.Failures.Values.FirstOrDefault(); + } + + if (okOps == 0) + { + Snackbar.Add($"设置失败:{firstFailure ?? "未知原因"}", Severity.Error); return; } - if (result.Failures.Count > 0) + if (failOps > 0) { - var first = result.Failures.Values.FirstOrDefault() ?? "未知原因"; - Snackbar.Add($"部分失败:成功 {result.SuccessCount}/{result.TotalCount},示例原因:{first}", Severity.Warning); + Snackbar.Add($"部分失败:成功 {okOps}/{totalOps},示例原因:{firstFailure ?? "未知原因"}", Severity.Warning); } else { - Snackbar.Add($"已设置管理员:成功 {result.SuccessCount}/{result.TotalCount} 个频道", Severity.Success); + Snackbar.Add($"已设置管理员:成功 {okOps}/{totalOps}", Severity.Success); } - MudDialog.Close(DialogResult.Ok(result.SuccessCount)); + MudDialog.Close(DialogResult.Ok(okOps)); } catch (Exception ex) { @@ -174,6 +300,28 @@ MudDialog.Cancel(); } + private static List ParseUserIds(string? input) + { + var list = new List(); + var raw = (input ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return list; + + // UI 提示:每行一个 ID;解析时仍做宽松处理,避免粘贴时带上空格/逗号导致失败 + var parts = raw.Split(new[] { '\r', '\n', ',', ',', ';', ';', '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var p in parts) + { + var s = p.Trim(); + if (string.IsNullOrWhiteSpace(s)) + continue; + + if (long.TryParse(s, out var id) && id > 0) + list.Add(id); + } + + return list.Distinct().ToList(); + } + private sealed class RightsModel { public bool ManageChat { get; set; } diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 5ca7d7f..720882f 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -17,21 +17,25 @@ - + - 已导入账号 (@accounts.Count) + 已导入账号 (@totalCount) + @if (selectedAccounts.Count > 0) + { + 已选 @selectedAccounts.Count + } + Disabled="@loading" OnClick="ReloadTable" Size="Size.Small"> 刷新 - + 全部分类 @foreach (var c in categories) { @@ -40,10 +44,10 @@ + Immediate="true" @bind-Value:after="OnSearchChanged" /> + Disabled="@(loading || accounts.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small"> @SelectionToggleText @@ -63,13 +67,14 @@ 批量加群/订阅(已选) 批量退群/退订(已选) 修改二级密码(已选) + 批量踢出所有其他设备(已选) 批量修改分类(已选) 批量改昵称(已选) 批量改用户名(已选) 批量改Bio(已选) 删除已选 导出已选 - 导出当前列表 + 导出当前页 @@ -158,39 +163,29 @@ + + + @code { + private MudTable? _table; private List accounts = new(); private List categories = new(); + private int totalCount; private bool loading = true; private string searchString = ""; private int filterCategoryId = 0; private readonly Dictionary telegramStatus = new(); private HashSet selectedAccounts = new(); private readonly CancellationTokenSource _pageCts = new(); - - private IEnumerable filteredAccounts => - accounts.Where(a => - { - if (filterCategoryId > 0 && a.CategoryId != filterCategoryId) - return false; - - if (string.IsNullOrWhiteSpace(searchString)) - return true; - - return a.Phone.Contains(searchString, StringComparison.OrdinalIgnoreCase) - || (a.Nickname?.Contains(searchString, StringComparison.OrdinalIgnoreCase) ?? false) - || (a.Username?.Contains(searchString, StringComparison.OrdinalIgnoreCase) ?? false) - || a.UserId.ToString().Contains(searchString); - }); + private CancellationTokenSource? _searchDebounceCts; protected override async Task OnInitializedAsync() { await LoadCategories(); - await LoadAccounts(); } private async Task LoadCategories() @@ -206,16 +201,23 @@ } } - private async Task LoadAccounts() + private async Task> LoadAccountsServerData(TableState state, CancellationToken cancellationToken) { loading = true; try { - var accountList = await AccountManagement.GetAllAccountsAsync(); - accounts = accountList.ToList(); + var categoryId = filterCategoryId <= 0 ? (int?)null : filterCategoryId; + var (items, total) = await AccountManagement.QueryAccountsPagedAsync( + categoryId: categoryId, + search: searchString, + pageIndex: state.Page, + pageSize: state.PageSize, + cancellationToken: cancellationToken); - // 刷新账号列表后,从数据库重建“Telegram 状态”缓存(避免刷新页面后回到未检测) - telegramStatus.Clear(); + accounts = items.ToList(); + totalCount = total; + + // 从数据库字段恢复“Telegram 状态”缓存(仅需要当前页) foreach (var a in accounts) { if (!a.TelegramStatusCheckedAtUtc.HasValue) @@ -230,15 +232,15 @@ CheckedAtUtc: a.TelegramStatusCheckedAtUtc.Value); } - var ids = new HashSet(accounts.Select(a => a.Id)); - await LoadCategories(); + // 翻页/筛选时清空选择(与 WordPress 列表行为一致,避免跨页误操作) + selectedAccounts.Clear(); - // 清理已选中但已不存在的账号 - selectedAccounts = selectedAccounts.Where(a => ids.Contains(a.Id)).ToHashSet(); + return new TableData { Items = accounts, TotalItems = totalCount }; } catch (Exception ex) { Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + return new TableData { Items = Array.Empty(), TotalItems = 0 }; } finally { @@ -246,6 +248,37 @@ } } + private async Task ReloadTable() + { + selectedAccounts.Clear(); + if (_table != null) + await _table.ReloadServerData(); + } + + private async Task OnFiltersChanged() + { + // 筛选改变时回到第一页更符合直觉,但 MudTable API 不同版本可能不同;这里仅触发刷新 + await ReloadTable(); + } + + private async Task OnSearchChanged() + { + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = new CancellationTokenSource(); + + var token = _searchDebounceCts.Token; + try + { + await Task.Delay(300, token); + await ReloadTable(); + } + catch (OperationCanceledException) + { + // ignore + } + } + private async Task ShowDetails(Account account) { var parameters = new DialogParameters @@ -256,7 +289,7 @@ var dialog = DialogService.Show("账号详情", parameters, options)!; var result = await dialog.Result; if (result is { Canceled: false }) - await LoadAccounts(); + await ReloadTable(); } private async Task OpenChangeTwoFactorPassword(Account account) @@ -300,7 +333,7 @@ var dialog = DialogService.Show("编辑用户资料", parameters, options)!; var result = await dialog.Result; if (result is { Canceled: false }) - await LoadAccounts(); + await ReloadTable(); } private async Task OpenBatchChangeTwoFactorPassword() @@ -356,20 +389,14 @@ loading = true; try { - var changed = 0; foreach (var id in targets) { - var current = accounts.FirstOrDefault(a => a.Id == id); - if (current != null && current.CategoryId == categoryId) - continue; - await AccountManagement.UpdateAccountCategoryAsync(id, categoryId); - changed++; } - await LoadAccounts(); - - Snackbar.Add($"分类已更新:{changed}/{targets.Count}", Severity.Success); + selectedAccounts.Clear(); + await ReloadTable(); + Snackbar.Add($"分类已更新:{targets.Count} 个账号", Severity.Success); } catch (Exception ex) { @@ -401,7 +428,7 @@ var dialog = DialogService.Show("批量修改昵称", parameters, options)!; var result = await dialog.Result; if (result is { Canceled: false }) - await LoadAccounts(); + await ReloadTable(); } private async Task OpenBatchChangeUsername() @@ -424,7 +451,7 @@ var dialog = DialogService.Show("批量修改用户名", parameters, options)!; var result = await dialog.Result; if (result is { Canceled: false }) - await LoadAccounts(); + await ReloadTable(); } private async Task OpenBatchChangeBio() @@ -447,7 +474,7 @@ var dialog = DialogService.Show("批量修改 Bio", parameters, options)!; var result = await dialog.Result; if (result is { Canceled: false }) - await LoadAccounts(); + await ReloadTable(); } private async Task OpenBatchJoinSubscribe() @@ -571,7 +598,7 @@ telegramStatus[account.Id] = result; // 资料刷新后重新加载(昵称/用户名/用户ID 可能更新) - await LoadAccounts(); + await ReloadTable(); Snackbar.Add($"账号 {account.DisplayPhone} 状态:{result.Summary}", result.Ok ? Severity.Success : Severity.Warning); } @@ -603,7 +630,8 @@ cancelText: "普通刷新"); var probe = probeResult == true; - var targets = filteredAccounts.ToList(); + var categoryId = filterCategoryId <= 0 ? (int?)null : filterCategoryId; + var targets = (await AccountManagement.QueryAccountsAsync(categoryId, searchString, _pageCts.Token)).ToList(); // 批量风控检查:深度探测是敏感操作 if (probe && targets.Count > 0) @@ -664,7 +692,7 @@ StateHasChanged(); } - await LoadAccounts(); + await ReloadTable(); Snackbar.Add($"批量刷新完成:{targets.Count} 个账号", Severity.Success); } catch (Exception ex) @@ -695,13 +723,13 @@ private Task SelectAllFiltered() { - selectedAccounts = filteredAccounts.ToHashSet(); + selectedAccounts = accounts.ToHashSet(); return Task.CompletedTask; } private Task InvertSelection() { - var targets = filteredAccounts.ToList(); + var targets = accounts.ToList(); foreach (var a in targets) { if (selectedAccounts.Contains(a)) @@ -721,10 +749,10 @@ private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; private string SelectionToggleText => _selectionToggleMode switch { - SelectionToggleMode.SelectAll => "全选", - SelectionToggleMode.Invert => "反选", + SelectionToggleMode.SelectAll => "全选本页", + SelectionToggleMode.Invert => "反选本页", SelectionToggleMode.Clear => "清空选择", - _ => "全选" + _ => "全选本页" }; private string SelectionToggleIcon => _selectionToggleMode switch @@ -800,7 +828,7 @@ } selectedAccounts.Clear(); - await LoadAccounts(); + await ReloadTable(); if (failures.Count == 0) { @@ -843,12 +871,12 @@ Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}", forceLoad: true); } - private void ExportCurrentAccounts() + private void ExportCurrentPageAccounts() { - var ids = filteredAccounts.Select(a => a.Id).Distinct().ToList(); + var ids = accounts.Select(a => a.Id).Distinct().ToList(); if (ids.Count == 0) { - Snackbar.Add("当前列表没有账号可导出", Severity.Info); + Snackbar.Add("当前页没有账号可导出", Severity.Info); return; } @@ -912,7 +940,7 @@ try { await AccountManagement.DeleteAccountAsync(account.Id); - accounts.Remove(account); + await ReloadTable(); Snackbar.Add("账号已删除", Severity.Success); } catch (Exception ex) @@ -921,4 +949,66 @@ } } } + + private async Task BatchKickAllOtherDevices() + { + if (loading) + 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; + + loading = 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 + { + loading = false; + } + } } diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor index 22e348e..199294f 100644 --- a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor +++ b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor @@ -14,71 +14,85 @@ - + - - -- 请选择机器人 -- - @foreach (var b in bots) - { - @b.Name - } - + + + + -- 请选择机器人 -- + @foreach (var b in bots) + { + @b.Name + } + - - 频道 (@channels.Count) - + + 频道 (@totalCount) + - + @if (selected.Count > 0) + { + 已选 @selected.Count + } - - 全部分类 - 未分类 - @foreach (var c in categories) - { - @c.Name - } - + - + + 全部分类 + 未分类 + @foreach (var c in categories) + { + @c.Name + } + - + - - 同步 - + - - 导出邀请(已选) - + + - - 设置管理员(已选) - + + + + 同步 + - - 批量设置分类(@selected.Count) - + + 刷新 + - - 踢出用户(@selected.Count) - - - - 刷新 - + + + + 批量操作 + + + + 导出邀请(已选) + 批量设置管理员(已选) + 批量设置分类(已选) + 踢出用户(已选) + + + + + @@ -103,14 +117,14 @@ } - - 未分类 - @foreach (var c in categories) - { - @c.Name - } - + @if (context.Category != null) + { + @context.Category.Name + } + else + { + 未分类 + } @@ -132,6 +146,9 @@ 暂无频道 } + + + @@ -140,6 +157,7 @@ [SupplyParameterFromQuery(Name = "botId")] public int? BotIdQuery { get; set; } + private MudTable? _table; private List bots = new(); private Bot? selectedBot; private int selectedBotId = 0; @@ -147,27 +165,14 @@ private bool loading = true; private List categories = new(); private List channels = new(); + private int totalCount; private HashSet selected = new(); private int filterCategoryId = 0; private string search = ""; - - private IEnumerable filtered => selectedBotId <= 0 - ? Enumerable.Empty() - : channels.Where(c => - { - if (filterCategoryId == -1 && c.CategoryId != null) - return false; - if (filterCategoryId > 0 && c.CategoryId != filterCategoryId) - return false; - - if (string.IsNullOrWhiteSpace(search)) - return true; - - return c.Title.Contains(search, StringComparison.OrdinalIgnoreCase) - || (c.Username?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false); - }); + private bool _actionsExpanded = true; + private CancellationTokenSource? _searchDebounceCts; protected override async Task OnInitializedAsync() { @@ -211,6 +216,7 @@ selectedBot = null; categories.Clear(); channels.Clear(); + totalCount = 0; Navigation.NavigateTo("/bots/channels", replace: true); return; } @@ -236,11 +242,7 @@ } categories = (await BotManagement.GetCategoriesAsync(selectedBotId)).ToList(); - channels = (await BotManagement.GetChannelsAsync(selectedBotId)).ToList(); - - // 清理已选 - var ids = channels.Select(x => x.Id).ToHashSet(); - selected = selected.Where(x => ids.Contains(x.Id)).ToHashSet(); + await ReloadTable(); } catch (Exception ex) { @@ -252,6 +254,74 @@ } } + private async Task> LoadChannelsServerData(TableState state, CancellationToken cancellationToken) + { + loading = true; + try + { + if (selectedBotId <= 0) + return new TableData { Items = Array.Empty(), TotalItems = 0 }; + + var (items, total) = await BotManagement.QueryChannelsPagedAsync( + botId: selectedBotId, + categoryId: filterCategoryId == -1 ? -1 : (filterCategoryId > 0 ? filterCategoryId : null), + search: search, + pageIndex: state.Page, + pageSize: state.PageSize, + cancellationToken: cancellationToken); + + channels = items.ToList(); + totalCount = total; + selected.Clear(); + + return new TableData { Items = channels, TotalItems = totalCount }; + } + catch (Exception ex) + { + Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + return new TableData { Items = Array.Empty(), TotalItems = 0 }; + } + finally + { + loading = false; + } + } + + private void ToggleActions() + { + _actionsExpanded = !_actionsExpanded; + } + + private async Task ReloadTable() + { + selected.Clear(); + if (_table != null) + await _table.ReloadServerData(); + } + + private async Task OnFiltersChanged() + { + await ReloadTable(); + } + + private async Task OnSearchChanged() + { + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = new CancellationTokenSource(); + + var token = _searchDebounceCts.Token; + try + { + await Task.Delay(300, token); + await ReloadTable(); + } + catch (OperationCanceledException) + { + // ignore + } + } + private async Task Sync() { if (selectedBotId <= 0) @@ -265,7 +335,8 @@ Snackbar.Add($"同步完成:应用更新 {result.AppliedUpdates} 条,清理失效 {result.RemovedStale} 个频道", Severity.Success); else Snackbar.Add("同步完成:没有需要处理的更新或失效频道。提示:请先把 Bot 设为频道管理员,再点同步。", Severity.Info); - await LoadBotData(); + + await ReloadTable(); } catch (Exception ex) { @@ -297,20 +368,6 @@ } } - private async Task SetCategoryAsync(BotChannel channel, int? categoryId) - { - try - { - await BotManagement.SetChannelCategoryAsync(channel.Id, categoryId); - channel.CategoryId = categoryId; - Snackbar.Add("已保存分类", Severity.Success); - } - catch (Exception ex) - { - Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); - } - } - private async Task CopyLinkAsync(BotChannel channel) { if (selectedBotId <= 0) diff --git a/src/TelegramPanel.Web/Components/Pages/Channels.razor b/src/TelegramPanel.Web/Components/Pages/Channels.razor index cac0acf..e314f95 100644 --- a/src/TelegramPanel.Web/Components/Pages/Channels.razor +++ b/src/TelegramPanel.Web/Components/Pages/Channels.razor @@ -1,5 +1,6 @@ @page "/channels" @inject ChannelManagementService ChannelManagement +@inject ChannelGroupManagementService ChannelGroupManagement @inject AccountManagementService AccountManagement @inject TelegramPanel.Web.Services.DataSyncService DataSync @inject IChannelService ChannelService @@ -16,12 +17,12 @@ - + - 频道 (@channels.Count) + 频道 (@totalCount) @if (selectedChannels.Count > 0) { 已选 @selectedChannels.Count @@ -29,18 +30,18 @@ + Immediate="true" @bind-Value:after="OnSearchChanged" /> - + 全部账号 @foreach (var account in accounts) { @FormatAccountLabel(account) } - + 全部 公开 私密 @@ -55,7 +56,7 @@ + Disabled="@(loading || channels.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small"> @SelectionToggleText @@ -69,6 +70,7 @@ 批量邀请(已选) 批量设置管理员(已选) + 批量修改分组(已选) @@ -146,26 +148,33 @@ + + + @code { + private MudTable? _table; private List channels = new(); private List accounts = new(); + private List groups = new(); + private int totalCount; private bool loading = true; private string searchString = ""; private int filterAccount = 0; private string filterType = "all"; private HashSet selectedChannels = new(); + private CancellationTokenSource? _searchDebounceCts; private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; private string SelectionToggleText => _selectionToggleMode switch { - SelectionToggleMode.SelectAll => "全选", - SelectionToggleMode.Invert => "反选", + SelectionToggleMode.SelectAll => "全选本页", + SelectionToggleMode.Invert => "反选本页", SelectionToggleMode.Clear => "清空选择", - _ => "全选" + _ => "全选本页" }; private string SelectionToggleIcon => _selectionToggleMode switch @@ -179,7 +188,7 @@ protected override async Task OnInitializedAsync() { await LoadAccounts(); - await LoadChannels(); + await LoadGroups(); } private async Task LoadAccounts() @@ -195,18 +204,42 @@ } } - private async Task LoadChannels() + private async Task LoadGroups() + { + try + { + var list = await ChannelGroupManagement.GetAllGroupsAsync(); + groups = list.ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载分组失败:{ex.Message}", Severity.Error); + } + } + + private async Task> LoadChannelsServerData(TableState state, CancellationToken cancellationToken) { loading = true; try { - var channelList = await ChannelManagement.GetChannelsForViewAsync(filterAccount, includeNonCreator: false); - channels = channelList.ToList(); + var (items, total) = await ChannelManagement.QueryChannelsForViewPagedAsync( + creatorAccountId: filterAccount, + filterType: filterType, + search: searchString, + pageIndex: state.Page, + pageSize: state.PageSize, + cancellationToken: cancellationToken); + + channels = items.ToList(); + totalCount = total; selectedChannels.Clear(); + + return new TableData { Items = channels, TotalItems = totalCount }; } catch (Exception ex) { Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + return new TableData { Items = Array.Empty(), TotalItems = 0 }; } finally { @@ -214,40 +247,22 @@ } } - private bool FilterFunc(Channel channel) + private async Task ReloadTable() { - if (filterType == "public" && string.IsNullOrEmpty(channel.Username)) - return false; - - if (filterType == "private" && !string.IsNullOrEmpty(channel.Username)) - return false; - - if (string.IsNullOrWhiteSpace(searchString)) - return true; - - if (channel.Title.Contains(searchString, StringComparison.OrdinalIgnoreCase)) - return true; - - if (channel.Username?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true) - return true; - - return false; - } - - private IReadOnlyList GetFilteredChannels() - { - return channels.Where(FilterFunc).ToList(); + selectedChannels.Clear(); + if (_table != null) + await _table.ReloadServerData(); } private Task SelectAllFiltered() { - selectedChannels = GetFilteredChannels().ToHashSet(); + selectedChannels = channels.ToHashSet(); return Task.CompletedTask; } private Task InvertSelection() { - var targets = GetFilteredChannels().ToList(); + var targets = channels.ToList(); foreach (var c in targets) { if (selectedChannels.Contains(c)) @@ -264,6 +279,29 @@ return Task.CompletedTask; } + private async Task OnFiltersChanged() + { + await ReloadTable(); + } + + private async Task OnSearchChanged() + { + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = new CancellationTokenSource(); + + var token = _searchDebounceCts.Token; + try + { + await Task.Delay(300, token); + await ReloadTable(); + } + catch (OperationCanceledException) + { + // ignore + } + } + private async Task CycleSelectionToggle() { switch (_selectionToggleMode) @@ -401,7 +439,7 @@ Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); - await LoadChannels(); + await ReloadTable(); } catch (Exception ex) { @@ -428,7 +466,7 @@ Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); - await LoadChannels(); + await ReloadTable(); } catch (Exception ex) { @@ -455,7 +493,53 @@ var dialog = DialogService.Show("编辑频道", parameters, options); var result = await dialog.Result; if (!result.Canceled) - await LoadChannels(); + await ReloadTable(); + } + + private async Task OpenBatchSetGroupSelected() + { + var targets = selectedChannels.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + if (groups.Count == 0) + await LoadGroups(); + + var parameters = new DialogParameters + { + ["Groups"] = groups, + ["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 groupIdRaw = result.Data is int x ? x : 0; + int? groupId = groupIdRaw <= 0 ? null : groupIdRaw; + + loading = true; + try + { + foreach (var c in targets) + await ChannelManagement.UpdateChannelGroupAsync(c.Id, groupId); + + selectedChannels.Clear(); + await ReloadTable(); + Snackbar.Add($"分组已更新:{targets.Count} 个频道", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"修改分组失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } } private async Task KickUser(Channel channel) @@ -501,7 +585,7 @@ try { await ChannelManagement.DeleteChannelAsync(id); - channels.Remove(channel); + await ReloadTable(); Snackbar.Add("删除成功", Severity.Success); } catch (Exception ex) diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index d6f686a..35021fc 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -316,6 +316,7 @@ builder.Services.AddTelegramPanelCore(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck")); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("System")); diff --git a/src/TelegramPanel.Web/Services/BotAdminPresetsService.cs b/src/TelegramPanel.Web/Services/BotAdminPresetsService.cs new file mode 100644 index 0000000..3b74129 --- /dev/null +++ b/src/TelegramPanel.Web/Services/BotAdminPresetsService.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Hosting; + +namespace TelegramPanel.Web.Services; + +public sealed record BotAdminPreset(string Name, IReadOnlyList UserIds); + +/// +/// Bot 管理员 ID 预设(保存到 appsettings.local.json,避免手动改 JSON 配置) +/// +public sealed class BotAdminPresetsService +{ + private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _environment; + private readonly string _configFilePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public BotAdminPresetsService(IConfiguration configuration, IWebHostEnvironment environment) + { + _configuration = configuration; + _environment = environment; + _configFilePath = LocalConfigFile.ResolvePath(configuration, environment); + } + + public async Task> GetPresetsAsync(CancellationToken cancellationToken = default) + { + try + { + if (!File.Exists(_configFilePath)) + return Array.Empty(); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return Array.Empty(); + + if (root["BotAdminPresets"] is not JsonObject section) + return Array.Empty(); + + if (section["Presets"] is not JsonObject presetsObj) + return Array.Empty(); + + var list = new List(); + foreach (var kv in presetsObj) + { + var name = (kv.Key ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var ids = new List(); + if (kv.Value is JsonArray arr) + { + foreach (var node in arr) + { + if (node == null) + continue; + + if (node is JsonValue v) + { + if (v.TryGetValue(out var id) && id > 0) + ids.Add(id); + else if (v.TryGetValue(out var s) && long.TryParse(s, out var sid) && sid > 0) + ids.Add(sid); + } + } + } + + ids = ids.Distinct().ToList(); + if (ids.Count == 0) + continue; + + list.Add(new BotAdminPreset(name, ids)); + } + + return list + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + return Array.Empty(); + } + } + + public async Task SavePresetAsync(string name, IReadOnlyList userIds, CancellationToken cancellationToken = default) + { + name = (name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("预设名称不能为空", nameof(name)); + + var ids = (userIds ?? Array.Empty()).Where(x => x > 0).Distinct().ToList(); + if (ids.Count == 0) + throw new ArgumentException("预设用户 ID 不能为空", nameof(userIds)); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await LocalConfigFile.EnsureExistsAsync(_configFilePath, cancellationToken); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject(); + + var section = root["BotAdminPresets"] as JsonObject ?? new JsonObject(); + var presetsObj = section["Presets"] as JsonObject ?? new JsonObject(); + + var arr = new JsonArray(); + foreach (var id in ids) + arr.Add(id); + + presetsObj[name] = arr; + section["Presets"] = presetsObj; + root["BotAdminPresets"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task DeletePresetAsync(string name, CancellationToken cancellationToken = default) + { + name = (name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + await _writeLock.WaitAsync(cancellationToken); + try + { + if (!File.Exists(_configFilePath)) + return; + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return; + + if (root["BotAdminPresets"] is not JsonObject section) + return; + + if (section["Presets"] is not JsonObject presetsObj) + return; + + if (!presetsObj.Remove(name)) + return; + + section["Presets"] = presetsObj; + root["BotAdminPresets"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } +}