feat: add category filters and sync action

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

View File

@@ -35,6 +35,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ChannelManagementService>();
services.AddScoped<ChannelGroupManagementService>();
services.AddScoped<GroupManagementService>();
services.AddScoped<GroupCategoryManagementService>();
services.AddScoped<BatchTaskManagementService>();
services.AddScoped<BotManagementService>();

View File

@@ -37,6 +37,7 @@ public class ChannelManagementService
/// </summary>
public async Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryChannelsForViewPagedAsync(
int accountId,
int? groupId,
string? filterType,
string? membershipRole,
string? search,
@@ -44,7 +45,7 @@ public class ChannelManagementService
int pageSize,
CancellationToken cancellationToken = default)
{
return await _channelRepository.QueryForViewPagedAsync(accountId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
return await _channelRepository.QueryForViewPagedAsync(accountId, groupId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
}
public async Task<IEnumerable<Channel>> GetChannelsByCreatorAsync(int accountId)

View File

@@ -0,0 +1,49 @@
using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
namespace TelegramPanel.Core.Services;
/// <summary>
/// 群组分类管理服务
/// </summary>
public class GroupCategoryManagementService
{
private readonly IGroupCategoryRepository _categoryRepository;
public GroupCategoryManagementService(IGroupCategoryRepository categoryRepository)
{
_categoryRepository = categoryRepository;
}
public async Task<IEnumerable<GroupCategory>> GetAllCategoriesAsync()
{
return await _categoryRepository.GetAllAsync();
}
public async Task<GroupCategory?> GetCategoryAsync(int id)
{
return await _categoryRepository.GetByIdAsync(id);
}
public async Task<GroupCategory?> GetCategoryByNameAsync(string name)
{
return await _categoryRepository.GetByNameAsync(name);
}
public async Task<GroupCategory> CreateCategoryAsync(GroupCategory category)
{
return await _categoryRepository.AddAsync(category);
}
public async Task UpdateCategoryAsync(GroupCategory category)
{
await _categoryRepository.UpdateAsync(category);
}
public async Task DeleteCategoryAsync(int id)
{
var category = await _categoryRepository.GetByIdAsync(id);
if (category != null)
await _categoryRepository.DeleteAsync(category);
}
}

View File

@@ -34,6 +34,7 @@ public class GroupManagementService
public async Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryGroupsForViewPagedAsync(
int accountId,
int? categoryId,
string? filterType,
string? membershipRole,
string? search,
@@ -41,7 +42,7 @@ public class GroupManagementService
int pageSize,
CancellationToken cancellationToken = default)
{
return await _groupRepository.QueryForViewPagedAsync(accountId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
return await _groupRepository.QueryForViewPagedAsync(accountId, categoryId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
}
public async Task<IEnumerable<Group>> GetGroupsByCreatorAsync(int accountId)
@@ -62,6 +63,8 @@ public class GroupManagementService
existing.MemberCount = group.MemberCount;
existing.About = group.About;
existing.AccessHash = group.AccessHash;
if (group.CategoryId.HasValue)
existing.CategoryId = group.CategoryId;
if (existing.CreatorAccountId == null && group.CreatorAccountId != null)
existing.CreatorAccountId = group.CreatorAccountId;
if (group.CreatedAt.HasValue)
@@ -89,6 +92,17 @@ public class GroupManagementService
await _groupRepository.DeleteAsync(group);
}
public async Task UpdateGroupCategoryAsync(int groupId, int? categoryId)
{
var group = await _groupRepository.GetByIdAsync(groupId);
if (group != null)
{
group.CategoryId = categoryId;
group.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(group);
}
}
public async Task<int> GetTotalGroupCountAsync()
{
return await _groupRepository.CountAsync();

View File

@@ -20,6 +20,7 @@ public class AppDbContext : DbContext
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ChannelGroup> ChannelGroups => Set<ChannelGroup>();
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupCategory> GroupCategories => Set<GroupCategory>();
public DbSet<BatchTask> BatchTasks => Set<BatchTask>();
public DbSet<Bot> Bots => Set<Bot>();
public DbSet<BotChannel> BotChannels => Set<BotChannel>();
@@ -44,11 +45,8 @@ public class AppDbContext : DbContext
entity.Property(e => e.TelegramStatusDetails).HasMaxLength(2000);
entity.HasIndex(e => e.Phone).IsUnique();
// 注意UserId 在未登录/未验证的 session 导入场景可能为 0SQLite 的 UNIQUE 约束会导致多账号导入失败。
// 这里保留索引但不做唯一约束,真正的去重以 Phone 为准。
entity.HasIndex(e => e.UserId);
// 与AccountCategory的关系
entity.HasOne(e => e.Category)
.WithMany(c => c.Accounts)
.HasForeignKey(e => e.CategoryId)
@@ -78,13 +76,11 @@ public class AppDbContext : DbContext
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
// 与Account的关系创建者
entity.HasOne(e => e.CreatorAccount)
.WithMany(a => a.Channels)
.HasForeignKey(e => e.CreatorAccountId)
.OnDelete(DeleteBehavior.SetNull);
// 与ChannelGroup的关系
entity.HasOne(e => e.Group)
.WithMany(g => g.Channels)
.HasForeignKey(e => e.GroupId)
@@ -95,7 +91,6 @@ public class AppDbContext : DbContext
modelBuilder.Entity<AccountChannel>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.AccountId, e.ChannelId }).IsUnique();
entity.HasIndex(e => e.ChannelId);
@@ -130,19 +125,23 @@ public class AppDbContext : DbContext
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
entity.HasIndex(e => e.CategoryId);
// 与Account的关系创建者
entity.HasOne(e => e.CreatorAccount)
.WithMany(a => a.Groups)
.HasForeignKey(e => e.CreatorAccountId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Category)
.WithMany(c => c.Groups)
.HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
});
// AccountGroup配置
modelBuilder.Entity<AccountGroup>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.AccountId, e.GroupId }).IsUnique();
entity.HasIndex(e => e.GroupId);
@@ -157,6 +156,16 @@ public class AppDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// GroupCategory配置
modelBuilder.Entity<GroupCategory>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.Property(e => e.Description).HasMaxLength(500);
entity.HasIndex(e => e.Name).IsUnique();
});
// BatchTask配置
modelBuilder.Entity<BatchTask>(entity =>
{

View File

@@ -13,10 +13,12 @@ public class Group
public int MemberCount { get; set; }
public string? About { get; set; }
public int? CreatorAccountId { get; set; }
public int? CategoryId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public Account? CreatorAccount { get; set; }
public GroupCategory? Category { get; set; }
public ICollection<AccountGroup> AccountGroups { get; set; } = new List<AccountGroup>();
}

View File

@@ -0,0 +1,15 @@
namespace TelegramPanel.Data.Entities;
/// <summary>
/// 群组分类实体
/// </summary>
public class GroupCategory
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public ICollection<Group> Groups { get; set; } = new List<Group>();
}

