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