Files
Telegram-Panel/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor
T
meoacgxandClaude 987be180d0 feat: BOT频道增强 - 踢人/封禁功能及用户ID获取提示
新增功能:
- Bot频道支持批量踢人/封禁用户(支持两种模式:仅踢出 or 永久封禁)
- 踢人对话框支持选择操作模式(默认仅踢出,可选永久封禁)

体验优化:
- 踢人和设置管理员对话框添加"如何获取用户ID"的详细步骤提示
- 统一用户ID输入方式(使用纯文本输入框,移除数字增减按钮)

技术改动:
- BotTelegramService: 新增 BanChatMemberAsync 支持踢人/封禁
- BotTelegramService: 新增 CanBotBanMembersAsync 权限检查
- 新增 BotBanMemberDialog 踢人对话框组件
- BotChannelsHome: 添加踢出用户按钮及批量处理逻辑

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-20 22:36:47 +08:00

543 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/bots/channels"
@inject BotManagementService BotManagement
@inject TelegramPanel.Core.Services.Telegram.BotTelegramService BotTelegram
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using TelegramPanel.Data.Entities
@using TelegramPanel.Web.Components.Dialogs
<PageTitle>Bot 频道 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Bot 频道</MudText>
<MudCard>
<MudCardContent>
<MudTable Items="@filtered" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
MultiSelection="true" @bind-SelectedItems="selected">
<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>
<MudText Typo="Typo.h6">
频道 (@channels.Count)
</MudText>
<MudSpacer />
<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>
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small"
Title="新建分类" OnClick="OpenCreateCategoryDialog"
Disabled="@(selectedBotId <= 0)" Class="mr-2" />
<MudTextField @bind-Value="search" Placeholder="搜索频道..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"
Immediate="true" 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>
<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>
<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>
<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.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>
</ToolBarContent>
<HeaderContent>
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>分类</MudTh>
<MudTh>最后同步</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="频道名称">
<MudText Typo="Typo.body2"><strong>@context.Title</strong></MudText>
</MudTd>
<MudTd DataLabel="用户名">
@if (!string.IsNullOrWhiteSpace(context.Username))
{
<MudChip T="string" Size="Size.Small" Icon="@Icons.Material.Filled.AlternateEmail">@context.Username</MudChip>
}
else
{
<span>-</span>
}
</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>
</MudTd>
<MudTd DataLabel="最后同步">@context.SyncedAt.ToString("yyyy-MM-dd HH:mm")</MudTd>
<MudTd DataLabel="操作">
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small">
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyLinkAsync(context))">复制链接</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Info" OnClick="@(async () => await ShowDetailsAsync(context))">查看详情</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Edit" OnClick="@(async () => await EditChannelAsync(context))">编辑频道</MudMenuItem>
</MudMenu>
</MudTd>
</RowTemplate>
<NoRecordsContent>
@if (selectedBotId <= 0)
{
<MudText>请先选择机器人</MudText>
}
else
{
<MudText>暂无频道</MudText>
}
</NoRecordsContent>
</MudTable>
</MudCardContent>
</MudCard>
@code {
[SupplyParameterFromQuery(Name = "botId")]
public int? BotIdQuery { get; set; }
private List<Bot> bots = new();
private Bot? selectedBot;
private int selectedBotId = 0;
private bool loading = true;
private List<BotChannelCategory> categories = new();
private List<BotChannel> channels = new();
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);
});
protected override async Task OnInitializedAsync()
{
try
{
bots = (await BotManagement.GetAllBotsAsync()).ToList();
if (BotIdQuery is > 0 && bots.Any(x => x.Id == BotIdQuery.Value))
{
selectedBotId = BotIdQuery.Value;
await LoadBotData();
}
else if (bots.Count == 1)
{
selectedBotId = bots[0].Id;
await LoadBotData();
}
}
catch (Exception ex)
{
Snackbar.Add($"加载机器人失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task OnSelectedBotChanged(int botId)
{
if (selectedBotId == botId)
return;
selectedBotId = botId;
filterCategoryId = 0;
search = "";
selected.Clear();
if (selectedBotId <= 0)
{
selectedBot = null;
categories.Clear();
channels.Clear();
Navigation.NavigateTo("/bots/channels", replace: true);
return;
}
Navigation.NavigateTo($"/bots/channels?botId={selectedBotId}", replace: true);
await LoadBotData();
}
private async Task LoadBotData()
{
if (selectedBotId <= 0)
return;
loading = true;
try
{
selectedBot = await BotManagement.GetBotAsync(selectedBotId);
if (selectedBot == null)
{
Snackbar.Add("机器人不存在", Severity.Error);
selectedBotId = 0;
return;
}
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();
}
catch (Exception ex)
{
Snackbar.Add($"加载失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task Sync()
{
if (selectedBotId <= 0)
return;
loading = true;
try
{
var count = await BotTelegram.SyncBotChannelsAsync(selectedBotId, CancellationToken.None);
Snackbar.Add($"同步完成:影响 {count} 个频道", Severity.Success);
await LoadBotData();
}
catch (Exception ex)
{
Snackbar.Add($"同步失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task OpenCreateCategoryDialog()
{
if (selectedBotId <= 0)
return;
var parameters = new DialogParameters
{
["BotId"] = selectedBotId
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BotChannelCategoryDialog>("新建分类", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
{
// 重新加载分类列表
await LoadBotData();
}
}
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)
return;
loading = true;
try
{
var link = await BotTelegram.ExportInviteLinkAsync(selectedBotId, channel.TelegramId, CancellationToken.None);
await JS.InvokeVoidAsync("telegramPanel.copyText", link);
Snackbar.Add("已复制链接", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"复制失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task ExportInvites()
{
if (selectedBotId <= 0)
return;
if (selected.Count == 0)
{
Snackbar.Add("请先选择频道", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认导出",
$"将为 {selected.Count} 个频道生成邀请链接并导出(需要机器人具备管理权限)。是否继续?",
yesText: "继续", cancelText: "取消");
if (result != true)
return;
var ids = string.Join(",", selected.Select(x => x.TelegramId));
var url = $"/downloads/bots/{selectedBotId}/invites.txt?ids={Uri.EscapeDataString(ids)}";
Navigation.NavigateTo(url, forceLoad: true);
await Task.CompletedTask;
}
private async Task OpenSetAdmins()
{
if (selectedBotId <= 0)
return;
if (selected.Count == 0)
{
Snackbar.Add("请先选择频道", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["BotId"] = selectedBotId,
["ChannelTelegramIds"] = selected.Select(x => x.TelegramId).Distinct().ToList()
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BotSetAdminsDialog>("批量设置管理员", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
{
// 管理员设置后不影响本地数据,但用户可能期望刷新一下
await LoadBotData();
}
}
private async Task OpenBatchSetCategory()
{
if (selectedBotId <= 0)
return;
if (selected.Count == 0)
{
Snackbar.Add("请先选择频道", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["Categories"] = categories,
["SelectedCount"] = selected.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BotBatchSetCategoryDialog>("批量设置分类", parameters, options);
var result = await dialog.Result;
if (!result.Canceled && result.Data is int categoryId)
{
loading = true;
try
{
var channelIds = selected.Select(x => x.Id).ToList();
int successCount = 0;
int failCount = 0;
foreach (var channelId in channelIds)
{
try
{
await BotManagement.SetChannelCategoryAsync(channelId, categoryId == 0 ? null : categoryId);
successCount++;
}
catch
{
failCount++;
}
}
if (failCount == 0)
{
Snackbar.Add($"已成功设置 {successCount} 个频道的分类", Severity.Success);
}
else
{
Snackbar.Add($"成功:{successCount} 个,失败:{failCount} 个", Severity.Warning);
}
// 重新加载数据
await LoadBotData();
selected.Clear();
}
catch (Exception ex)
{
Snackbar.Add($"批量设置失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
}
private async Task OpenBanMember()
{
if (selectedBotId <= 0)
return;
if (selected.Count == 0)
{
Snackbar.Add("请先选择频道", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["SelectedCount"] = selected.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BotBanMemberDialog>("移除用户", parameters, options);
var result = await dialog.Result;
if (!result.Canceled && result.Data is BotBanMemberDialog.BanUserRequest request)
{
loading = true;
try
{
var channelTelegramIds = selected.Select(x => x.TelegramId).ToList();
var banResult = await BotTelegram.BanChatMemberAsync(
selectedBotId,
channelTelegramIds,
request.UserId,
request.PermanentBan,
CancellationToken.None);
var actionText = request.PermanentBan ? "封禁" : "踢出";
if (banResult.Failures.Count == 0)
{
Snackbar.Add($"已成功从 {banResult.SuccessCount} 个频道{actionText}用户 {request.UserId}", Severity.Success);
}
else
{
var msg = $"成功:{banResult.SuccessCount} 个,失败:{banResult.Failures.Count} 个";
if (banResult.Failures.Count > 0)
{
var firstError = banResult.Failures.First();
msg += $"(首个失败原因:{firstError.Value})";
}
Snackbar.Add(msg, Severity.Warning);
}
selected.Clear();
}
catch (Exception ex)
{
Snackbar.Add($"操作失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
}
private async Task ShowDetailsAsync(BotChannel channel)
{
if (selectedBotId <= 0)
return;
var parameters = new DialogParameters
{
["BotId"] = selectedBotId,
["ChannelTelegramId"] = channel.TelegramId
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true };
DialogService.Show<BotChannelDetailsDialog>("频道详情", parameters, options);
await Task.CompletedTask;
}
private async Task EditChannelAsync(BotChannel channel)
{
if (selectedBotId <= 0)
return;
var parameters = new DialogParameters
{
["BotId"] = selectedBotId,
["ChannelTelegramId"] = channel.TelegramId
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<BotChannelEditDialog>("编辑频道", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
await LoadBotData();
}
}