View File

@@ -0,0 +1,731 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TelegramPanel.Data;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260308172014_AddGroupCategories")]
partial class AddGroupCategories
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("AccountCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("AccountId", "ChannelId")
.IsUnique();
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("AccountId", "GroupId")
.IsUnique();
b.ToTable("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Bots");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("BotChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("BotChannelCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BotChannelId");
b.HasIndex("BotId");
b.HasIndex("BotId", "BotChannelId")
.IsUnique();
b.ToTable("BotChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("GroupId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorAccountId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
.WithMany("Accounts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountChannels")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
.WithMany("AccountChannels")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountGroups")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
.WithMany("AccountGroups")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
.WithMany("Channels")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
.WithMany("Members")
.HasForeignKey("BotChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
.WithMany("ChannelMembers")
.HasForeignKey("BotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bot");
b.Navigation("BotChannel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Channels")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
.WithMany("Channels")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
.WithMany("Groups")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Navigation("AccountChannels");
b.Navigation("AccountGroups");
b.Navigation("Channels");
b.Navigation("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Navigation("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Navigation("ChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Navigation("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Navigation("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Navigation("Groups");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddGroupCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "Groups",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "GroupCategories",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GroupCategories", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Groups_CategoryId",
table: "Groups",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_GroupCategories_Name",
table: "GroupCategories",
column: "Name",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Groups_GroupCategories_CategoryId",
table: "Groups",
column: "CategoryId",
principalTable: "GroupCategories",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Groups_GroupCategories_CategoryId",
table: "Groups");
migrationBuilder.DropTable(
name: "GroupCategories");
migrationBuilder.DropIndex(
name: "IX_Groups_CategoryId",
table: "Groups");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "Groups");
}
}
}

View File

@@ -494,6 +494,9 @@ namespace TelegramPanel.Data.Migrations
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
@@ -520,6 +523,8 @@ namespace TelegramPanel.Data.Migrations
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorAccountId");
b.HasIndex("TelegramId")
@@ -530,6 +535,32 @@ namespace TelegramPanel.Data.Migrations
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
@@ -626,11 +657,18 @@ namespace TelegramPanel.Data.Migrations
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
.WithMany("Groups")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("CreatorAccount");
});
@@ -679,6 +717,11 @@ namespace TelegramPanel.Data.Migrations
{
b.Navigation("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Navigation("Groups");
});
#pragma warning restore 612, 618
}
}

