feat: add category filters and sync action

This commit is contained in:
meoacgx committed 2026-03-09 01:28:09 +08:00
1 parent da44797626
commit 7dc1eea3c0
25 files changed
+1707 -110

No files matched your search

@@ -5,11 +5,11 @@
<DialogContent>
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info">
将为 <strong>@SelectedCount</strong> 个频道设置分组
将为 <strong>@SelectedCount</strong> 个频道设置分类
</MudAlert>
<MudSelect @bind-Value="selectedGroupId" Label="选择目标分组" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">未分组</MudSelectItem>
<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>
@@ -0,0 +1,40 @@
@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="selectedCategoryId" Label="选择目标分类" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var category in Categories)
{
<MudSelectItem Value="@category.Id">@category.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<GroupCategory> Categories { get; set; } = new();
[Parameter] public int SelectedCount { get; set; }
private int selectedCategoryId;
private void Cancel() => MudDialog.Cancel();
private void Confirm()
{
MudDialog.Close(DialogResult.Ok(selectedCategoryId));
}
}
@@ -15,11 +15,12 @@
<MudNavGroup Title="频道管理" Icon="@Icons.Material.Filled.Campaign" Expanded="true">
<MudNavLink Href="/channels" Icon="@Icons.Material.Filled.List">频道列表</MudNavLink>
<MudNavLink Href="/channels/create" Icon="@Icons.Material.Filled.Add">创建频道</MudNavLink>
<MudNavLink Href="/channels/groups" Icon="@Icons.Material.Filled.Folder">频道分组</MudNavLink>
<MudNavLink Href="/channels/groups" Icon="@Icons.Material.Filled.Folder">频道分类</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="群组管理" Icon="@Icons.Material.Filled.Group">
<MudNavLink Href="/groups" Icon="@Icons.Material.Filled.List">群组列表</MudNavLink>
<MudNavLink Href="/groups/categories" Icon="@Icons.Material.Filled.Folder">群组分类</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="机器人管理" Icon="@Icons.Material.Filled.SmartToy">
@@ -1,28 +1,33 @@
@page "/channels/groups"
@inject ChannelGroupManagementService GroupManagement
@inject ChannelManagementService ChannelManagement
@inject AccountManagementService AccountManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
<PageTitle>频道分组 - Telegram Panel</PageTitle>
<PageTitle>频道分类 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">频道分组管理</MudText>
<MudText Typo="Typo.h4" Class="mb-4">频道分类管理</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">添加分组</MudText>
<MudText Typo="Typo.h6">@(editingGroupId > 0 ? "编辑分类" : "添加分类")</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="newGroupName" Label="分组名称" Variant="Variant.Outlined" />
<MudTextField @bind-Value="newGroupName" Label="分类名称" Variant="Variant.Outlined" />
<MudTextField @bind-Value="newGroupDesc" Label="描述" Variant="Variant.Outlined" Lines="3" Class="mt-3" />
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Disabled="@string.IsNullOrEmpty(newGroupName)" OnClick="AddGroup">
添加分组
@if (editingGroupId > 0)
{
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelEdit">取消</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="@(editingGroupId <= 0)"
Disabled="@string.IsNullOrWhiteSpace(newGroupName)" OnClick="SaveGroup">
@(editingGroupId > 0 ? "保存修改" : "添加分类")
</MudButton>
</MudCardActions>
</MudCard>
@@ -31,27 +36,31 @@
<MudItem xs="12" md="8">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">分组列表</MudText>
<MudText Typo="Typo.h6">分类列表</MudText>
</MudCardHeader>
<MudCardContent>
<MudTable Items="@groups" Hover="true" Loading="@loading">
<HeaderContent>
<MudTh>分组名称</MudTh>
<MudTh>分类名称</MudTh>
<MudTh>描述</MudTh>
<MudTh>频道数量</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="分组名称">@context.Name</MudTd>
<MudTd DataLabel="分类名称">@context.Name</MudTd>
<MudTd DataLabel="描述">@(context.Description ?? "-")</MudTd>
<MudTd DataLabel="频道数量">@context.Channels.Count</MudTd>
<MudTd DataLabel="操作">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteGroup(context))" />
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(() => BeginEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteGroup(context))" />
</MudStack>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无分组,请添加分组</MudText>
<MudText>暂无分类,请添加分类</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
@@ -59,32 +68,43 @@
<MudCard Class="mt-4">
<MudCardHeader>
<MudText Typo="Typo.h6">分组绑定频道</MudText>
<MudText Typo="Typo.h6">分类绑定频道</MudText>
</MudCardHeader>
<MudCardContent>
<MudSelect @bind-Value="assignGroupId" Label="选择分组" Variant="Variant.Outlined" Dense="true"
@bind-Value:after="OnAssignGroupChanged">
<MudSelectItem Value="0">-- 请选择分组 --</MudSelectItem>
@foreach (var g in groups)
{
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
}
</MudSelect>
<MudStack Row="true" Spacing="2" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="assignGroupId" Label="选择分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignGroupChanged">
<MudSelectItem Value="0">-- 请选择分类 --</MudSelectItem>
@foreach (var g in groups)
{
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
}
</MudSelect>
<MudTable Items="@channels" Hover="true" Dense="true" Class="mt-3"
<MudSelect @bind-Value="filterAccountId" Label="筛选账号" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignGroupChanged">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudTable Items="@VisibleChannels" Hover="true" Dense="true" Class="mt-3"
MultiSelection="true" @bind-SelectedItems="selectedChannels"
Loading="@channelsLoading">
<HeaderContent>
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>创建账号</MudTh>
<MudTh>当前分组</MudTh>
<MudTh>当前分类</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="频道名称">@context.Title</MudTd>
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : context.Username)</MudTd>
<MudTd DataLabel="创建账号">@(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)"))</MudTd>
<MudTd DataLabel="当前分组">@(context.Group?.Name ?? "未分组")</MudTd>
<MudTd DataLabel="当前分类">@(context.Group?.Name ?? "未分类")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无频道数据</MudText>
@@ -95,7 +115,7 @@
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(assignGroupId == 0 || channelsLoading)"
OnClick="@(async (MouseEventArgs _) => await SaveGroupAssignments())">
保存勾选到分组
保存勾选到分类
</MudButton>
</MudCardActions>
</MudCard>
@@ -105,20 +125,41 @@
@code {
private string newGroupName = "";
private string newGroupDesc = "";
private int editingGroupId = 0;
private List<ChannelGroup> groups = new();
private bool loading = true;
private List<Channel> channels = new();
private List<Account> accounts = new();
private bool channelsLoading = true;
private int assignGroupId = 0;
private int filterAccountId = 0;
private HashSet<Channel> selectedChannels = new();
private IEnumerable<Channel> VisibleChannels => channels.Where(MatchAccountFilter);
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadGroups();
await LoadChannels();
}
private async Task LoadAccounts()
{
try
{
var accountList = await AccountManagement.GetAllAccountsAsync();
accounts = accountList
.Where(a => a.Category?.ExcludeFromOperations != true)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error);
}
}
private async Task LoadGroups()
{
loading = true;
@@ -144,6 +185,7 @@
{
var list = await ChannelManagement.GetAllChannelsAsync();
channels = list.ToList();
await OnAssignGroupChanged();
}
catch (Exception ex)
{
@@ -155,11 +197,19 @@
}
}
private bool MatchAccountFilter(Channel channel)
{
if (filterAccountId <= 0)
return true;
return channel.AccountChannels.Any(x => x.AccountId == filterAccountId);
}
private Task OnAssignGroupChanged()
{
selectedChannels = assignGroupId <= 0
? new HashSet<Channel>()
: channels.Where(c => c.GroupId == assignGroupId).ToHashSet();
: VisibleChannels.Where(c => c.GroupId == assignGroupId).ToHashSet();
return Task.CompletedTask;
}
@@ -167,30 +217,30 @@
{
if (assignGroupId <= 0)
{
Snackbar.Add("请选择分组", Severity.Error);
Snackbar.Add("请选择分类", Severity.Error);
return;
}
try
{
var currentlyInGroup = channels.Where(c => c.GroupId == assignGroupId).ToList();
var scope = VisibleChannels.ToList();
var currentlyInGroup = scope.Where(c => c.GroupId == assignGroupId).ToList();
var selected = selectedChannels.ToHashSet();
// 取消勾选的 -> 移出分组
foreach (var channel in currentlyInGroup.Where(c => !selected.Contains(c)))
{
await ChannelManagement.UpdateChannelGroupAsync(channel.Id, null);
}
// 新勾选的 -> 加入分组
foreach (var channel in selected.Where(c => c.GroupId != assignGroupId))
{
await ChannelManagement.UpdateChannelGroupAsync(channel.Id, assignGroupId);
}
await LoadGroups();
await LoadChannels();
selectedChannels = channels.Where(c => c.GroupId == assignGroupId).ToHashSet();
Snackbar.Add("分组绑定已保存", Severity.Success);
selectedChannels = VisibleChannels.Where(c => c.GroupId == assignGroupId).ToHashSet();
Snackbar.Add("分类绑定已保存", Severity.Success);
}
catch (Exception ex)
{
@@ -198,35 +248,66 @@
}
}
private async Task AddGroup()
private async Task SaveGroup()
{
try
{
var group = new ChannelGroup
if (editingGroupId > 0)
{
Name = newGroupName,
Description = newGroupDesc,
CreatedAt = DateTime.UtcNow
};
var existing = await GroupManagement.GetGroupAsync(editingGroupId);
if (existing == null)
{
Snackbar.Add("分类不存在", Severity.Error);
return;
}
await GroupManagement.CreateGroupAsync(group);
existing.Name = newGroupName.Trim();
existing.Description = string.IsNullOrWhiteSpace(newGroupDesc) ? null : newGroupDesc.Trim();
await GroupManagement.UpdateGroupAsync(existing);
Snackbar.Add($"分类 \"{newGroupName}\" 修改成功", Severity.Success);
}
else
{
var group = new ChannelGroup
{
Name = newGroupName.Trim(),
Description = string.IsNullOrWhiteSpace(newGroupDesc) ? null : newGroupDesc.Trim(),
CreatedAt = DateTime.UtcNow
};
await GroupManagement.CreateGroupAsync(group);
Snackbar.Add($"分类 \"{newGroupName}\" 添加成功", Severity.Success);
}
CancelEdit();
await LoadGroups();
Snackbar.Add($"分组 \"{newGroupName}\" 添加成功", Severity.Success);
newGroupName = "";
newGroupDesc = "";
await LoadChannels();
}
catch (Exception ex)
{
Snackbar.Add($"添加失败:{ex.Message}", Severity.Error);
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
}
private void BeginEdit(ChannelGroup group)
{
editingGroupId = group.Id;
newGroupName = group.Name;
newGroupDesc = group.Description ?? string.Empty;
}
private void CancelEdit()
{
editingGroupId = 0;
newGroupName = string.Empty;
newGroupDesc = string.Empty;
}
private async Task DeleteGroup(ChannelGroup group)
{
bool? result = await DialogService.ShowMessageBox(
"确认删除",
$"确定要删除分组 {group.Name} 吗?关联的频道将变为未分组。",
$"确定要删除分类 {group.Name} 吗?关联的频道将变为未分类。",
yesText: "删除", cancelText: "取消");
if (result == true)
@@ -234,7 +315,10 @@
try
{
await GroupManagement.DeleteGroupAsync(group.Id);
groups.Remove(group);
if (editingGroupId == group.Id)
CancelEdit();
await LoadGroups();
await LoadChannels();
Snackbar.Add("删除成功", Severity.Success);
}
catch (Exception ex)
@@ -243,4 +327,15 @@
}
}
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}
@@ -32,13 +32,21 @@
<MudSelectItem Value='@("private")'>私密</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterMembershipRole" @bind-Value:after="OnFiltersChanged" Label="账号角色" Variant="Variant.Outlined" Dense="true"
Disabled="@(filterAccount <= 0)"
Class="tp-input-full-xs" Style="min-width: 140px; flex: 0 1 180px;">
<MudSelectItem Value='@("all")'>全部角色</MudSelectItem>
<MudSelectItem Value='@("creator")'>我创建</MudSelectItem>
<MudSelectItem Value='@("admin")'>我管理</MudSelectItem>
<MudSelectItem Value='@("creator")'>创建者</MudSelectItem>
<MudSelectItem Value='@("admin")'>管理员</MudSelectItem>
<MudSelectItem Value='@("member")'>非管理员</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterGroupId" @bind-Value:after="OnFiltersChanged" Label="频道分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 160px; flex: 0 1 220px;">
<MudSelectItem Value='@(-1)'>全部分类</MudSelectItem>
<MudSelectItem Value='@(0)'>未分类</MudSelectItem>
@foreach (var group in groups)
{
<MudSelectItem Value="@group.Id">@group.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="searchString" Placeholder="搜索频道..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0 tp-input-full-xs"
Style="min-width: 200px; flex: 1; max-width: 400px;"
@@ -67,7 +75,7 @@
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="BatchCopyInvitesSelected">批量复制邀请链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="BatchExportInvitesSelected">批量导出邀请链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetAdminsSelected">批量设置管理员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分组(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分类(已选)</MudMenuItem>
</MudMenu>
@if (selectedChannels.Count > 0)
@@ -89,6 +97,7 @@
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>类型</MudTh>
<MudTh>频道分类</MudTh>
<MudTh>成员数</MudTh>
@if (filterAccount > 0)
{
@@ -126,6 +135,11 @@
@(string.IsNullOrEmpty(context.Username) ? "私密" : "公开")
</MudChip>
</MudTd>
<MudTd DataLabel="频道分类">
<MudChip T="string" Size="Size.Small" Color="@(context.GroupId.HasValue ? Color.Info : Color.Default)">
@(context.Group?.Name ?? "未分类")
</MudChip>
</MudTd>
<MudTd DataLabel="成员数">@context.MemberCount</MudTd>
@if (filterAccount > 0)
{
@@ -189,6 +203,7 @@
private bool loading = true;
private string searchString = "";
private int filterAccount = 0;
private int filterGroupId = -1;
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet<Channel> selectedChannels = new();
@@ -241,7 +256,7 @@
}
catch (Exception ex)
{
Snackbar.Add($"加载分组失败:{ex.Message}", Severity.Error);
Snackbar.Add($"加载分类失败:{ex.Message}", Severity.Error);
}
}
@@ -252,8 +267,9 @@
{
var (items, total) = await ChannelManagement.QueryChannelsForViewPagedAsync(
accountId: filterAccount,
groupId: filterGroupId,
filterType: filterType,
membershipRole: filterAccount > 0 ? filterMembershipRole : "all",
membershipRole: filterMembershipRole,
search: searchString,
pageIndex: state.Page,
pageSize: state.PageSize,
@@ -310,9 +326,6 @@
private async Task OnFiltersChanged()
{
if (filterAccount <= 0)
filterMembershipRole = "all";
await ReloadTable();
}
@@ -684,7 +697,7 @@
["SelectedCount"] = targets.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BatchSetChannelGroupDialog>("批量修改分组", parameters, options);
var dialog = DialogService.Show<BatchSetChannelGroupDialog>("批量修改分类", parameters, options);
var result = await dialog.Result;
if (result is not { Canceled: false })
return;
@@ -700,11 +713,11 @@
selectedChannels.Clear();
await ReloadTable();
Snackbar.Add($"分组已更新:{targets.Count} 个频道", Severity.Success);
Snackbar.Add($"分类已更新:{targets.Count} 个频道", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"修改分组失败:{ex.Message}", Severity.Error);
Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
}
finally
{
@@ -0,0 +1,341 @@
@page "/groups/categories"
@inject GroupCategoryManagementService CategoryManagement
@inject GroupManagementService GroupManagement
@inject AccountManagementService AccountManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
<PageTitle>群组分类 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">群组分类管理</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">@(editingCategoryId > 0 ? "编辑分类" : "添加分类")</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="newCategoryName" Label="分类名称" Variant="Variant.Outlined" />
<MudTextField @bind-Value="newCategoryDesc" Label="描述" Variant="Variant.Outlined" Lines="3" Class="mt-3" />
</MudCardContent>
<MudCardActions>
@if (editingCategoryId > 0)
{
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelEdit">取消</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="@(editingCategoryId <= 0)"
Disabled="@string.IsNullOrWhiteSpace(newCategoryName)" OnClick="SaveCategory">
@(editingCategoryId > 0 ? "保存修改" : "添加分类")
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
<MudItem xs="12" md="8">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">分类列表</MudText>
</MudCardHeader>
<MudCardContent>
<MudTable Items="@categories" Hover="true" Loading="@categoriesLoading">
<HeaderContent>
<MudTh>分类名称</MudTh>
<MudTh>描述</MudTh>
<MudTh>群组数量</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="分类名称">@context.Name</MudTd>
<MudTd DataLabel="描述">@(context.Description ?? "-")</MudTd>
<MudTd DataLabel="群组数量">@context.Groups.Count</MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(() => BeginEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteCategory(context))" />
</MudStack>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无分类,请添加分类</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
</MudCard>
<MudCard Class="mt-4">
<MudCardHeader>
<MudText Typo="Typo.h6">分类绑定群组</MudText>
</MudCardHeader>
<MudCardContent>
<MudStack Row="true" Spacing="2" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="assignCategoryId" Label="选择分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignCategoryChanged">
<MudSelectItem Value="0">-- 请选择分类 --</MudSelectItem>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="filterAccountId" Label="筛选账号" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignCategoryChanged">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudTable Items="@VisibleGroups" Hover="true" Dense="true" Class="mt-3"
MultiSelection="true" @bind-SelectedItems="selectedGroups"
Loading="@groupsLoading">
<HeaderContent>
<MudTh>群组名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>创建账号</MudTh>
<MudTh>当前分类</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="群组名称">@context.Title</MudTd>
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : context.Username)</MudTd>
<MudTd DataLabel="创建账号">@(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)"))</MudTd>
<MudTd DataLabel="当前分类">@(context.Category?.Name ?? "未分类")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无群组数据</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(assignCategoryId == 0 || groupsLoading)"
OnClick="@(async (MouseEventArgs _) => await SaveCategoryAssignments())">
保存勾选到分类
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
</MudGrid>
@code {
private string newCategoryName = string.Empty;
private string newCategoryDesc = string.Empty;
private int editingCategoryId;
private List<GroupCategory> categories = new();
private bool categoriesLoading = true;
private List<Group> groups = new();
private List<Account> accounts = new();
private bool groupsLoading = true;
private int assignCategoryId;
private int filterAccountId;
private HashSet<Group> selectedGroups = new();
private IEnumerable<Group> VisibleGroups => groups.Where(MatchAccountFilter);
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadCategories();
await LoadGroups();
}
private async Task LoadAccounts()
{
try
{
var accountList = await AccountManagement.GetAllAccountsAsync();
accounts = accountList
.Where(a => a.Category?.ExcludeFromOperations != true)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error);
}
}
private async Task LoadCategories()
{
categoriesLoading = true;
try
{
var result = await CategoryManagement.GetAllCategoriesAsync();
categories = result.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载分类失败:{ex.Message}", Severity.Error);
}
finally
{
categoriesLoading = false;
}
}
private async Task LoadGroups()
{
groupsLoading = true;
try
{
var list = await GroupManagement.GetAllGroupsAsync();
groups = list.ToList();
await OnAssignCategoryChanged();
}
catch (Exception ex)
{
Snackbar.Add($"加载群组失败:{ex.Message}", Severity.Error);
}
finally
{
groupsLoading = false;
}
}
private bool MatchAccountFilter(Group group)
{
if (filterAccountId <= 0)
return true;
return group.AccountGroups.Any(x => x.AccountId == filterAccountId);
}
private Task OnAssignCategoryChanged()
{
selectedGroups = assignCategoryId <= 0
? new HashSet<Group>()
: VisibleGroups.Where(g => g.CategoryId == assignCategoryId).ToHashSet();
return Task.CompletedTask;
}
private async Task SaveCategoryAssignments()
{
if (assignCategoryId <= 0)
{
Snackbar.Add("请选择分类", Severity.Error);
return;
}
try
{
var scope = VisibleGroups.ToList();
var currentlyInCategory = scope.Where(g => g.CategoryId == assignCategoryId).ToList();
var selected = selectedGroups.ToHashSet();
foreach (var group in currentlyInCategory.Where(g => !selected.Contains(g)))
{
await GroupManagement.UpdateGroupCategoryAsync(group.Id, null);
}
foreach (var group in selected.Where(g => g.CategoryId != assignCategoryId))
{
await GroupManagement.UpdateGroupCategoryAsync(group.Id, assignCategoryId);
}
await LoadCategories();
await LoadGroups();
selectedGroups = VisibleGroups.Where(g => g.CategoryId == assignCategoryId).ToHashSet();
Snackbar.Add("分类绑定已保存", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
}
private async Task SaveCategory()
{
try
{
if (editingCategoryId > 0)
{
var existing = await CategoryManagement.GetCategoryAsync(editingCategoryId);
if (existing == null)
{
Snackbar.Add("分类不存在", Severity.Error);
return;
}
existing.Name = newCategoryName.Trim();
existing.Description = string.IsNullOrWhiteSpace(newCategoryDesc) ? null : newCategoryDesc.Trim();
await CategoryManagement.UpdateCategoryAsync(existing);
Snackbar.Add($"分类 \"{newCategoryName}\" 修改成功", Severity.Success);
}
else
{
var category = new GroupCategory
{
Name = newCategoryName.Trim(),
Description = string.IsNullOrWhiteSpace(newCategoryDesc) ? null : newCategoryDesc.Trim(),
CreatedAt = DateTime.UtcNow
};
await CategoryManagement.CreateCategoryAsync(category);
Snackbar.Add($"分类 \"{newCategoryName}\" 添加成功", Severity.Success);
}
CancelEdit();
await LoadCategories();
await LoadGroups();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
}
private void BeginEdit(GroupCategory category)
{
editingCategoryId = category.Id;
newCategoryName = category.Name;
newCategoryDesc = category.Description ?? string.Empty;
}
private void CancelEdit()
{
editingCategoryId = 0;
newCategoryName = string.Empty;
newCategoryDesc = string.Empty;
}
private async Task DeleteCategory(GroupCategory category)
{
bool? result = await DialogService.ShowMessageBox(
"确认删除",
$"确定要删除分类 {category.Name} 吗?关联的群组将变为未分类。",
yesText: "删除", cancelText: "取消");
if (result == true)
{
try
{
await CategoryManagement.DeleteCategoryAsync(category.Id);
if (editingCategoryId == category.Id)
CancelEdit();
await LoadCategories();
await LoadGroups();
Snackbar.Add("删除成功", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"删除失败:{ex.Message}", Severity.Error);
}
}
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}
@@ -1,5 +1,6 @@
@page "/groups"
@inject GroupManagementService GroupManagement
@inject GroupCategoryManagementService GroupCategoryManagement
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Web.Services.DataSyncService DataSync
@inject IGroupService GroupService
@@ -31,13 +32,21 @@
<MudSelectItem Value='@("private")'>私密</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterMembershipRole" @bind-Value:after="OnFiltersChanged" Label="账号角色" Variant="Variant.Outlined" Dense="true"
Disabled="@(filterAccount <= 0)"
Class="tp-input-full-xs" Style="min-width: 140px; flex: 0 1 180px;">
<MudSelectItem Value='@("all")'>全部角色</MudSelectItem>
<MudSelectItem Value='@("creator")'>我创建</MudSelectItem>
<MudSelectItem Value='@("admin")'>我管理</MudSelectItem>
<MudSelectItem Value='@("member")'>非管理员</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterCategoryId" @bind-Value:after="OnFiltersChanged" Label="群组分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 160px; flex: 0 1 220px;">
<MudSelectItem Value='@(-1)'>全部分类</MudSelectItem>
<MudSelectItem Value='@(0)'>未分类</MudSelectItem>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="searchString" Placeholder="搜索群组..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0 tp-input-full-xs"
Style="min-width: 200px; flex: 1; max-width: 400px;"
@@ -64,6 +73,7 @@
Size="Size.Small" Disabled="@loading" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="BatchCopyLinksSelected">批量复制加入链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="BatchExportLinksSelected">批量导出加入链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="OpenBatchSetCategorySelected">批量修改分类(已选)</MudMenuItem>
<MudDivider />
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="DeleteSelectedGroups">批量删除(已选)</MudMenuItem>
</MudMenu>
@@ -87,6 +97,7 @@
<MudTh>群组名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>类型</MudTh>
<MudTh>群组分类</MudTh>
<MudTh>成员数</MudTh>
@if (filterAccount > 0)
{
@@ -124,6 +135,11 @@
@(string.IsNullOrWhiteSpace(context.Username) ? "私密" : "公开")
</MudChip>
</MudTd>
<MudTd DataLabel="群组分类">
<MudChip T="string" Size="Size.Small" Color="@(context.CategoryId.HasValue ? Color.Info : Color.Default)">
@(context.Category?.Name ?? "未分类")
</MudChip>
</MudTd>
<MudTd DataLabel="成员数">@context.MemberCount</MudTd>
@if (filterAccount > 0)
{
@@ -177,10 +193,12 @@
private MudTable<Group>? _table;
private List<Group> groups = new();
private List<Account> accounts = new();
private List<GroupCategory> categories = new();
private int totalCount;
private bool loading = true;
private string searchString = "";
private int filterAccount = 0;
private int filterCategoryId = -1;
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet<Group> selectedGroups = new();
@@ -206,6 +224,7 @@
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadCategories();
}
private async Task LoadAccounts()
@@ -223,6 +242,19 @@
}
}
private async Task LoadCategories()
{
try
{
var list = await GroupCategoryManagement.GetAllCategoriesAsync();
categories = list.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载分类失败:{ex.Message}", Severity.Error);
}
}
private async Task<TableData<Group>> LoadGroupsServerData(TableState state, CancellationToken cancellationToken)
{
loading = true;
@@ -230,8 +262,9 @@
{
var (items, total) = await GroupManagement.QueryGroupsForViewPagedAsync(
accountId: filterAccount,
categoryId: filterCategoryId,
filterType: filterType,
membershipRole: filterAccount > 0 ? filterMembershipRole : "all",
membershipRole: filterMembershipRole,
search: searchString,
pageIndex: state.Page,
pageSize: state.PageSize,
@@ -289,9 +322,6 @@
private async Task OnFiltersChanged()
{
if (filterAccount <= 0)
filterMembershipRole = "all";
await ReloadTable();
}
@@ -491,6 +521,52 @@
await Task.CompletedTask;
}
private async Task OpenBatchSetCategorySelected()
{
var targets = selectedGroups.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
if (categories.Count == 0)
await LoadCategories();
var parameters = new DialogParameters
{
["Categories"] = categories,
["SelectedCount"] = targets.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BatchSetGroupCategoryDialog>("批量修改分类", parameters, options);
var result = await dialog.Result;
if (result is not { Canceled: false })
return;
var categoryIdRaw = result.Data is int x ? x : 0;
int? categoryId = categoryIdRaw <= 0 ? null : categoryIdRaw;
loading = true;
try
{
foreach (var group in targets)
await GroupManagement.UpdateGroupCategoryAsync(group.Id, categoryId);
selectedGroups.Clear();
await ReloadTable();
Snackbar.Add($"分类已更新:{targets.Count} 个群组", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task SyncCurrentAccountGroups()
{
if (loading || filterAccount <= 0)
@@ -158,10 +158,20 @@
<MudNumericField @bind-Value="syncInterval" Label="同步间隔 (小时)" Variant="Variant.Outlined" Class="mt-3"
Min="1" Max="24" />
}
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true" Class="mt-3"
OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())">
保存同步设置
</MudButton>
<MudStack Row="true" Spacing="2" Class="mt-3" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Class="tp-input-full-xs"
Style="flex: 1; min-width: 220px;"
OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())">
保存同步设置
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Info" Class="tp-input-full-xs"
Style="flex: 1; min-width: 220px;"
Disabled="@syncing"
OnClick="@(async (MouseEventArgs _) => await SyncNow())">
@(syncing ? "同步中..." : "立即同步频道/群组")
</MudButton>
</MudStack>
<MudDivider Class="my-4" />
@@ -180,10 +190,6 @@
保存 Bot 自动同步设置
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" FullWidth="true" Class="mt-4"
OnClick="@(async (MouseEventArgs _) => await SyncNow())">
立即同步
</MudButton>
</MudCardContent>
</MudCard>