feat: 列表分页/批量操作与Bot管理员预设
This commit is contained in:
1 parent
89c7d49f60
commit
4bab1f88d3
17 files changed
+1086
-241
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user