View File

@@ -12,7 +12,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
{
}
private IQueryable<Channel> BuildForViewQuery(int accountId, string? filterType, string? membershipRole, string? search)
private IQueryable<Channel> BuildForViewQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search)
{
var query = _dbSet
.AsNoTracking()
@@ -22,28 +22,37 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.AsSplitQuery()
.AsQueryable();
query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any());
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
if (accountId > 0)
{
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId));
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
if (membershipRole == "creator")
{
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId && x.IsCreator));
}
else if (membershipRole == "admin")
{
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId && x.IsAdmin && !x.IsCreator));
}
else if (membershipRole == "member")
{
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId && !x.IsAdmin));
}
}
else
else if (membershipRole == "creator")
{
query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any());
query = query.Where(c => c.AccountChannels.Any(x => x.IsCreator));
}
else if (membershipRole == "admin")
{
query = query.Where(c => c.AccountChannels.Any(x => x.IsAdmin && !x.IsCreator));
}
else if (membershipRole == "member")
{
query = query.Where(c => c.AccountChannels.Any(x => !x.IsAdmin));
}
if (groupId.HasValue && groupId.Value > 0)
query = query.Where(c => c.GroupId == groupId.Value);
else if (groupId.HasValue && groupId.Value == 0)
query = query.Where(c => c.GroupId == null);
filterType = (filterType ?? "all").Trim().ToLowerInvariant();
if (filterType == "public")
@@ -57,7 +66,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
var like = $"%{search}%";
query = query.Where(c =>
EF.Functions.Like(c.Title, like)
|| (c.Username != null && EF.Functions.Like(c.Username, like)));
|| (c.Username != null && EF.Functions.Like(c.Username, like))
|| (c.Group != null && EF.Functions.Like(c.Group.Name, like)));
}
return query.OrderByDescending(c => c.SyncedAt);
@@ -68,6 +78,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == id);
}
@@ -76,6 +88,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -85,6 +99,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.TelegramId == telegramId);
}
@@ -93,6 +109,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.CreatorAccountId != null)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
@@ -103,6 +121,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.CreatorAccountId == accountId)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
@@ -116,6 +136,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => links.Any(x => x.ChannelId == c.Id))
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
@@ -126,6 +148,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.GroupId == groupId)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
@@ -136,6 +160,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
@@ -143,6 +169,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
public async Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? groupId,
string? filterType,
string? membershipRole,
string? search,
@@ -154,7 +181,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var query = BuildForViewQuery(accountId, filterType, membershipRole, search);
var query = BuildForViewQuery(accountId, groupId, filterType, membershipRole, search);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip(pageIndex * pageSize)

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 群组分类仓储实现
/// </summary>
public class GroupCategoryRepository : Repository<GroupCategory>, IGroupCategoryRepository
{
public GroupCategoryRepository(AppDbContext context) : base(context)
{
}
public override async Task<IEnumerable<GroupCategory>> GetAllAsync()
{
return await _dbSet
.Include(g => g.Groups)
.OrderBy(g => g.Name)
.ToListAsync();
}
public async Task<GroupCategory?> GetByNameAsync(string name)
{
return await _dbSet
.Include(g => g.Groups)
.FirstOrDefaultAsync(g => g.Name == name);
}
}

