feat: 列表分页/批量操作与Bot管理员预设

This commit is contained in:
meoacgx committed 2025-12-26 15:46:01 +08:00
1 parent 89c7d49f60
commit 4bab1f88d3
17 files changed
+1086 -241

No files matched your search

+4 -4
View File
@@ -5,10 +5,10 @@
<!-- 主版本: 重大变更/不兼容更新 -->
<!-- 次版本: 新功能/兼容性更新 -->
<!-- 修订号: Bug修复/小改进 -->
<Version>1.1.1</Version>
<AssemblyVersion>1.1.1.0</AssemblyVersion>
<FileVersion>1.1.1.0</FileVersion>
<InformationalVersion>1.1.1</InformationalVersion>
<Version>1.2.0</Version>
<AssemblyVersion>1.2.0.0</AssemblyVersion>
<FileVersion>1.2.0.0</FileVersion>
<InformationalVersion>1.2.0</InformationalVersion>
<!-- 项目元信息 -->
<Authors>Telegram Panel Contributors</Authors>
@@ -58,6 +58,30 @@ public class AccountManagementService
return list;
}
public async Task<(IReadOnlyList<Account> 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<IReadOnlyList<Account>> 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<IEnumerable<Account>> GetActiveAccountsAsync()
{
var list = (await _accountRepository.GetActiveAccountsAsync()).ToList();
@@ -132,6 +132,24 @@ public class BotManagementService
return list.Where(x => x.IsBroadcast);
}
public async Task<(IReadOnlyList<BotChannel> 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);
}
/// <summary>
/// 获取 Bot 可管理的聊天列表(包含频道/群组/超级群组)。
/// </summary>
@@ -43,6 +43,17 @@ public class ChannelManagementService
return await _channelRepository.GetByCreatorAccountAsync(accountId);
}
public async Task<(IReadOnlyList<Channel> 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<IEnumerable<Channel>> GetChannelsByCreatorAsync(int accountId)
{
return await _channelRepository.GetByCreatorAccountAsync(accountId);
@@ -12,6 +12,41 @@ public class AccountRepository : Repository<Account>, IAccountRepository
{
}
private IQueryable<Account> 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<Account?> GetByIdAsync(int id)
{
return await _dbSet
@@ -57,4 +92,34 @@ public class AccountRepository : Repository<Account>, IAccountRepository
.Where(a => a.IsActive)
.ToListAsync();
}
public async Task<(IReadOnlyList<Account> 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<IReadOnlyList<Account>> QueryAsync(
int? categoryId,
string? search,
CancellationToken cancellationToken = default)
{
var query = BuildQuery(categoryId, search);
return await query.ToListAsync(cancellationToken);
}
}
@@ -9,6 +9,37 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
{
}
private IQueryable<BotChannel> 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<BotChannel?> GetByTelegramIdAsync(int botId, long telegramId)
{
return await _dbSet
@@ -29,5 +60,27 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
.OrderByDescending(x => x.SyncedAt)
.ToListAsync();
}
}
public async Task<(IReadOnlyList<BotChannel> 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);
}
}
@@ -12,6 +12,38 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
{
}
private IQueryable<Channel> 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<Channel?> GetByIdAsync(int id)
{
return await _dbSet
@@ -89,4 +121,26 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
public async Task<(IReadOnlyList<Channel> 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);
}
}
@@ -11,4 +11,16 @@ public interface IAccountRepository : IRepository<Account>
Task<Account?> GetByUserIdAsync(long userId);
Task<IEnumerable<Account>> GetByCategoryAsync(int categoryId);
Task<IEnumerable<Account>> GetActiveAccountsAsync();
Task<(IReadOnlyList<Account> Items, int TotalCount)> QueryPagedAsync(
int? categoryId,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<Account>> QueryAsync(
int? categoryId,
string? search,
CancellationToken cancellationToken = default);
}
@@ -6,5 +6,13 @@ public interface IBotChannelRepository : IRepository<BotChannel>
{
Task<BotChannel?> GetByTelegramIdAsync(int botId, long telegramId);
Task<IEnumerable<BotChannel>> GetForBotAsync(int botId, int? categoryId = null);
}
Task<(IReadOnlyList<BotChannel> Items, int TotalCount)> QueryPagedAsync(
int botId,
int? categoryId,
bool broadcastOnly,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default);
}
@@ -13,4 +13,12 @@ public interface IChannelRepository : IRepository<Channel>
Task<IEnumerable<Channel>> GetForAccountAsync(int accountId, bool includeNonCreator);
Task<IEnumerable<Channel>> GetByGroupAsync(int groupId);
Task<IEnumerable<Channel>> GetBroadcastChannelsAsync();
Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int creatorAccountId,
string? filterType,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,41 @@
@namespace TelegramPanel.Web.Components.Dialogs
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info">
将为 <strong>@SelectedCount</strong> 个频道设置分组
</MudAlert>
<MudSelect @bind-Value="selectedGroupId" Label="选择目标分组" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">未分组</MudSelectItem>
@foreach (var g in Groups)
{
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel">取消</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Confirm">确定</MudButton>
</DialogActions>
</MudDialog>
@code
{
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public List<ChannelGroup> 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));
}
}
@@ -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
<MudDialog>
@@ -10,9 +12,42 @@
请输入要设置为管理员的 Telegram 用户 ID(不是手机号/用户名)。机器人需要在目标频道具备"添加管理员/添加新管理员"权限,否则会失败。
</MudAlert>
<MudTextField @bind-Value="userIdInput" Label="用户 ID" Variant="Variant.Outlined" Required="true"
HelperText="输入 Telegram 用户 ID(纯数字)"
Placeholder="例如: 123456789" />
<MudGrid Spacing="2">
<MudItem xs="12" sm="8">
<MudSelect T="string" Value="@selectedPresetName" ValueChanged="OnPresetChanged"
Label="管理员预设" Variant="Variant.Outlined" Dense="true" Disabled="@saving">
<MudSelectItem Value="@string.Empty">(不使用预设)</MudSelectItem>
@foreach (var p in Presets)
{
<MudSelectItem Value="@p.Name">@p.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteSelectedPreset"
Disabled="@(saving || string.IsNullOrWhiteSpace(selectedPresetName))">
删除预设
</MudButton>
</MudItem>
</MudGrid>
<MudTextField @bind-Value="userIdInput" Label="用户 ID(支持多条)" Variant="Variant.Outlined" Required="true"
Lines="6"
HelperText="每行一个用户 ID(可粘贴多行)"
Placeholder="@("例如:\n123456789\n987654321")" />
<MudGrid Spacing="2">
<MudItem xs="12" sm="8">
<MudTextField @bind-Value="presetNameToSave" Label="保存当前 ID 为预设组(名称)"
Variant="Variant.Outlined" Dense="true" Disabled="@saving" />
</MudItem>
<MudItem xs="12" sm="4" Class="d-flex align-center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SavePreset"
Disabled="@(saving || string.IsNullOrWhiteSpace(presetNameToSave))">
保存为预设
</MudButton>
</MudItem>
</MudGrid>
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Dense="true">
<strong>如何获取用户 ID?</strong>
@@ -79,15 +114,98 @@
[Parameter] public IReadOnlyList<long> ChannelTelegramIds { get; set; } = Array.Empty<long>();
private string userIdInput = "";
private string selectedPresetName = "";
private string presetNameToSave = "";
private bool saving;
private RightsModel rights = new();
private List<TelegramPanel.Web.Services.BotAdminPreset> 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<long> ParseUserIds(string? input)
{
var list = new List<long>();
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; }
@@ -17,21 +17,25 @@
<MudCard>
<MudCardContent>
<MudTable Items="@filteredAccounts" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
MultiSelection="true" @bind-SelectedItems="selectedAccounts">
<MudTable ServerData="LoadAccountsServerData" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
MultiSelection="true" @bind-SelectedItems="selectedAccounts" @ref="_table">
<ToolBarContent>
<MudStack Spacing="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="flex-wrap: wrap;">
<MudText Typo="Typo.h6">已导入账号 (@accounts.Count)</MudText>
<MudText Typo="Typo.h6">已导入账号 (@totalCount)</MudText>
@if (selectedAccounts.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">已选 @selectedAccounts.Count</MudChip>
}
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
Disabled="@loading" OnClick="LoadAccounts" Size="Size.Small">
Disabled="@loading" OnClick="ReloadTable" Size="Size.Small">
刷新
</MudButton>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="filterCategoryId" Label="分类" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 160px;">
<MudSelect @bind-Value="filterCategoryId" @bind-Value:after="OnFiltersChanged" Label="分类" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 160px;">
<MudSelectItem Value="0">全部分类</MudSelectItem>
@foreach (var c in categories)
{
@@ -40,10 +44,10 @@
</MudSelect>
<MudTextField @bind-Value="searchString" Placeholder="搜索账号..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"
Immediate="true" />
Immediate="true" @bind-Value:after="OnSearchChanged" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@SelectionToggleIcon"
Disabled="@(loading || !filteredAccounts.Any())" OnClick="CycleSelectionToggle" Size="Size.Small">
Disabled="@(loading || accounts.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small">
@SelectionToggleText
</MudButton>
@@ -63,13 +67,14 @@
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchJoinSubscribe">批量加群/订阅(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchLeaveUnsubscribe">批量退群/退订(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeTwoFactorPassword">修改二级密码(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="BatchKickAllOtherDevices">批量踢出所有其他设备(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="DeleteSelectedAccounts">删除已选</MudMenuItem>
<MudMenuItem OnClick="ExportSelectedAccounts">导出已选</MudMenuItem>
<MudMenuItem OnClick="ExportCurrentAccounts">导出当前列表</MudMenuItem>
<MudMenuItem OnClick="ExportCurrentPageAccounts">导出当前页</MudMenuItem>
</ChildContent>
</MudMenu>
</MudStack>
@@ -158,39 +163,29 @@
<LoadingContent>
<MudProgressCircular Indeterminate="true" />
</LoadingContent>
<PagerContent>
<MudTablePager PageSizeOptions="new int[] { 20, 50, 100, 200 }" />
</PagerContent>
</MudTable>
</MudCardContent>
</MudCard>
@code {
private MudTable<Account>? _table;
private List<Account> accounts = new();
private List<AccountCategory> categories = new();
private int totalCount;
private bool loading = true;
private string searchString = "";
private int filterCategoryId = 0;
private readonly Dictionary<int, TelegramAccountStatusResult> telegramStatus = new();
private HashSet<Account> selectedAccounts = new();
private readonly CancellationTokenSource _pageCts = new();
private IEnumerable<Account> 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<TableData<Account>> 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<int>(accounts.Select(a => a.Id));
await LoadCategories();
// 翻页/筛选时清空选择(与 WordPress 列表行为一致,避免跨页误操作)
selectedAccounts.Clear();
// 清理已选中但已不存在的账号
selectedAccounts = selectedAccounts.Where(a => ids.Contains(a.Id)).ToHashSet();
return new TableData<Account> { Items = accounts, TotalItems = totalCount };
}
catch (Exception ex)
{
Snackbar.Add($"加载失败:{ex.Message}", Severity.Error);
return new TableData<Account> { Items = Array.Empty<Account>(), 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<TelegramPanel.Web.Components.Dialogs.AccountDetailsDialog>("账号详情", 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<TelegramPanel.Web.Components.Dialogs.EditUserProfileDialog>("编辑用户资料", 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<TelegramPanel.Web.Components.Dialogs.BatchChangeNicknameDialog>("批量修改昵称", 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<TelegramPanel.Web.Components.Dialogs.BatchChangeUsernameDialog>("批量修改用户名", 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<TelegramPanel.Web.Components.Dialogs.BatchChangeBioDialog>("批量修改 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<string>();
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;
}
}
}
@@ -14,71 +14,85 @@
<MudCard>
<MudCardContent>
<MudTable Items="@filtered" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
MultiSelection="true" @bind-SelectedItems="selected">
<MudTable ServerData="LoadChannelsServerData" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
MultiSelection="true" @bind-SelectedItems="selected" @ref="_table">
<ToolBarContent>
<MudSelect T="int" Value="@selectedBotId" ValueChanged="@(async (int id) => await OnSelectedBotChanged(id))" Label="选择机器人"
Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 260px;">
<MudSelectItem Value="0">-- 请选择机器人 --</MudSelectItem>
@foreach (var b in bots)
{
<MudSelectItem Value="@b.Id">@b.Name</MudSelectItem>
}
</MudSelect>
<MudStack Spacing="1" Style="width: 100%;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="flex-wrap: wrap;">
<MudSelect T="int" Value="@selectedBotId" ValueChanged="@(async (int id) => await OnSelectedBotChanged(id))" Label="选择机器人"
Variant="Variant.Outlined" Dense="true" Style="min-width: 260px;">
<MudSelectItem Value="0">-- 请选择机器人 --</MudSelectItem>
@foreach (var b in bots)
{
<MudSelectItem Value="@b.Id">@b.Name</MudSelectItem>
}
</MudSelect>
<MudText Typo="Typo.h6">
频道 (@channels.Count)
</MudText>
<MudText Typo="Typo.h6">
频道 (@totalCount)
</MudText>
<MudSpacer />
@if (selected.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">已选 @selected.Count</MudChip>
}
<MudSelect @bind-Value="filterCategoryId" Label="分类" Variant="Variant.Outlined" Dense="true" Style="min-width: 160px;"
Disabled="@(selectedBotId <= 0)">
<MudSelectItem Value="0">全部分类</MudSelectItem>
<MudSelectItem Value="-1">未分类</MudSelectItem>
@foreach (var c in categories)
{
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
}
</MudSelect>
<MudSpacer />
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small"
Title="新建分类" OnClick="OpenCreateCategoryDialog"
Disabled="@(selectedBotId <= 0)" Class="mr-2" />
<MudSelect @bind-Value="filterCategoryId" @bind-Value:after="OnFiltersChanged" Label="分类" Variant="Variant.Outlined" Dense="true"
Style="min-width: 160px;" Disabled="@(selectedBotId <= 0)">
<MudSelectItem Value="0">全部分类</MudSelectItem>
<MudSelectItem Value="-1">未分类</MudSelectItem>
@foreach (var c in categories)
{
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="search" Placeholder="搜索频道..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"
Immediate="true" Disabled="@(selectedBotId <= 0)" />
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small"
Title="新建分类" OnClick="OpenCreateCategoryDialog"
Disabled="@(selectedBotId <= 0)" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync" Class="ml-2"
Disabled="@(loading || selectedBotId <= 0)" OnClick="Sync">
同步
</MudButton>
<MudTextField @bind-Value="search" Placeholder="搜索频道..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"
Immediate="true" Disabled="@(selectedBotId <= 0)" @bind-Value:after="OnSearchChanged" />
<MudButton Variant="Variant.Outlined" Color="Color.Success" StartIcon="@Icons.Material.Filled.Link" Class="ml-2"
Disabled="@(loading || selectedBotId <= 0 || selected.Count == 0)" OnClick="ExportInvites">
导出邀请(已选)
</MudButton>
<MudIconButton Icon="@(_actionsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Color="Color.Default"
Title="@(_actionsExpanded ? "收起操作" : "展开操作")"
Disabled="@(selectedBotId <= 0)"
OnClick="@ToggleActions" />
</MudStack>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AdminPanelSettings" Class="ml-2"
Disabled="@(loading || selectedBotId <= 0 || selected.Count == 0)" OnClick="OpenSetAdmins">
设置管理员(已选)
</MudButton>
<MudCollapse Expanded="@_actionsExpanded">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="flex-wrap: wrap; margin-top: 6px;">
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync"
Disabled="@(loading || selectedBotId <= 0)" OnClick="Sync" Size="Size.Small">
同步
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Category" Class="ml-2"
Disabled="@(loading || selectedBotId <= 0 || selected.Count == 0)" OnClick="OpenBatchSetCategory">
批量设置分类(@selected.Count)
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
Disabled="@(loading || selectedBotId <= 0)" OnClick="ReloadTable" Size="Size.Small">
刷新
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error" StartIcon="@Icons.Material.Filled.PersonRemove" Class="ml-2"
Disabled="@(loading || selectedBotId <= 0 || selected.Count == 0)" OnClick="OpenBanMember">
踢出用户(@selected.Count)
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
Disabled="@(loading || selectedBotId <= 0)" OnClick="LoadBotData" Class="ml-2">
刷新
</MudButton>
<MudMenu Dense="true" Disabled="@(loading || selectedBotId <= 0)">
<ActivatorContent>
<MudButton Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Disabled="@(loading || selectedBotId <= 0)" Size="Size.Small">
批量操作
</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="ExportInvites">导出邀请(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenSetAdmins">批量设置管理员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetCategory">批量设置分类(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBanMember">踢出用户(已选)</MudMenuItem>
</ChildContent>
</MudMenu>
</MudStack>
</MudCollapse>
</MudStack>
</ToolBarContent>
<HeaderContent>
@@ -103,14 +117,14 @@
}
</MudTd>
<MudTd DataLabel="分类">
<MudSelect T="int?" Value="@context.CategoryId" ValueChanged="@(async v => await SetCategoryAsync(context, v))"
Dense="true" Variant="Variant.Outlined" Style="min-width: 160px;">
<MudSelectItem Value="@(default(int?))">未分类</MudSelectItem>
@foreach (var c in categories)
{
<MudSelectItem Value="@((int?)c.Id)">@c.Name</MudSelectItem>
}
</MudSelect>
@if (context.Category != null)
{
<MudChip T="string" Size="Size.Small">@context.Category.Name</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">未分类</MudText>
}
</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
<MudTd DataLabel="操作">
@@ -132,6 +146,9 @@
<MudText>暂无频道</MudText>
}
</NoRecordsContent>
<PagerContent>
<MudTablePager PageSizeOptions="new int[] { 20, 50, 100, 200 }" />
</PagerContent>
</MudTable>
</MudCardContent>
</MudCard>
@@ -140,6 +157,7 @@
[SupplyParameterFromQuery(Name = "botId")]
public int? BotIdQuery { get; set; }
private MudTable<BotChannel>? _table;
private List<Bot> bots = new();
private Bot? selectedBot;
private int selectedBotId = 0;
@@ -147,27 +165,14 @@
private bool loading = true;
private List<BotChannelCategory> categories = new();
private List<BotChannel> channels = new();
private int totalCount;
private HashSet<BotChannel> selected = new();
private int filterCategoryId = 0;
private string search = "";
private IEnumerable<BotChannel> filtered => selectedBotId <= 0
? Enumerable.Empty<BotChannel>()
: 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<TableData<BotChannel>> LoadChannelsServerData(TableState state, CancellationToken cancellationToken)
{
loading = true;
try
{
if (selectedBotId <= 0)
return new TableData<BotChannel> { Items = Array.Empty<BotChannel>(), 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<BotChannel> { Items = channels, TotalItems = totalCount };
}
catch (Exception ex)
{
Snackbar.Add($"加载失败:{ex.Message}", Severity.Error);
return new TableData<BotChannel> { Items = Array.Empty<BotChannel>(), 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)
@@ -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 @@
<MudCard>
<MudCardContent>
<MudTable Items="@channels" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading" Filter="FilterFunc"
MultiSelection="true" @bind-SelectedItems="selectedChannels">
<MudTable ServerData="LoadChannelsServerData" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
MultiSelection="true" @bind-SelectedItems="selectedChannels" @ref="_table">
<ToolBarContent>
<MudStack Spacing="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="flex-wrap: wrap;">
<MudText Typo="Typo.h6">频道 (@channels.Count)</MudText>
<MudText Typo="Typo.h6">频道 (@totalCount)</MudText>
@if (selectedChannels.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">已选 @selectedChannels.Count</MudChip>
@@ -29,18 +30,18 @@
<MudSpacer />
<MudTextField @bind-Value="searchString" Placeholder="搜索频道..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"
Immediate="true" />
Immediate="true" @bind-Value:after="OnSearchChanged" />
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="filterAccount" @bind-Value:after="LoadChannels" Label="筛选账号" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 220px;">
<MudSelect @bind-Value="filterAccount" @bind-Value:after="OnFiltersChanged" Label="筛选账号" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 220px;">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="filterType" Label="频道类型" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 120px;">
<MudSelect @bind-Value="filterType" @bind-Value:after="OnFiltersChanged" Label="频道类型" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 120px;">
<MudSelectItem Value='@("all")'>全部</MudSelectItem>
<MudSelectItem Value='@("public")'>公开</MudSelectItem>
<MudSelectItem Value='@("private")'>私密</MudSelectItem>
@@ -55,7 +56,7 @@
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@SelectionToggleIcon"
Disabled="@(loading || !GetFilteredChannels().Any())" OnClick="CycleSelectionToggle" Size="Size.Small">
Disabled="@(loading || channels.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small">
@SelectionToggleText
</MudButton>
@@ -69,6 +70,7 @@
<ChildContent>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchInviteSelected">批量邀请(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetAdminsSelected">批量设置管理员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分组(已选)</MudMenuItem>
</ChildContent>
</MudMenu>
</MudStack>
@@ -146,26 +148,33 @@
<LoadingContent>
<MudProgressCircular Indeterminate="true" />
</LoadingContent>
<PagerContent>
<MudTablePager PageSizeOptions="new int[] { 20, 50, 100, 200 }" />
</PagerContent>
</MudTable>
</MudCardContent>
</MudCard>
@code {
private MudTable<Channel>? _table;
private List<Channel> channels = new();
private List<Account> accounts = new();
private List<ChannelGroup> groups = new();
private int totalCount;
private bool loading = true;
private string searchString = "";
private int filterAccount = 0;
private string filterType = "all";
private HashSet<Channel> 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<TableData<Channel>> 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<Channel> { Items = channels, TotalItems = totalCount };
}
catch (Exception ex)
{
Snackbar.Add($"加载失败:{ex.Message}", Severity.Error);
return new TableData<Channel> { Items = Array.Empty<Channel>(), 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<Channel> 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<ChannelEditDialog>("编辑频道", 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<BatchSetChannelGroupDialog>("批量修改分组", 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)
+1
View File
@@ -316,6 +316,7 @@ builder.Services.AddTelegramPanelCore();
builder.Services.AddScoped<AccountExportService>();
builder.Services.AddScoped<DataSyncService>();
builder.Services.AddScoped<UiPreferencesService>();
builder.Services.AddScoped<BotAdminPresetsService>();
builder.Services.Configure<UpdateCheckOptions>(builder.Configuration.GetSection("UpdateCheck"));
builder.Services.AddSingleton<UpdateCheckService>();
builder.Services.Configure<PanelTimeZoneOptions>(builder.Configuration.GetSection("System"));
@@ -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<long> UserIds);
/// <summary>
/// Bot 管理员 ID 预设(保存到 appsettings.local.json,避免手动改 JSON 配置)
/// </summary>
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<IReadOnlyList<BotAdminPreset>> GetPresetsAsync(CancellationToken cancellationToken = default)
{
try
{
if (!File.Exists(_configFilePath))
return Array.Empty<BotAdminPreset>();
var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken);
var root = JsonNode.Parse(json)?.AsObject();
if (root == null)
return Array.Empty<BotAdminPreset>();
if (root["BotAdminPresets"] is not JsonObject section)
return Array.Empty<BotAdminPreset>();
if (section["Presets"] is not JsonObject presetsObj)
return Array.Empty<BotAdminPreset>();
var list = new List<BotAdminPreset>();
foreach (var kv in presetsObj)
{
var name = (kv.Key ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
var ids = new List<long>();
if (kv.Value is JsonArray arr)
{
foreach (var node in arr)
{
if (node == null)
continue;
if (node is JsonValue v)
{
if (v.TryGetValue<long>(out var id) && id > 0)
ids.Add(id);
else if (v.TryGetValue<string>(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<BotAdminPreset>();
}
}
public async Task SavePresetAsync(string name, IReadOnlyList<long> userIds, CancellationToken cancellationToken = default)
{
name = (name ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("预设名称不能为空", nameof(name));
var ids = (userIds ?? Array.Empty<long>()).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();
}
}
}