diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs index 00ab607..cf9d40b 100644 --- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 2383f78..783a6bb 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -37,6 +37,7 @@ public class ChannelManagementService /// public async Task<(IReadOnlyList 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> GetChannelsByCreatorAsync(int accountId) diff --git a/src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs b/src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs new file mode 100644 index 0000000..066809f --- /dev/null +++ b/src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs @@ -0,0 +1,49 @@ +using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Core.Services; + +/// +/// 群组分类管理服务 +/// +public class GroupCategoryManagementService +{ + private readonly IGroupCategoryRepository _categoryRepository; + + public GroupCategoryManagementService(IGroupCategoryRepository categoryRepository) + { + _categoryRepository = categoryRepository; + } + + public async Task> GetAllCategoriesAsync() + { + return await _categoryRepository.GetAllAsync(); + } + + public async Task GetCategoryAsync(int id) + { + return await _categoryRepository.GetByIdAsync(id); + } + + public async Task GetCategoryByNameAsync(string name) + { + return await _categoryRepository.GetByNameAsync(name); + } + + public async Task 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); + } +} diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 92c5585..53de5be 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -34,6 +34,7 @@ public class GroupManagementService public async Task<(IReadOnlyList 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> 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 GetTotalGroupCountAsync() { return await _groupRepository.CountAsync(); diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index a867f8f..56169a7 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -20,6 +20,7 @@ public class AppDbContext : DbContext public DbSet Channels => Set(); public DbSet ChannelGroups => Set(); public DbSet Groups => Set(); + public DbSet GroupCategories => Set(); public DbSet BatchTasks => Set(); public DbSet Bots => Set(); public DbSet BotChannels => Set(); @@ -44,11 +45,8 @@ public class AppDbContext : DbContext entity.Property(e => e.TelegramStatusDetails).HasMaxLength(2000); entity.HasIndex(e => e.Phone).IsUnique(); - // 注意:UserId 在未登录/未验证的 session 导入场景可能为 0,SQLite 的 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(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(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(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(entity => { diff --git a/src/TelegramPanel.Data/Entities/Group.cs b/src/TelegramPanel.Data/Entities/Group.cs index 27e73ba..e34eb8e 100644 --- a/src/TelegramPanel.Data/Entities/Group.cs +++ b/src/TelegramPanel.Data/Entities/Group.cs @@ -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 AccountGroups { get; set; } = new List(); } diff --git a/src/TelegramPanel.Data/Entities/GroupCategory.cs b/src/TelegramPanel.Data/Entities/GroupCategory.cs new file mode 100644 index 0000000..fd983bb --- /dev/null +++ b/src/TelegramPanel.Data/Entities/GroupCategory.cs @@ -0,0 +1,15 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 群组分类实体 +/// +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 Groups { get; set; } = new List(); +} diff --git a/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs b/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs new file mode 100644 index 0000000..dab3837 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs @@ -0,0 +1,731 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApiId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SessionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramStatusDetails") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusOk") + .HasColumnType("INTEGER"); + + b.Property("TelegramStatusSummary") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TwoFactorPassword") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("BatchTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("LastUpdateId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BotChannelId") + .HasColumnType("INTEGER"); + + b.Property("BotId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.cs b/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.cs new file mode 100644 index 0000000..5679818 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddGroupCategories : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CategoryId", + table: "Groups", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "GroupCategories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index e8c6d6c..90970e3 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -494,6 +494,9 @@ namespace TelegramPanel.Data.Migrations b.Property("AccessHash") .HasColumnType("INTEGER"); + b.Property("CategoryId") + .HasColumnType("INTEGER"); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("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 } } diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index d935715..51023eb 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -12,7 +12,7 @@ public class ChannelRepository : Repository, IChannelRepository { } - private IQueryable BuildForViewQuery(int accountId, string? filterType, string? membershipRole, string? search) + private IQueryable BuildForViewQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search) { var query = _dbSet .AsNoTracking() @@ -22,28 +22,37 @@ public class ChannelRepository : Repository, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, IChannelRepository public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, + int? groupId, string? filterType, string? membershipRole, string? search, @@ -154,7 +181,7 @@ public class ChannelRepository : Repository, 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) diff --git a/src/TelegramPanel.Data/Repositories/GroupCategoryRepository.cs b/src/TelegramPanel.Data/Repositories/GroupCategoryRepository.cs new file mode 100644 index 0000000..de5fb37 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/GroupCategoryRepository.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 群组分类仓储实现 +/// +public class GroupCategoryRepository : Repository, IGroupCategoryRepository +{ + public GroupCategoryRepository(AppDbContext context) : base(context) + { + } + + public override async Task> GetAllAsync() + { + return await _dbSet + .Include(g => g.Groups) + .OrderBy(g => g.Name) + .ToListAsync(); + } + + public async Task GetByNameAsync(string name) + { + return await _dbSet + .Include(g => g.Groups) + .FirstOrDefaultAsync(g => g.Name == name); + } +} diff --git a/src/TelegramPanel.Data/Repositories/GroupRepository.cs b/src/TelegramPanel.Data/Repositories/GroupRepository.cs index 096c224..45a4606 100644 --- a/src/TelegramPanel.Data/Repositories/GroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/GroupRepository.cs @@ -12,37 +12,47 @@ public class GroupRepository : Repository, IGroupRepository { } - private IQueryable BuildForViewQuery(int accountId, string? filterType, string? membershipRole, string? search) + private IQueryable 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, 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, 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, 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, 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, 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, IGroupRepository public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, + int? categoryId, string? filterType, string? membershipRole, string? search, @@ -115,7 +131,7 @@ public class GroupRepository : Repository, 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) diff --git a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs index 113bba8..5b79c73 100644 --- a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs @@ -16,6 +16,7 @@ public interface IChannelRepository : IRepository Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, + int? groupId, string? filterType, string? membershipRole, string? search, diff --git a/src/TelegramPanel.Data/Repositories/IGroupCategoryRepository.cs b/src/TelegramPanel.Data/Repositories/IGroupCategoryRepository.cs new file mode 100644 index 0000000..6870ee3 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IGroupCategoryRepository.cs @@ -0,0 +1,11 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 群组分类仓储接口 +/// +public interface IGroupCategoryRepository : IRepository +{ + Task GetByNameAsync(string name); +} diff --git a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs index 9e6d521..e5f60f0 100644 --- a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs @@ -11,6 +11,7 @@ public interface IGroupRepository : IRepository Task> GetByCreatorAccountAsync(int accountId); Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, + int? categoryId, string? filterType, string? membershipRole, string? search, diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index 3c55a95..74c112e 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor index 42a086f..95a9901 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchSetChannelGroupDialog.razor @@ -5,11 +5,11 @@ - 将为 @SelectedCount 个频道设置分组 + 将为 @SelectedCount 个频道设置分类 - - 未分组 + + 未分类 @foreach (var g in Groups) { @g.Name diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchSetGroupCategoryDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchSetGroupCategoryDialog.razor new file mode 100644 index 0000000..d96d57a --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchSetGroupCategoryDialog.razor @@ -0,0 +1,40 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@using TelegramPanel.Data.Entities + + + + + + 将为 @SelectedCount 个群组设置分类 + + + + 未分类 + @foreach (var category in Categories) + { + @category.Name + } + + + + + 取消 + 确定 + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public List 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)); + } +} diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index 5829eac..0cd9be2 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -15,11 +15,12 @@ 频道列表 创建频道 - 频道分组 + 频道分类 群组列表 + 群组分类 diff --git a/src/TelegramPanel.Web/Components/Pages/ChannelGroups.razor b/src/TelegramPanel.Web/Components/Pages/ChannelGroups.razor index 6ba011e..f3e084c 100644 --- a/src/TelegramPanel.Web/Components/Pages/ChannelGroups.razor +++ b/src/TelegramPanel.Web/Components/Pages/ChannelGroups.razor @@ -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 -频道分组 - Telegram Panel +频道分类 - Telegram Panel -频道分组管理 +频道分类管理 - 添加分组 + @(editingGroupId > 0 ? "编辑分类" : "添加分类") - + - - 添加分组 + @if (editingGroupId > 0) + { + 取消 + } + + @(editingGroupId > 0 ? "保存修改" : "添加分类") @@ -31,27 +36,31 @@ - 分组列表 + 分类列表 - 分组名称 + 分类名称 描述 频道数量 操作 - @context.Name + @context.Name @(context.Description ?? "-") @context.Channels.Count - + + + + - 暂无分组,请添加分组 + 暂无分类,请添加分类 @@ -59,32 +68,43 @@ - 分组绑定频道 + 分类绑定频道 - - -- 请选择分组 -- - @foreach (var g in groups) - { - @g.Name - } - + + + -- 请选择分类 -- + @foreach (var g in groups) + { + @g.Name + } + - + 全部账号 + @foreach (var account in accounts) + { + @FormatAccountLabel(account) + } + + + + 频道名称 用户名 创建账号 - 当前分组 + 当前分类 @context.Title @(string.IsNullOrWhiteSpace(context.Username) ? "-" : context.Username) @(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)")) - @(context.Group?.Name ?? "未分组") + @(context.Group?.Name ?? "未分类") 暂无频道数据 @@ -95,7 +115,7 @@ - 保存勾选到分组 + 保存勾选到分类 @@ -105,20 +125,41 @@ @code { private string newGroupName = ""; private string newGroupDesc = ""; + private int editingGroupId = 0; private List groups = new(); private bool loading = true; private List channels = new(); + private List accounts = new(); private bool channelsLoading = true; private int assignGroupId = 0; + private int filterAccountId = 0; private HashSet selectedChannels = new(); + private IEnumerable 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() - : 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; + } } diff --git a/src/TelegramPanel.Web/Components/Pages/Channels.razor b/src/TelegramPanel.Web/Components/Pages/Channels.razor index f729ce0..320395f 100644 --- a/src/TelegramPanel.Web/Components/Pages/Channels.razor +++ b/src/TelegramPanel.Web/Components/Pages/Channels.razor @@ -32,13 +32,21 @@ 私密 全部角色 - 我创建 - 我管理 + 创建者 + 管理员 非管理员 + + 全部分类 + 未分类 + @foreach (var group in groups) + { + @group.Name + } + 批量复制邀请链接(已选) 批量导出邀请链接(已选) 批量设置管理员(已选) - 批量修改分组(已选) + 批量修改分类(已选) @if (selectedChannels.Count > 0) @@ -89,6 +97,7 @@ 频道名称 用户名 类型 + 频道分类 成员数 @if (filterAccount > 0) { @@ -126,6 +135,11 @@ @(string.IsNullOrEmpty(context.Username) ? "私密" : "公开") + + + @(context.Group?.Name ?? "未分类") + + @context.MemberCount @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 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("批量修改分组", parameters, options); + var dialog = DialogService.Show("批量修改分类", 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 { diff --git a/src/TelegramPanel.Web/Components/Pages/GroupCategories.razor b/src/TelegramPanel.Web/Components/Pages/GroupCategories.razor new file mode 100644 index 0000000..47b5024 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/GroupCategories.razor @@ -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 + +群组分类 - Telegram Panel + +群组分类管理 + + + + + + @(editingCategoryId > 0 ? "编辑分类" : "添加分类") + + + + + + + @if (editingCategoryId > 0) + { + 取消 + } + + @(editingCategoryId > 0 ? "保存修改" : "添加分类") + + + + + + + + + 分类列表 + + + + + 分类名称 + 描述 + 群组数量 + 操作 + + + @context.Name + @(context.Description ?? "-") + @context.Groups.Count + + + + + + + + + 暂无分类,请添加分类 + + + + + + + + 分类绑定群组 + + + + + -- 请选择分类 -- + @foreach (var category in categories) + { + @category.Name + } + + + + 全部账号 + @foreach (var account in accounts) + { + @FormatAccountLabel(account) + } + + + + + + 群组名称 + 用户名 + 创建账号 + 当前分类 + + + @context.Title + @(string.IsNullOrWhiteSpace(context.Username) ? "-" : context.Username) + @(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)")) + @(context.Category?.Name ?? "未分类") + + + 暂无群组数据 + + + + + + 保存勾选到分类 + + + + + + +@code { + private string newCategoryName = string.Empty; + private string newCategoryDesc = string.Empty; + private int editingCategoryId; + private List categories = new(); + private bool categoriesLoading = true; + + private List groups = new(); + private List accounts = new(); + private bool groupsLoading = true; + private int assignCategoryId; + private int filterAccountId; + private HashSet selectedGroups = new(); + + private IEnumerable 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() + : 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; + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/Groups.razor b/src/TelegramPanel.Web/Components/Pages/Groups.razor index 1ff2160..9ca5bf3 100644 --- a/src/TelegramPanel.Web/Components/Pages/Groups.razor +++ b/src/TelegramPanel.Web/Components/Pages/Groups.razor @@ -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 @@ 私密 全部角色 我创建 我管理 非管理员 + + 全部分类 + 未分类 + @foreach (var category in categories) + { + @category.Name + } + 批量复制加入链接(已选) 批量导出加入链接(已选) + 批量修改分类(已选) 批量删除(已选) @@ -87,6 +97,7 @@ 群组名称 用户名 类型 + 群组分类 成员数 @if (filterAccount > 0) { @@ -124,6 +135,11 @@ @(string.IsNullOrWhiteSpace(context.Username) ? "私密" : "公开") + + + @(context.Category?.Name ?? "未分类") + + @context.MemberCount @if (filterAccount > 0) { @@ -177,10 +193,12 @@ private MudTable? _table; private List groups = new(); private List accounts = new(); + private List 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 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> 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("批量修改分类", 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) diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index f129238..26ab650 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -158,10 +158,20 @@ } - - 保存同步设置 - + + + 保存同步设置 + + + + @(syncing ? "同步中..." : "立即同步频道/群组") + + @@ -180,10 +190,6 @@ 保存 Bot 自动同步设置 - - 立即同步 -