View File

@@ -12,37 +12,47 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
}
private IQueryable<Group> BuildForViewQuery(int accountId, string? filterType, string? membershipRole, string? search)
private IQueryable<Group> BuildForViewQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search)
{
var query = _dbSet
.AsNoTracking()
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.AsQueryable();
query = query.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any());
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
if (accountId > 0)
{
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId));
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
if (membershipRole == "creator")
{
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId && x.IsCreator));
}
else if (membershipRole == "admin")
{
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId && x.IsAdmin && !x.IsCreator));
}
else if (membershipRole == "member")
{
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId && !x.IsAdmin));
}
}
else
else if (membershipRole == "creator")
{
query = query.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any());
query = query.Where(g => g.AccountGroups.Any(x => x.IsCreator));
}
else if (membershipRole == "admin")
{
query = query.Where(g => g.AccountGroups.Any(x => x.IsAdmin && !x.IsCreator));
}
else if (membershipRole == "member")
{
query = query.Where(g => g.AccountGroups.Any(x => !x.IsAdmin));
}
if (categoryId.HasValue && categoryId.Value > 0)
query = query.Where(g => g.CategoryId == categoryId.Value);
else if (categoryId.HasValue && categoryId.Value == 0)
query = query.Where(g => g.CategoryId == null);
filterType = (filterType ?? "all").Trim().ToLowerInvariant();
if (filterType == "public")
@@ -56,7 +66,8 @@ public class GroupRepository : Repository<Group>, IGroupRepository
var like = $"%{search}%";
query = query.Where(g =>
EF.Functions.Like(g.Title, like)
|| (g.Username != null && EF.Functions.Like(g.Username, like)));
|| (g.Username != null && EF.Functions.Like(g.Username, like))
|| (g.Category != null && EF.Functions.Like(g.Category.Name, like)));
}
return query.OrderByDescending(g => g.SyncedAt);
@@ -66,6 +77,7 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.FirstOrDefaultAsync(g => g.Id == id);
@@ -75,6 +87,7 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any())
@@ -86,6 +99,7 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.FirstOrDefaultAsync(g => g.TelegramId == telegramId);
@@ -95,6 +109,7 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.Where(g => g.CreatorAccountId == accountId)
@@ -104,6 +119,7 @@ public class GroupRepository : Repository<Group>, IGroupRepository
public async Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? categoryId,
string? filterType,
string? membershipRole,
string? search,
@@ -115,7 +131,7 @@ public class GroupRepository : Repository<Group>, IGroupRepository
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var query = BuildForViewQuery(accountId, filterType, membershipRole, search);
var query = BuildForViewQuery(accountId, categoryId, filterType, membershipRole, search);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip(pageIndex * pageSize)

View File

@@ -16,6 +16,7 @@ public interface IChannelRepository : IRepository<Channel>
Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? groupId,
string? filterType,
string? membershipRole,
string? search,

View File

@@ -0,0 +1,11 @@
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 群组分类仓储接口
/// </summary>
public interface IGroupCategoryRepository : IRepository<GroupCategory>
{
Task<GroupCategory?> GetByNameAsync(string name);
}

View File

@@ -11,6 +11,7 @@ public interface IGroupRepository : IRepository<Group>
Task<IEnumerable<Group>> GetByCreatorAccountAsync(int accountId);
Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? categoryId,
string? filterType,
string? membershipRole,
string? search,

View File

@@ -27,6 +27,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IChannelRepository, ChannelRepository>();
services.AddScoped<IChannelGroupRepository, ChannelGroupRepository>();
services.AddScoped<IGroupRepository, GroupRepository>();
services.AddScoped<IGroupCategoryRepository, GroupCategoryRepository>();
services.AddScoped<IBatchTaskRepository, BatchTaskRepository>();
services.AddScoped<IBotRepository, BotRepository>();
services.AddScoped<IBotChannelRepository, BotChannelRepository>();

View File

@@ -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>

View File

@@ -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));
}
}

View File

@@ -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">

View File

@@ -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;
}
}

View File

@@ -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
{

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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>