From 0fead785a9915b4cb00dcea475cf3f67380f4300 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Sun, 8 Mar 2026 21:20:23 +0800 Subject: [PATCH 1/6] fix(tasks): improve user active task controls --- .../Dialogs/UserChatActiveTaskEditor.razor | 60 ++++++--- .../Components/Pages/Tasks.razor | 125 ++++++++++++++++-- .../Services/UserChatActiveTaskHandler.cs | 38 +++++- .../Services/UserChatActiveTaskModels.cs | 6 + 4 files changed, 202 insertions(+), 27 deletions(-) diff --git a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor index a690aab..9e7d4aa 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor @@ -1,4 +1,3 @@ -@namespace TelegramPanel.Web.Components.Dialogs @inject AccountCategoryManagementService CategoryManagement @using System.Text.Json @using TelegramPanel.Data.Entities @@ -18,8 +17,14 @@ } else { - + @foreach (var c in categories) { @c.Name @@ -91,7 +96,7 @@ private bool loading = true; private bool initializedFromConfig; - private int selectedCategoryId; + private IEnumerable selectedCategoryIds = Array.Empty(); private string targetsText = ""; private string dictionaryText = ""; private int delayMinMs = 15; @@ -113,8 +118,11 @@ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToList(); - if (selectedCategoryId <= 0) - selectedCategoryId = categories.FirstOrDefault()?.Id ?? 0; + if (!selectedCategoryIds.Any()) + { + var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; + selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); + } } finally { @@ -140,8 +148,9 @@ if (cfg == null) return; - if (cfg.CategoryId > 0) - selectedCategoryId = cfg.CategoryId; + var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); + if (categoryIds.Count > 0) + selectedCategoryIds = categoryIds; targetsText = string.Join(Environment.NewLine, cfg.Targets ?? new List()); dictionaryText = string.Join(Environment.NewLine, cfg.Dictionary ?? new List()); @@ -158,6 +167,12 @@ } } + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = NormalizeCategoryIds(values, 0); + await OnInputsChanged(); + } + private async Task OnInputsChanged() { await PushDraftAsync(); @@ -167,6 +182,8 @@ { var targets = ParseTargetLines(targetsText); var dictionary = ParseMessageLines(dictionaryText); + var selectedIds = NormalizeCategoryIds(selectedCategoryIds, 0); + var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList(); if (loading) { @@ -180,9 +197,9 @@ return; } - if (selectedCategoryId <= 0 || categories.All(x => x.Id != selectedCategoryId)) + if (selectedCategories.Count == 0) { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择执行账号分类")); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类")); return; } @@ -222,11 +239,13 @@ return; } - var category = categories.First(x => x.Id == selectedCategoryId); + var categoryNames = selectedCategories.Select(x => x.Name).ToList(); var cfg = new UserChatActiveTaskConfig { - CategoryId = selectedCategoryId, - CategoryName = category.Name, + CategoryId = selectedCategories[0].Id, + CategoryName = selectedCategories[0].Name, + CategoryIds = selectedCategories.Select(x => x.Id).ToList(), + CategoryNames = categoryNames, Targets = targets, Dictionary = dictionary, DelayMinMs = delayMinMs * 1000, @@ -242,6 +261,19 @@ await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); } + private static List NormalizeCategoryIds(IEnumerable? values, int fallback) + { + var ids = (values ?? Array.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + private static int ConvertMillisecondsToSeconds(int value) { if (value <= 0) @@ -288,5 +320,3 @@ .ToList(); } } - - diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index 8150326..efc9249 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -110,6 +110,8 @@ } + @@ -175,7 +177,7 @@ OnClick="@(() => RerunTask(context))" /> } + OnClick="@(() => DeleteTask(context))" /> @@ -538,8 +540,7 @@ using var doc = JsonDocument.Parse(task.Config); var root = doc.RootElement; - var categoryName = root.TryGetProperty("category_name", out var categoryNameEl) ? (categoryNameEl.GetString() ?? "").Trim() : ""; - var categoryId = root.TryGetProperty("category_id", out var categoryIdEl) && categoryIdEl.TryGetInt32(out var cid) ? cid : 0; + var categoryText = BuildSelectedCategorySummary(root); var maxMessages = root.TryGetProperty("max_messages", out var maxMessagesEl) && maxMessagesEl.TryGetInt32(out var mm) ? mm : 0; var accountMode = root.TryGetProperty("account_mode", out var accountModeEl) ? (accountModeEl.GetString() ?? "").Trim() : ""; var targetMode = root.TryGetProperty("target_mode", out var targetModeEl) ? (targetModeEl.GetString() ?? "").Trim() : ""; @@ -573,7 +574,6 @@ } } - var categoryText = categoryName.Length > 0 ? $"{categoryName} (#{categoryId})" : $"#{categoryId}"; details += "\n配置摘要:\n"; details += $"分类: {categoryText}\n"; details += $"目标数: {targetCount}\n"; @@ -607,6 +607,72 @@ return seconds.ToString("0.###"); } + private static string BuildSelectedCategorySummary(JsonElement root) + { + var ids = ExtractSelectedCategoryIds(root); + var names = ExtractSelectedCategoryNames(root); + var count = Math.Max(ids.Count, names.Count); + if (count == 0) + return "-"; + + var parts = new List(); + for (var i = 0; i < count; i++) + { + var id = i < ids.Count ? ids[i] : 0; + var name = i < names.Count ? names[i] : string.Empty; + + if (id > 0 && name.Length > 0) + parts.Add($"{name} (#{id})"); + else if (id > 0) + parts.Add($"#{id}"); + else if (name.Length > 0) + parts.Add(name); + } + + return parts.Count == 0 ? "-" : string.Join(", ", parts); + } + + private static List ExtractSelectedCategoryIds(JsonElement root) + { + var ids = new List(); + if (root.TryGetProperty("category_ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array) + { + foreach (var item in idsEl.EnumerateArray()) + { + if (item.TryGetInt32(out var id) && id > 0) + ids.Add(id); + } + } + + if (ids.Count == 0 && root.TryGetProperty("category_id", out var categoryIdEl) && categoryIdEl.TryGetInt32(out var categoryId) && categoryId > 0) + ids.Add(categoryId); + + return ids.Distinct().ToList(); + } + + private static List ExtractSelectedCategoryNames(JsonElement root) + { + var names = new List(); + if (root.TryGetProperty("category_names", out var namesEl) && namesEl.ValueKind == JsonValueKind.Array) + { + foreach (var item in namesEl.EnumerateArray()) + { + var name = (item.GetString() ?? string.Empty).Trim(); + if (name.Length > 0) + names.Add(name); + } + } + + if (names.Count == 0 && root.TryGetProperty("category_name", out var categoryNameEl)) + { + var fallbackName = (categoryNameEl.GetString() ?? string.Empty).Trim(); + if (fallbackName.Length > 0) + names.Add(fallbackName); + } + + return names.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + private static string NormalizeTaskModeDisplay(string? mode) { var text = (mode ?? "").Trim(); @@ -688,20 +754,26 @@ } } - private async Task DeleteTask(int id) + private async Task DeleteTask(BatchTask task) { + var status = GetDisplayStatus(task); + var isActive = IsActiveStatus(status); + var message = isActive + ? $"确定要删除任务 #{task.Id} 吗?删除后将停止后台执行并移除记录,此操作不可恢复。" + : $"确定要删除任务 #{task.Id} 的记录吗?此操作不可恢复。"; + bool? result = await DialogService.ShowMessageBox( "确认删除", - "确定要删除这个任务记录吗?此操作不可恢复。", + message, yesText: "删除", cancelText: "取消"); if (result == true) { try { - await TaskManagement.DeleteTaskAsync(id); - tasks.RemoveAll(t => t.Id == id); - Snackbar.Add("任务已删除", Severity.Success); + await TaskManagement.DeleteTaskAsync(task.Id); + await LoadTasks(); + Snackbar.Add(isActive ? "任务已删除,后台执行将在下一次状态检查时停止" : "任务已删除", Severity.Success); } catch (Exception ex) { @@ -885,9 +957,14 @@ throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); } - if (cfg.CategoryId <= 0) + cfg.CategoryIds = NormalizeCategoryIdsForRerun(cfg.CategoryIds, cfg.CategoryId); + if (cfg.CategoryIds.Count == 0) throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); + cfg.CategoryId = cfg.CategoryIds[0]; + cfg.CategoryNames = NormalizeCategoryNamesForRerun(cfg.CategoryNames, cfg.CategoryName); + cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; + cfg.Targets = (cfg.Targets ?? new List()) .Select(x => (x ?? string.Empty).Trim()) .Where(x => x.Length > 0) @@ -923,6 +1000,34 @@ return cfg; } + private static List NormalizeCategoryIdsForRerun(IEnumerable? values, int fallback) + { + var ids = (values ?? Array.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + + private static List NormalizeCategoryNamesForRerun(IEnumerable? values, string? fallback) + { + var names = (values ?? Array.Empty()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var fallbackName = (fallback ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + + return names; + } + private static string NormalizeModeValue(string? mode) { return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase) diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs index bfd1db3..53ee9fd 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -25,9 +25,10 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var config = DeserializeConfig(host.Config); ValidateAndNormalizeConfig(config); + var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet(); var allAccounts = (await accountManagement.GetAllAccountsAsync()) .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) - .Where(x => x.CategoryId == config.CategoryId) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) .OrderBy(x => x.Id) .ToList(); @@ -196,9 +197,14 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler private static void ValidateAndNormalizeConfig(UserChatActiveTaskConfig config) { - if (config.CategoryId <= 0) + config.CategoryIds = NormalizeSelectedCategoryIds(config); + if (config.CategoryIds.Count == 0) throw new InvalidOperationException("任务缺少账号分类"); + config.CategoryId = config.CategoryIds[0]; + config.CategoryNames = NormalizeSelectedCategoryNames(config); + config.CategoryName = config.CategoryNames.FirstOrDefault() ?? config.CategoryName; + config.Targets = config.Targets .Select(x => (x ?? string.Empty).Trim()) .Where(x => x.Length > 0) @@ -231,6 +237,34 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler config.RecentFailures ??= new List(); } + private static List NormalizeSelectedCategoryIds(UserChatActiveTaskConfig config) + { + var ids = (config.CategoryIds ?? new List()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && config.CategoryId > 0) + ids.Add(config.CategoryId); + + return ids; + } + + private static List NormalizeSelectedCategoryNames(UserChatActiveTaskConfig config) + { + var names = (config.CategoryNames ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var fallbackName = (config.CategoryName ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + + return names; + } + private static string NormalizeMode(string? mode) { return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase) diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs index c8f5b46..7b7744a 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs @@ -16,6 +16,12 @@ public sealed class UserChatActiveTaskConfig [JsonPropertyName("category_name")] public string? CategoryName { get; set; } + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + [JsonPropertyName("targets")] public List Targets { get; set; } = new(); From da4479762643903fc8b55c77d4dbf635e158e7a7 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 9 Mar 2026 00:19:45 +0800 Subject: [PATCH 2/6] feat: support multi-account groups and tracked sync tasks --- Directory.Build.props | 8 +- .../BatchTasks/BatchTaskTypes.cs | 3 + .../Interfaces/IChannelService.cs | 5 + .../Interfaces/IGroupService.cs | 5 + src/TelegramPanel.Core/Models/GroupInfo.cs | 6 +- .../Services/ChannelManagementService.cs | 15 +- .../Services/GroupManagementService.cs | 73 +- .../Services/Telegram/ChannelService.cs | 59 ++ .../Services/Telegram/GroupService.cs | 268 +++++-- src/TelegramPanel.Data/AppDbContext.cs | 20 + src/TelegramPanel.Data/Entities/Account.cs | 14 + .../Entities/AccountGroup.cs | 27 + src/TelegramPanel.Data/Entities/Group.cs | 5 +- ...0260308155832_AddAccountGroups.Designer.cs | 688 ++++++++++++++++++ .../20260308155832_AddAccountGroups.cs | 125 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 64 +- .../Repositories/AccountGroupRepository.cs | 61 ++ .../Repositories/AccountRepository.cs | 61 ++ .../Repositories/ChannelRepository.cs | 34 +- .../Repositories/GroupRepository.cs | 82 +++ .../Repositories/IAccountGroupRepository.cs | 14 + .../Repositories/IChannelRepository.cs | 3 +- .../Repositories/IGroupRepository.cs | 8 + .../ServiceCollectionExtensions.cs | 1 + .../Dialogs/GroupDetailsDialog.razor | 35 +- .../Components/Pages/Accounts.razor | 2 +- .../Components/Pages/Channels.razor | 79 +- .../Components/Pages/Groups.razor | 525 +++++++++++-- .../Components/Pages/Settings.razor | 13 +- .../Components/Pages/Tasks.razor | 8 +- src/TelegramPanel.Web/Program.cs | 72 ++ .../AccountDataAutoSyncBackgroundService.cs | 20 +- .../Services/DataSyncService.cs | 254 ++++++- 33 files changed, 2482 insertions(+), 175 deletions(-) create mode 100644 src/TelegramPanel.Data/Entities/AccountGroup.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs create mode 100644 src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs diff --git a/Directory.Build.props b/Directory.Build.props index 7b821de..1902e39 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.31.4 - 1.31.4.0 - 1.31.4.0 - 1.31.4 + 1.31.5 + 1.31.5.0 + 1.31.5.0 + 1.31.5 Telegram Panel Contributors diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs index 14cd765..ec2d94d 100644 --- a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs +++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs @@ -15,6 +15,9 @@ public static class BatchTaskTypes public const string UserJoinSubscribe = "user_join_subscribe"; public const string UserChatActive = "user_chat_active"; + // System 任务(记录到任务中心) + public const string AccountAutoSync = "account_auto_sync"; + // External API(记录到任务中心) public const string ExternalApiKick = "external_api_kick"; } diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs index f2013ae..479d2c6 100644 --- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs +++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs @@ -17,6 +17,11 @@ public interface IChannelService /// Task> GetAdminedChannelsAsync(int accountId); + /// + /// 获取账号当前可见的全部频道(创建者/管理员/普通成员) + /// + Task> GetVisibleChannelsAsync(int accountId); + /// /// 创建新频道 /// diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs index 9495c83..d952ce5 100644 --- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs +++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs @@ -12,6 +12,11 @@ public interface IGroupService /// Task> GetOwnedGroupsAsync(int accountId); + /// + /// 获取账号当前可见的全部群组(创建者/管理员/普通成员) + /// + Task> GetVisibleGroupsAsync(int accountId); + /// /// 获取群组详情 /// diff --git a/src/TelegramPanel.Core/Models/GroupInfo.cs b/src/TelegramPanel.Core/Models/GroupInfo.cs index 70008ab..c50f6b6 100644 --- a/src/TelegramPanel.Core/Models/GroupInfo.cs +++ b/src/TelegramPanel.Core/Models/GroupInfo.cs @@ -11,7 +11,11 @@ public record GroupInfo public string Title { get; init; } = string.Empty; public string? Username { get; init; } public int MemberCount { get; init; } - public int CreatorAccountId { get; init; } + public string? About { get; init; } + public int? CreatorAccountId { get; init; } + public bool IsCreator { get; init; } + public bool IsAdmin { get; init; } + public DateTime? CreatedAt { get; init; } public DateTime SyncedAt { get; init; } public bool IsPublic => !string.IsNullOrEmpty(Username); diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 4741f47..2383f78 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -33,25 +33,18 @@ public class ChannelManagementService } /// - /// 用于列表展示:可选按账号筛选(仅展示“本系统创建/创建者为该账号”的频道) + /// 用于列表展示:支持按账号查看该账号可见的全部频道,并按角色筛选。 /// - public async Task> GetChannelsForViewAsync(int accountId, bool includeNonCreator) - { - if (accountId <= 0) - return await _channelRepository.GetCreatedAsync(); - - return await _channelRepository.GetByCreatorAccountAsync(accountId); - } - public async Task<(IReadOnlyList Items, int TotalCount)> QueryChannelsForViewPagedAsync( - int creatorAccountId, + int accountId, string? filterType, + string? membershipRole, string? search, int pageIndex, int pageSize, CancellationToken cancellationToken = default) { - return await _channelRepository.QueryForViewPagedAsync(creatorAccountId, filterType, search, pageIndex, pageSize, cancellationToken); + return await _channelRepository.QueryForViewPagedAsync(accountId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken); } public async Task> GetChannelsByCreatorAsync(int accountId) diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 6ef8a44..92c5585 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -9,10 +9,12 @@ namespace TelegramPanel.Core.Services; public class GroupManagementService { private readonly IGroupRepository _groupRepository; + private readonly IAccountGroupRepository _accountGroupRepository; - public GroupManagementService(IGroupRepository groupRepository) + public GroupManagementService(IGroupRepository groupRepository, IAccountGroupRepository accountGroupRepository) { _groupRepository = groupRepository; + _accountGroupRepository = accountGroupRepository; } public async Task GetGroupAsync(int id) @@ -30,6 +32,18 @@ public class GroupManagementService return await _groupRepository.GetAllAsync(); } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryGroupsForViewPagedAsync( + int accountId, + string? filterType, + string? membershipRole, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + return await _groupRepository.QueryForViewPagedAsync(accountId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken); + } + public async Task> GetGroupsByCreatorAsync(int accountId) { return await _groupRepository.GetByCreatorAccountAsync(accountId); @@ -37,35 +51,42 @@ public class GroupManagementService public async Task CreateOrUpdateGroupAsync(Group group) { + if (group.TelegramId <= 0) + throw new ArgumentException("TelegramId 必须为正数", nameof(group)); + var existing = await _groupRepository.GetByTelegramIdAsync(group.TelegramId); if (existing != null) { - // 更新现有群组 existing.Title = group.Title; existing.Username = group.Username; existing.MemberCount = group.MemberCount; existing.About = group.About; existing.AccessHash = group.AccessHash; + if (existing.CreatorAccountId == null && group.CreatorAccountId != null) + existing.CreatorAccountId = group.CreatorAccountId; + if (group.CreatedAt.HasValue) + existing.CreatedAt = group.CreatedAt; existing.SyncedAt = DateTime.UtcNow; await _groupRepository.UpdateAsync(existing); return existing; } - else - { - // 创建新群组 - group.SyncedAt = DateTime.UtcNow; - return await _groupRepository.AddAsync(group); - } + + group.SyncedAt = DateTime.UtcNow; + return await _groupRepository.AddAsync(group); + } + + public async Task UpdateGroupAsync(Group group) + { + group.SyncedAt = DateTime.UtcNow; + await _groupRepository.UpdateAsync(group); } public async Task DeleteGroupAsync(int id) { var group = await _groupRepository.GetByIdAsync(id); if (group != null) - { await _groupRepository.DeleteAsync(group); - } } public async Task GetTotalGroupCountAsync() @@ -77,4 +98,36 @@ public class GroupManagementService { return await _groupRepository.CountAsync(g => g.CreatorAccountId == accountId); } + + public async Task UpsertAccountGroupAsync(int accountId, int groupId, bool isCreator, bool isAdmin, DateTime syncedAtUtc) + { + await _accountGroupRepository.UpsertAsync(new AccountGroup + { + AccountId = accountId, + GroupId = groupId, + IsCreator = isCreator, + IsAdmin = isAdmin, + SyncedAt = syncedAtUtc + }); + } + + public async Task DeleteStaleAccountGroupsAsync(int accountId, IReadOnlyCollection keepGroupIds) + { + await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds); + } + + /// + /// 解析群组操作的执行账号: + /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 + /// + public async Task ResolveExecuteAccountIdAsync(Group group, int? preferredAccountId = null) + { + if (preferredAccountId.HasValue && preferredAccountId.Value > 0) + return preferredAccountId.Value; + + if (group.CreatorAccountId.HasValue) + return group.CreatorAccountId.Value; + + return await _accountGroupRepository.GetPreferredAdminAccountIdAsync(group.Id); + } } diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs index 10d4925..a6f6f05 100644 --- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs @@ -152,6 +152,65 @@ public class ChannelService : IChannelService return channels; } + public async Task> GetVisibleChannelsAsync(int accountId) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + + var channels = new List(); + var dialogs = await client.Messages_GetAllDialogs(); + + foreach (var (_, chat) in dialogs.chats) + { + if (chat is not Channel channel || !channel.IsActive) + continue; + + try + { + var isCreator = + ReadBool(channel, "creator", "Creator", "is_creator", "IsCreator") + || ReadFlagsHas(channel, flagName: "creator", memberNames: new[] { "flags", "Flags" }); + var adminRights = ReadObject(channel, "admin_rights", "AdminRights", "adminRights"); + var isAdmin = isCreator || adminRights != null; + + var memberCount = ReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount", "memberCount", "MemberCount"); + string? about = null; + try + { + var fullChannel = await client.Channels_GetFullChannel(channel); + memberCount = fullChannel.full_chat.ParticipantsCount; + about = (fullChannel.full_chat as ChannelFull)?.about; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get full visible channel info for {ChannelId}", channel.id); + } + + channels.Add(new ChannelInfo + { + TelegramId = channel.id, + AccessHash = channel.access_hash, + Title = channel.title, + Username = channel.MainUsername, + IsBroadcast = channel.IsChannel, + MemberCount = memberCount, + About = about, + CreatorAccountId = isCreator ? accountId : null, + IsCreator = isCreator, + IsAdmin = isAdmin, + CreatedAt = channel.date, + SyncedAt = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get visible channel info for {ChannelId}", channel.id); + } + } + + _logger.LogInformation("Found {Count} visible channels for account {AccountId}", channels.Count, accountId); + return channels; + } + private static bool ReadBool(object obj, params string[] names) { foreach (var name in names) diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs index 87b221a..040feb9 100644 --- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs @@ -1,6 +1,6 @@ using System.Linq; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Models; using TelegramPanel.Core.Services; @@ -31,37 +31,43 @@ public class GroupService : IGroupService } public async Task> GetOwnedGroupsAsync(int accountId) + { + var groups = await GetVisibleGroupsAsync(accountId); + return groups.Where(x => x.IsCreator).ToList(); + } + + public async Task> GetVisibleGroupsAsync(int accountId) { var client = await GetOrCreateConnectedClientAsync(accountId); - var ownedGroups = new List(); + var groups = new List(); var dialogs = await client.Messages_GetAllDialogs(); - foreach (var (id, chat) in dialogs.chats) + foreach (var (_, chat) in dialogs.chats) { - // 处理基础群组(Chat类型,非Channel) - // 注意:基础 Chat 类型无法直接判断创建者,需要通过 GetFullChat 获取 + // 基础群组(Chat 类型,非 Channel) if (chat is Chat basicChat && basicChat.IsActive) { + var memberCount = (int)basicChat.participants_count; + var about = ReadString(basicChat, null, "about", "About"); + var username = ReadString(basicChat, null, "username", "Username", "MainUsername"); + var isCreator = false; + var isAdmin = false; + try { - // 获取完整信息来判断是否为创建者 var fullChat = await client.Messages_GetFullChat(basicChat.id); - if (fullChat.full_chat is ChatFull cf && cf.participants is ChatParticipants cp) + if (fullChat.full_chat is ChatFull cf) { - // 检查当前用户是否为创建者 - var creator = cp.participants.OfType() - .FirstOrDefault(p => p.user_id == client.User!.id); - if (creator != null) + about = ReadString(cf, about, "about", "About"); + memberCount = Math.Max(memberCount, ReadInt(cf, memberCount, "participants_count", "ParticipantsCount", "participantsCount")); + + if (cf.participants is ChatParticipants cp) { - ownedGroups.Add(new GroupInfo - { - TelegramId = basicChat.id, - Title = basicChat.title, - MemberCount = basicChat.participants_count, - CreatorAccountId = accountId, - SyncedAt = DateTime.UtcNow - }); + memberCount = Math.Max(memberCount, cp.participants.Count()); + var self = cp.participants.FirstOrDefault(p => GetChatParticipantUserId(p) == client.User!.id); + isCreator = self is ChatParticipantCreator; + isAdmin = isCreator || self is ChatParticipantAdmin; } } } @@ -78,38 +84,48 @@ public class GroupService : IGroupService _logger.LogDebug(ex, "GetFullChat debug details (chatId={ChatId})", basicChat.id); } } + + groups.Add(new GroupInfo + { + TelegramId = basicChat.id, + Title = basicChat.title, + Username = username, + MemberCount = memberCount, + About = about, + CreatorAccountId = isCreator ? accountId : null, + IsCreator = isCreator, + IsAdmin = isAdmin, + SyncedAt = DateTime.UtcNow + }); + + continue; } - // 处理超级群组(Channel类型但megagroup=true, 即 !IsChannel) - else if (chat is Channel channel && channel.IsActive && !channel.IsChannel) + + // 超级群组(Channel 类型但 megagroup=true,即 !IsChannel) + if (chat is not Channel channel || !channel.IsActive || channel.IsChannel) + continue; + + try { + var isCreator = + ReadBool(channel, "creator", "Creator", "is_creator", "IsCreator") + || ReadFlagsHas(channel, flagName: "creator", memberNames: new[] { "flags", "Flags" }); + var adminRights = ReadObject(channel, "admin_rights", "AdminRights", "adminRights"); + var isAdmin = isCreator || adminRights != null; + + var memberCount = ReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount", "memberCount", "MemberCount"); + string? about = null; try { - // 通过获取管理员列表来检查当前用户是否为创建者 - var participants = await client.Channels_GetParticipants(channel, new ChannelParticipantsAdmins()); - var isCreator = participants.participants - .OfType() - .Any(p => p.user_id == client.User!.id); - - if (!isCreator) continue; - var fullChannel = await client.Channels_GetFullChannel(channel); - ownedGroups.Add(new GroupInfo - { - TelegramId = channel.id, - AccessHash = channel.access_hash, - Title = channel.title, - Username = channel.MainUsername, - MemberCount = fullChannel.full_chat.ParticipantsCount, - CreatorAccountId = accountId, - SyncedAt = DateTime.UtcNow - }); + memberCount = fullChannel.full_chat.ParticipantsCount; + about = (fullChannel.full_chat as ChannelFull)?.about; } catch (Exception ex) { if (ex.Message.Contains("CHANNEL_MONOFORUM_UNSUPPORTED", StringComparison.OrdinalIgnoreCase)) { - // 该错误通常不影响账号本身,仅表示 Telegram 不支持对这类“论坛/话题”群组调用某些接口 - _logger.LogDebug("Skipping group {GroupId}: {Error}", channel.id, ex.Message); + _logger.LogDebug("Skipping full info for group {GroupId}: {Error}", channel.id, ex.Message); } else if (TryGetFloodWaitSeconds(ex.Message, out var seconds)) { @@ -122,11 +138,30 @@ public class GroupService : IGroupService _logger.LogDebug(ex, "GetFullGroup debug details (groupId={GroupId})", channel.id); } } + + groups.Add(new GroupInfo + { + TelegramId = channel.id, + AccessHash = channel.access_hash, + Title = channel.title, + Username = channel.MainUsername, + MemberCount = memberCount, + About = about, + CreatorAccountId = isCreator ? accountId : null, + IsCreator = isCreator, + IsAdmin = isAdmin, + CreatedAt = channel.date, + SyncedAt = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get visible group info for {GroupId}", channel.id); } } - _logger.LogInformation("Found {Count} owned groups for account {AccountId}", ownedGroups.Count, accountId); - return ownedGroups; + _logger.LogInformation("Found {Count} visible groups for account {AccountId}", groups.Count, accountId); + return groups; } private static bool TryGetFloodWaitSeconds(string message, out int seconds) @@ -135,7 +170,6 @@ public class GroupService : IGroupService if (string.IsNullOrWhiteSpace(message)) return false; - // 典型:FLOOD_WAIT_13 var idx = message.IndexOf("FLOOD_WAIT_", StringComparison.OrdinalIgnoreCase); if (idx < 0) return false; @@ -150,9 +184,149 @@ public class GroupService : IGroupService return true; } + private static bool ReadBool(object obj, params string[] names) + { + foreach (var name in names) + { + if (TryReadMember(obj, name, out var value) && value is bool b) + return b; + } + + return false; + } + + private static bool ReadFlagsHas(object obj, string flagName, string[] memberNames) + { + foreach (var memberName in memberNames) + { + if (!TryReadMember(obj, memberName, out var value) || value == null) + continue; + + if (value is Enum enumValue) + { + var flagEnum = TryGetEnumFlag(enumValue.GetType(), flagName); + if (flagEnum != null) + return enumValue.HasFlag(flagEnum); + continue; + } + + if (value is int intFlags) + return ReadNumericFlagsHas(obj, flagName, intFlags); + if (value is long longFlags) + return ReadNumericFlagsHas(obj, flagName, longFlags); + } + + return false; + } + + private static bool ReadNumericFlagsHas(object obj, string flagName, long flagsValue) + { + var flagsType = obj.GetType().GetNestedType("Flags"); + if (flagsType == null || !flagsType.IsEnum) + return false; + + var flagEnum = TryGetEnumFlag(flagsType, flagName); + if (flagEnum == null) + return false; + + var flagValue = Convert.ToInt64(flagEnum); + return (flagsValue & flagValue) == flagValue; + } + + private static Enum? TryGetEnumFlag(Type enumType, string flagName) + { + var names = Enum.GetNames(enumType); + var match = names.FirstOrDefault(n => string.Equals(n, flagName, StringComparison.OrdinalIgnoreCase)); + if (match == null) + return null; + + return (Enum)Enum.Parse(enumType, match); + } + + private static int ReadInt(object obj, int fallback, params string[] names) + { + foreach (var name in names) + { + if (TryReadMember(obj, name, out var value)) + { + if (value is int i) return i; + if (value is long l) return unchecked((int)l); + if (value is short s) return s; + if (value is byte by) return by; + } + } + + return fallback; + } + + private static long ReadLong(object obj, long fallback, params string[] names) + { + foreach (var name in names) + { + if (TryReadMember(obj, name, out var value)) + { + if (value is long l) return l; + if (value is int i) return i; + if (value is short s) return s; + if (value is byte by) return by; + } + } + + return fallback; + } + + private static string? ReadString(object obj, string? fallback, params string[] names) + { + foreach (var name in names) + { + if (TryReadMember(obj, name, out var value) && value is string s) + return s; + } + + return fallback; + } + + private static object? ReadObject(object obj, params string[] names) + { + foreach (var name in names) + { + if (TryReadMember(obj, name, out var value) && value != null) + return value; + } + + return null; + } + + private static bool TryReadMember(object obj, string name, out object? value) + { + var type = obj.GetType(); + + var prop = type.GetProperty(name); + if (prop != null && prop.CanRead) + { + value = prop.GetValue(obj); + return true; + } + + var field = type.GetField(name); + if (field != null) + { + value = field.GetValue(obj); + return true; + } + + value = null; + return false; + } + + private static long GetChatParticipantUserId(object participant) + { + return ReadLong(participant, 0, "user_id", "UserId", "userId"); + } + public async Task GetGroupInfoAsync(int accountId, long groupId) { - var groups = await GetOwnedGroupsAsync(accountId); + var groups = await GetVisibleGroupsAsync(accountId); return groups.FirstOrDefault(g => g.TelegramId == groupId); } @@ -161,7 +335,6 @@ public class GroupService : IGroupService var client = await GetOrCreateConnectedClientAsync(accountId); var dialogs = await client.Messages_GetAllDialogs(); - // 基础群组(Chat) var basic = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && c.id == groupId); if (basic != null) { @@ -178,7 +351,6 @@ public class GroupService : IGroupService return link; } - // 超级群组(Channel 但 megagroup=true,即 !IsChannel) var mega = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && !c.IsChannel && c.id == groupId); if (mega != null) { @@ -206,7 +378,6 @@ public class GroupService : IGroupService var client = await GetOrCreateConnectedClientAsync(accountId); var dialogs = await client.Messages_GetAllDialogs(); - // 基础群组(Chat) var basic = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && c.id == groupId); if (basic != null) { @@ -250,7 +421,6 @@ public class GroupService : IGroupService .ToList(); } - // 超级群组(Channel but !IsChannel) var mega = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && !c.IsChannel && c.id == groupId); if (mega != null) { diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 6a449cf..a867f8f 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -16,6 +16,7 @@ public class AppDbContext : DbContext public DbSet Accounts => Set(); public DbSet AccountCategories => Set(); public DbSet AccountChannels => Set(); + public DbSet AccountGroups => Set(); public DbSet Channels => Set(); public DbSet ChannelGroups => Set(); public DbSet Groups => Set(); @@ -134,6 +135,25 @@ public class AppDbContext : DbContext entity.HasOne(e => e.CreatorAccount) .WithMany(a => a.Groups) .HasForeignKey(e => e.CreatorAccountId) + .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); + + entity.HasOne(e => e.Account) + .WithMany(a => a.AccountGroups) + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Group) + .WithMany(g => g.AccountGroups) + .HasForeignKey(e => e.GroupId) .OnDelete(DeleteBehavior.Cascade); }); diff --git a/src/TelegramPanel.Data/Entities/Account.cs b/src/TelegramPanel.Data/Entities/Account.cs index 80b0691..7decde3 100644 --- a/src/TelegramPanel.Data/Entities/Account.cs +++ b/src/TelegramPanel.Data/Entities/Account.cs @@ -24,6 +24,7 @@ public class Account } public long UserId { get; set; } + /// /// 账号昵称(Telegram 显示名称) /// @@ -67,9 +68,22 @@ public class Account /// public string? TwoFactorPassword { get; set; } + /// + /// 账号当前已同步的频道数量(用于列表展示,不持久化) + /// + [NotMapped] + public int ChannelCount { get; set; } + + /// + /// 账号当前已同步的群组数量(用于列表展示,不持久化) + /// + [NotMapped] + public int GroupCount { get; set; } + // 导航属性 public AccountCategory? Category { get; set; } public ICollection Channels { get; set; } = new List(); public ICollection AccountChannels { get; set; } = new List(); public ICollection Groups { get; set; } = new List(); + public ICollection AccountGroups { get; set; } = new List(); } diff --git a/src/TelegramPanel.Data/Entities/AccountGroup.cs b/src/TelegramPanel.Data/Entities/AccountGroup.cs new file mode 100644 index 0000000..18ed80f --- /dev/null +++ b/src/TelegramPanel.Data/Entities/AccountGroup.cs @@ -0,0 +1,27 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 账号-群组关联 +/// +public class AccountGroup +{ + public int Id { get; set; } + + public int AccountId { get; set; } + public int GroupId { get; set; } + + /// + /// 是否为创建者(拥有者) + /// + public bool IsCreator { get; set; } + + /// + /// 是否为管理员(包含创建者) + /// + public bool IsAdmin { get; set; } + + public DateTime SyncedAt { get; set; } = DateTime.UtcNow; + + public Account Account { get; set; } = null!; + public Group Group { get; set; } = null!; +} diff --git a/src/TelegramPanel.Data/Entities/Group.cs b/src/TelegramPanel.Data/Entities/Group.cs index 2a9a91c..27e73ba 100644 --- a/src/TelegramPanel.Data/Entities/Group.cs +++ b/src/TelegramPanel.Data/Entities/Group.cs @@ -12,10 +12,11 @@ public class Group public string? Username { get; set; } public int MemberCount { get; set; } public string? About { get; set; } - public int CreatorAccountId { get; set; } + public int? CreatorAccountId { get; set; } public DateTime? CreatedAt { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; // 导航属性 - public Account CreatorAccount { get; set; } = null!; + public Account? CreatorAccount { get; set; } + public ICollection AccountGroups { get; set; } = new List(); } diff --git a/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs new file mode 100644 index 0000000..d56c9e6 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs @@ -0,0 +1,688 @@ +// +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("20260308155832_AddAccountGroups")] + partial class AddAccountGroups + { + /// + 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("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("CreatorAccountId"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + 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.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs new file mode 100644 index 0000000..2fa9c9f --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs @@ -0,0 +1,125 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddAccountGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Groups_Accounts_CreatorAccountId", + table: "Groups"); + + migrationBuilder.AlterColumn( + name: "CreatorAccountId", + table: "Groups", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.CreateTable( + name: "AccountGroups", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccountId = table.Column(type: "INTEGER", nullable: false), + GroupId = table.Column(type: "INTEGER", nullable: false), + IsCreator = table.Column(type: "INTEGER", nullable: false), + IsAdmin = table.Column(type: "INTEGER", nullable: false), + SyncedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountGroups", x => x.Id); + table.ForeignKey( + name: "FK_AccountGroups_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AccountGroups_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccountGroups_AccountId_GroupId", + table: "AccountGroups", + columns: new[] { "AccountId", "GroupId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AccountGroups_GroupId", + table: "AccountGroups", + column: "GroupId"); + + migrationBuilder.Sql(@" +INSERT OR IGNORE INTO AccountGroups (AccountId, GroupId, IsCreator, IsAdmin, SyncedAt) +SELECT CreatorAccountId, Id, 1, 1, SyncedAt +FROM Groups +WHERE CreatorAccountId IS NOT NULL; +"); + + migrationBuilder.AddForeignKey( + name: "FK_Groups_Accounts_CreatorAccountId", + table: "Groups", + column: "CreatorAccountId", + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Groups_Accounts_CreatorAccountId", + table: "Groups"); + + migrationBuilder.Sql(@" +UPDATE Groups +SET CreatorAccountId = ( + SELECT AccountId + FROM AccountGroups + WHERE GroupId = Groups.Id AND IsCreator = 1 + ORDER BY SyncedAt DESC + LIMIT 1 +) +WHERE CreatorAccountId IS NULL; +"); + + migrationBuilder.Sql(@"DELETE FROM Groups WHERE CreatorAccountId IS NULL;"); + + migrationBuilder.DropTable( + name: "AccountGroups"); + + migrationBuilder.AlterColumn( + name: "CreatorAccountId", + table: "Groups", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_Groups_Accounts_CreatorAccountId", + table: "Groups", + column: "CreatorAccountId", + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 1eba4bc..e8c6d6c 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace TelegramPanel.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.22"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.24"); modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => { @@ -162,6 +162,37 @@ namespace TelegramPanel.Data.Migrations 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") @@ -466,7 +497,7 @@ namespace TelegramPanel.Data.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); - b.Property("CreatorAccountId") + b.Property("CreatorAccountId") .HasColumnType("INTEGER"); b.Property("MemberCount") @@ -528,6 +559,25 @@ namespace TelegramPanel.Data.Migrations 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") @@ -579,8 +629,7 @@ namespace TelegramPanel.Data.Migrations b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") .WithMany("Groups") .HasForeignKey("CreatorAccountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); b.Navigation("CreatorAccount"); }); @@ -589,6 +638,8 @@ namespace TelegramPanel.Data.Migrations { b.Navigation("AccountChannels"); + b.Navigation("AccountGroups"); + b.Navigation("Channels"); b.Navigation("Groups"); @@ -623,6 +674,11 @@ namespace TelegramPanel.Data.Migrations { b.Navigation("Channels"); }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Navigation("AccountGroups"); + }); #pragma warning restore 612, 618 } } diff --git a/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs b/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs new file mode 100644 index 0000000..03f10a1 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 账号-群组关联仓储实现 +/// +public class AccountGroupRepository : Repository, IAccountGroupRepository +{ + public AccountGroupRepository(AppDbContext context) : base(context) + { + } + + public async Task GetAsync(int accountId, int groupId) + { + return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.GroupId == groupId); + } + + public async Task UpsertAsync(AccountGroup link) + { + var existing = await GetAsync(link.AccountId, link.GroupId); + if (existing == null) + { + await _dbSet.AddAsync(link); + } + else + { + existing.IsCreator = link.IsCreator; + existing.IsAdmin = link.IsAdmin; + existing.SyncedAt = link.SyncedAt; + _dbSet.Update(existing); + } + + await _context.SaveChangesAsync(); + } + + public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepGroupIds) + { + var keep = keepGroupIds.ToHashSet(); + var toDelete = await _dbSet + .Where(x => x.AccountId == accountId && !keep.Contains(x.GroupId)) + .ToListAsync(); + + if (toDelete.Count == 0) + return; + + _dbSet.RemoveRange(toDelete); + await _context.SaveChangesAsync(); + } + + public async Task GetPreferredAdminAccountIdAsync(int groupId) + { + return await _dbSet + .Where(x => x.GroupId == groupId && x.IsAdmin) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.SyncedAt) + .Select(x => (int?)x.AccountId) + .FirstOrDefaultAsync(); + } +} diff --git a/src/TelegramPanel.Data/Repositories/AccountRepository.cs b/src/TelegramPanel.Data/Repositories/AccountRepository.cs index c8a797a..9efff0f 100644 --- a/src/TelegramPanel.Data/Repositories/AccountRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountRepository.cs @@ -174,6 +174,8 @@ public class AccountRepository : Repository, IAccountRepository .Take(pageSize) .ToListAsync(cancellationToken); + await PopulateStatisticsAsync(items, cancellationToken); + return (items, total); } @@ -186,4 +188,63 @@ public class AccountRepository : Repository, IAccountRepository var query = BuildQuery(categoryId, search, onlyWaste); return await query.ToListAsync(cancellationToken); } + + private async Task PopulateStatisticsAsync(IReadOnlyList items, CancellationToken cancellationToken) + { + if (items.Count == 0) + return; + + var accountIds = items + .Select(x => x.Id) + .Where(x => x > 0) + .Distinct() + .ToArray(); + + if (accountIds.Length == 0) + return; + + var linkedChannelPairs = await _context.Set() + .AsNoTracking() + .Where(x => accountIds.Contains(x.AccountId)) + .Select(x => new { x.AccountId, x.ChannelId }) + .ToListAsync(cancellationToken); + + var createdChannelPairs = await _context.Set() + .AsNoTracking() + .Where(x => x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value)) + .Select(x => new { AccountId = x.CreatorAccountId!.Value, ChannelId = x.Id }) + .ToListAsync(cancellationToken); + + var channelCountMap = linkedChannelPairs + .Concat(createdChannelPairs) + .GroupBy(x => x.AccountId) + .ToDictionary( + g => g.Key, + g => g.Select(x => x.ChannelId).Distinct().Count()); + + var linkedGroupPairs = await _context.Set() + .AsNoTracking() + .Where(x => accountIds.Contains(x.AccountId)) + .Select(x => new { x.AccountId, x.GroupId }) + .ToListAsync(cancellationToken); + + var createdGroupPairs = await _context.Set() + .AsNoTracking() + .Where(x => x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value)) + .Select(x => new { AccountId = x.CreatorAccountId!.Value, GroupId = x.Id }) + .ToListAsync(cancellationToken); + + var groupCountMap = linkedGroupPairs + .Concat(createdGroupPairs) + .GroupBy(x => x.AccountId) + .ToDictionary( + g => g.Key, + g => g.Select(x => x.GroupId).Distinct().Count()); + + foreach (var account in items) + { + account.ChannelCount = channelCountMap.TryGetValue(account.Id, out var channelCount) ? channelCount : 0; + account.GroupCount = groupCountMap.TryGetValue(account.Id, out var groupCount) ? groupCount : 0; + } + } } diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index 952fda9..d935715 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -12,19 +12,38 @@ public class ChannelRepository : Repository, IChannelRepository { } - private IQueryable BuildForViewQuery(int creatorAccountId, string? filterType, string? search) + private IQueryable BuildForViewQuery(int accountId, string? filterType, string? membershipRole, string? search) { var query = _dbSet .AsNoTracking() .Include(c => c.CreatorAccount) .Include(c => c.Group) + .Include(c => c.AccountChannels) + .AsSplitQuery() .AsQueryable(); - // 列表只展示“系统创建”的频道(有 CreatorAccountId) - if (creatorAccountId <= 0) - query = query.Where(c => c.CreatorAccountId != null); + 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 - query = query.Where(c => c.CreatorAccountId == creatorAccountId); + { + query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any()); + } filterType = (filterType ?? "all").Trim().ToLowerInvariant(); if (filterType == "public") @@ -123,8 +142,9 @@ public class ChannelRepository : Repository, IChannelRepository } public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( - int creatorAccountId, + int accountId, string? filterType, + string? membershipRole, string? search, int pageIndex, int pageSize, @@ -134,7 +154,7 @@ public class ChannelRepository : Repository, IChannelRepository if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildForViewQuery(creatorAccountId, filterType, search); + var query = BuildForViewQuery(accountId, filterType, membershipRole, search); var total = await query.CountAsync(cancellationToken); var items = await query .Skip(pageIndex * pageSize) diff --git a/src/TelegramPanel.Data/Repositories/GroupRepository.cs b/src/TelegramPanel.Data/Repositories/GroupRepository.cs index f8075a0..096c224 100644 --- a/src/TelegramPanel.Data/Repositories/GroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/GroupRepository.cs @@ -12,10 +12,62 @@ public class GroupRepository : Repository, IGroupRepository { } + private IQueryable BuildForViewQuery(int accountId, string? filterType, string? membershipRole, string? search) + { + var query = _dbSet + .AsNoTracking() + .Include(g => g.CreatorAccount) + .Include(g => g.AccountGroups) + .AsSplitQuery() + .AsQueryable(); + + 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 + { + query = query.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any()); + } + + filterType = (filterType ?? "all").Trim().ToLowerInvariant(); + if (filterType == "public") + query = query.Where(g => g.Username != null && g.Username != ""); + else if (filterType == "private") + query = query.Where(g => g.Username == null || g.Username == ""); + + search = (search ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(search)) + { + var like = $"%{search}%"; + query = query.Where(g => + EF.Functions.Like(g.Title, like) + || (g.Username != null && EF.Functions.Like(g.Username, like))); + } + + return query.OrderByDescending(g => g.SyncedAt); + } + public override async Task GetByIdAsync(int id) { return await _dbSet .Include(g => g.CreatorAccount) + .Include(g => g.AccountGroups) + .AsSplitQuery() .FirstOrDefaultAsync(g => g.Id == id); } @@ -23,6 +75,9 @@ public class GroupRepository : Repository, IGroupRepository { return await _dbSet .Include(g => g.CreatorAccount) + .Include(g => g.AccountGroups) + .AsSplitQuery() + .Where(g => g.CreatorAccountId != null || g.AccountGroups.Any()) .OrderByDescending(g => g.SyncedAt) .ToListAsync(); } @@ -31,6 +86,8 @@ public class GroupRepository : Repository, IGroupRepository { return await _dbSet .Include(g => g.CreatorAccount) + .Include(g => g.AccountGroups) + .AsSplitQuery() .FirstOrDefaultAsync(g => g.TelegramId == telegramId); } @@ -38,8 +95,33 @@ public class GroupRepository : Repository, IGroupRepository { return await _dbSet .Include(g => g.CreatorAccount) + .Include(g => g.AccountGroups) + .AsSplitQuery() .Where(g => g.CreatorAccountId == accountId) .OrderByDescending(g => g.SyncedAt) .ToListAsync(); } + + public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( + int accountId, + string? filterType, + string? membershipRole, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + if (pageIndex < 0) pageIndex = 0; + if (pageSize <= 0) pageSize = 20; + if (pageSize > 500) pageSize = 500; + + var query = BuildForViewQuery(accountId, filterType, membershipRole, search); + var total = await query.CountAsync(cancellationToken); + var items = await query + .Skip(pageIndex * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } } diff --git a/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs new file mode 100644 index 0000000..893c4b5 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs @@ -0,0 +1,14 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 账号-群组关联仓储接口 +/// +public interface IAccountGroupRepository : IRepository +{ + Task GetAsync(int accountId, int groupId); + Task UpsertAsync(AccountGroup link); + Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepGroupIds); + Task GetPreferredAdminAccountIdAsync(int groupId); +} diff --git a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs index f419db6..113bba8 100644 --- a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs @@ -15,8 +15,9 @@ public interface IChannelRepository : IRepository Task> GetBroadcastChannelsAsync(); Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( - int creatorAccountId, + int accountId, string? filterType, + string? membershipRole, string? search, int pageIndex, int pageSize, diff --git a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs index 8ae7e1e..9e6d521 100644 --- a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs @@ -9,4 +9,12 @@ public interface IGroupRepository : IRepository { Task GetByTelegramIdAsync(long telegramId); Task> GetByCreatorAccountAsync(int accountId); + Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( + int accountId, + string? filterType, + string? membershipRole, + string? search, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index e3a0bca..3c55a95 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -23,6 +23,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/GroupDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor index 7c11f13..92b3060 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor @@ -103,6 +103,7 @@ private bool loading = true; private Group? group; private string creatorDisplay = "-"; + private int? executeAccountId; private bool adminsLoading; private List admins = new(); @@ -122,10 +123,19 @@ if (group == null) return; - var creator = await AccountManagement.GetAccountAsync(group.CreatorAccountId); - creatorDisplay = creator == null - ? $"账号 {group.CreatorAccountId}" - : $"{creator.DisplayPhone}({creator.Nickname ?? creator.Username ?? creator.UserId.ToString()})"; + if (group.CreatorAccountId.HasValue) + { + var creator = await AccountManagement.GetAccountAsync(group.CreatorAccountId.Value); + creatorDisplay = creator == null + ? $"账号 {group.CreatorAccountId.Value}" + : $"{creator.DisplayPhone}({creator.Nickname ?? creator.Username ?? creator.UserId.ToString()})"; + } + else + { + creatorDisplay = "(非系统创建)"; + } + + executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync(group); } catch (Exception ex) { @@ -142,10 +152,17 @@ if (group == null) return; + if (executeAccountId is not > 0) + { + admins.Clear(); + Snackbar.Add("该群组暂无可用的执行账号(请先同步群组关联账号,或确保至少有一个账号是管理员)", Severity.Warning); + return; + } + adminsLoading = true; try { - admins = await GroupService.GetAdminsAsync(group.CreatorAccountId, group.TelegramId); + admins = await GroupService.GetAdminsAsync(executeAccountId.Value, group.TelegramId); } catch (Exception ex) { @@ -163,9 +180,15 @@ if (group == null) return; + if (executeAccountId is not > 0) + { + Snackbar.Add("该群组暂无可用的执行账号,无法导出链接(请先同步群组关联账号)", Severity.Warning); + return; + } + try { - var link = await GroupService.ExportJoinLinkAsync(group.CreatorAccountId, group.TelegramId); + var link = await GroupService.ExportJoinLinkAsync(executeAccountId.Value, group.TelegramId); await JS.InvokeVoidAsync("telegramPanel.copyText", link); Snackbar.Add("已复制链接", Severity.Success); } diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 293b8c2..7fdbef7 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -136,7 +136,7 @@ } - @context.Channels.Count / @context.Groups.Count + @context.ChannelCount / @context.GroupCount diff --git a/src/TelegramPanel.Web/Components/Pages/Channels.razor b/src/TelegramPanel.Web/Components/Pages/Channels.razor index 20e43bb..f729ce0 100644 --- a/src/TelegramPanel.Web/Components/Pages/Channels.razor +++ b/src/TelegramPanel.Web/Components/Pages/Channels.razor @@ -31,6 +31,14 @@ 公开 私密 + + 全部角色 + 我创建 + 我管理 + 非管理员 + 用户名 类型 成员数 + @if (filterAccount > 0) + { + 当前账号角色 + } 创建账号 最后同步 操作 @@ -115,6 +127,14 @@ @context.MemberCount + @if (filterAccount > 0) + { + + + @GetCurrentAccountRoleLabel(context) + + + } @if (context.CreatorAccount != null) { @@ -170,6 +190,7 @@ private string searchString = ""; private int filterAccount = 0; private string filterType = "all"; + private string filterMembershipRole = "all"; private HashSet selectedChannels = new(); private CancellationTokenSource? _searchDebounceCts; @@ -230,8 +251,9 @@ try { var (items, total) = await ChannelManagement.QueryChannelsForViewPagedAsync( - creatorAccountId: filterAccount, + accountId: filterAccount, filterType: filterType, + membershipRole: filterAccount > 0 ? filterMembershipRole : "all", search: searchString, pageIndex: state.Page, pageSize: state.PageSize, @@ -288,6 +310,9 @@ private async Task OnFiltersChanged() { + if (filterAccount <= 0) + filterMembershipRole = "all"; + await ReloadTable(); } @@ -349,6 +374,42 @@ return namePart; } + private AccountChannel? GetCurrentAccountRelation(Channel channel) + { + if (filterAccount <= 0) + return null; + + return channel.AccountChannels? + .Where(x => x.AccountId == filterAccount) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .FirstOrDefault(); + } + + private string GetCurrentAccountRoleLabel(Channel channel) + { + var relation = GetCurrentAccountRelation(channel); + if (relation == null) + return "未同步"; + if (relation.IsCreator) + return "创建者"; + if (relation.IsAdmin) + return "管理员"; + return "成员"; + } + + private Color GetCurrentAccountRoleColor(Channel channel) + { + var relation = GetCurrentAccountRelation(channel); + if (relation == null) + return Color.Default; + if (relation.IsCreator) + return Color.Success; + if (relation.IsAdmin) + return Color.Info; + return Color.Default; + } + private async Task OpenBatchInviteSelected() { var targets = selectedChannels.ToList(); @@ -513,6 +574,9 @@ private int GuessDefaultExecuteAccountId(IReadOnlyList targets) { + if (filterAccount > 0) + return filterAccount; + if (targets.Count == 1) { var cid = targets[0].CreatorAccountId; @@ -520,9 +584,6 @@ return cid.Value; } - if (filterAccount > 0) - return filterAccount; - var ids = targets.Select(x => x.CreatorAccountId).Where(x => x != null && x.Value > 0).Select(x => x!.Value).Distinct().ToList(); return ids.Count == 1 ? ids[0] : 0; } @@ -661,14 +722,16 @@ { try { - var executeAccountId = channel.CreatorAccountId ?? filterAccount; - if (executeAccountId <= 0) + var executeAccountId = await ChannelManagement.ResolveExecuteAccountIdAsync( + channel, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + if (executeAccountId is not > 0) { - Snackbar.Add("无法导出链接:请先选择账号或确保频道有创建账号", Severity.Warning); + Snackbar.Add("无法导出链接:该频道暂无可用执行账号,请先同步对应账号的频道数据", Severity.Warning); return; } - var link = await ChannelService.ExportJoinLinkAsync(executeAccountId, channel.TelegramId); + var link = await ChannelService.ExportJoinLinkAsync(executeAccountId.Value, channel.TelegramId); await JS.InvokeVoidAsync("telegramPanel.copyText", link); Snackbar.Add("已复制链接", Severity.Success); diff --git a/src/TelegramPanel.Web/Components/Pages/Groups.razor b/src/TelegramPanel.Web/Components/Pages/Groups.razor index ce1b7bc..1ff2160 100644 --- a/src/TelegramPanel.Web/Components/Pages/Groups.razor +++ b/src/TelegramPanel.Web/Components/Pages/Groups.razor @@ -1,6 +1,7 @@ @page "/groups" @inject GroupManagementService GroupManagement @inject AccountManagementService AccountManagement +@inject TelegramPanel.Web.Services.DataSyncService DataSync @inject IGroupService GroupService @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -13,26 +14,84 @@ 群组列表 + + + + 全部账号 + @foreach (var account in accounts) + { + @FormatAccountLabel(account) + } + + + 全部 + 公开 + 私密 + + + 全部角色 + 我创建 + 我管理 + 非管理员 + + + + + + + + @SelectionToggleText + + + + 同步当前 + + + 同步全部 + + + + 批量复制加入链接(已选) + 批量导出加入链接(已选) + + 批量删除(已选) + + + @if (selectedGroups.Count > 0) + { + 已选 @selectedGroups.Count + } + else + { + 群组 (@totalCount) + } + + - - - 我的群组 (@groups.Count) - - - 全部账号 - @foreach (var account in accounts) - { - @account.DisplayPhone (@account.Username) - } - - - + 群组名称 用户名 + 类型 成员数 + @if (filterAccount > 0) + { + 当前账号角色 + } 创建账号 最后同步 操作 @@ -60,25 +119,45 @@ - } + + + @(string.IsNullOrWhiteSpace(context.Username) ? "私密" : "公开") + + @context.MemberCount + @if (filterAccount > 0) + { + + + @GetCurrentAccountRoleLabel(context) + + + } @if (context.CreatorAccount != null) { @context.CreatorAccount.DisplayPhone } - else + else if (context.CreatorAccountId != null) { 账号 @context.CreatorAccountId } + else + { + - + } - - 查看详情 - 复制链接 - - 删除群组 - + + + + 复制链接 + + 删除群组 + + @@ -87,21 +166,46 @@ + + + @code { + private MudTable? _table; private List groups = new(); private List accounts = new(); + private int totalCount; private bool loading = true; private string searchString = ""; private int filterAccount = 0; + private string filterType = "all"; + private string filterMembershipRole = "all"; + private HashSet selectedGroups = new(); + private CancellationTokenSource? _searchDebounceCts; + + private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; + private string SelectionToggleText => _selectionToggleMode switch + { + SelectionToggleMode.SelectAll => "全选本页", + SelectionToggleMode.Invert => "反选本页", + SelectionToggleMode.Clear => "清空选择", + _ => "全选本页" + }; + + private string SelectionToggleIcon => _selectionToggleMode switch + { + SelectionToggleMode.SelectAll => Icons.Material.Filled.SelectAll, + SelectionToggleMode.Invert => Icons.Material.Filled.Flip, + SelectionToggleMode.Clear => Icons.Material.Filled.Clear, + _ => Icons.Material.Filled.SelectAll + }; protected override async Task OnInitializedAsync() { await LoadAccounts(); - await LoadGroups(); } private async Task LoadAccounts() @@ -119,17 +223,30 @@ } } - private async Task LoadGroups() + private async Task> LoadGroupsServerData(TableState state, CancellationToken cancellationToken) { loading = true; try { - var groupList = await GroupManagement.GetAllGroupsAsync(); - groups = groupList.ToList(); + var (items, total) = await GroupManagement.QueryGroupsForViewPagedAsync( + accountId: filterAccount, + filterType: filterType, + membershipRole: filterAccount > 0 ? filterMembershipRole : "all", + search: searchString, + pageIndex: state.Page, + pageSize: state.PageSize, + cancellationToken: cancellationToken); + + groups = items.ToList(); + totalCount = total; + selectedGroups.Clear(); + + return new TableData { Items = groups, TotalItems = totalCount }; } catch (Exception ex) { Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + return new TableData { Items = Array.Empty(), TotalItems = 0 }; } finally { @@ -137,35 +254,317 @@ } } - private bool FilterFunc(Group group) + private async Task ReloadTable() { - if (filterAccount > 0 && group.CreatorAccountId != filterAccount) - return false; - - if (string.IsNullOrWhiteSpace(searchString)) - return true; - - if (group.Title.Contains(searchString, StringComparison.OrdinalIgnoreCase)) - return true; - - if (group.Username?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true) - return true; - - return false; + selectedGroups.Clear(); + if (_table != null) + await _table.ReloadServerData(); } - private void ShowDetails(Group group) + private Task SelectAllFiltered() + { + selectedGroups = groups.ToHashSet(); + return Task.CompletedTask; + } + + private Task InvertSelection() + { + var targets = groups.ToList(); + foreach (var group in targets) + { + if (selectedGroups.Contains(group)) + selectedGroups.Remove(group); + else + selectedGroups.Add(group); + } + + return Task.CompletedTask; + } + + private Task ClearSelection() + { + selectedGroups.Clear(); + return Task.CompletedTask; + } + + private async Task OnFiltersChanged() + { + if (filterAccount <= 0) + filterMembershipRole = "all"; + + await ReloadTable(); + } + + private async Task OnSearchChanged() + { + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = new CancellationTokenSource(); + + var token = _searchDebounceCts.Token; + try + { + await Task.Delay(300, token); + await ReloadTable(); + } + catch (OperationCanceledException) + { + // ignore + } + } + + private async Task CycleSelectionToggle() + { + switch (_selectionToggleMode) + { + case SelectionToggleMode.SelectAll: + await SelectAllFiltered(); + _selectionToggleMode = SelectionToggleMode.Invert; + break; + case SelectionToggleMode.Invert: + await InvertSelection(); + _selectionToggleMode = SelectionToggleMode.Clear; + break; + case SelectionToggleMode.Clear: + await ClearSelection(); + _selectionToggleMode = SelectionToggleMode.SelectAll; + break; + default: + _selectionToggleMode = SelectionToggleMode.SelectAll; + break; + } + } + + private enum SelectionToggleMode + { + SelectAll = 0, + Invert = 1, + Clear = 2 + } + + 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; + } + + private AccountGroup? GetCurrentAccountRelation(Group group) + { + if (filterAccount <= 0) + return null; + + return group.AccountGroups? + .Where(x => x.AccountId == filterAccount) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .FirstOrDefault(); + } + + private string GetCurrentAccountRoleLabel(Group group) + { + var relation = GetCurrentAccountRelation(group); + if (relation == null) + return "未同步"; + if (relation.IsCreator) + return "创建者"; + if (relation.IsAdmin) + return "管理员"; + return "成员"; + } + + private Color GetCurrentAccountRoleColor(Group group) + { + var relation = GetCurrentAccountRelation(group); + if (relation == null) + return Color.Default; + if (relation.IsCreator) + return Color.Success; + if (relation.IsAdmin) + return Color.Info; + return Color.Default; + } + + private async Task BatchCopyLinksSelected() + { + if (loading) + return; + + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认复制", + $"将为 {targets.Count} 个群组生成加入链接并复制到剪贴板。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + try + { + var lines = new List + { + $"# ExportedAtUtc: {DateTime.UtcNow:O}", + "# Format: \\t\\t<Link>", + "" + }; + + var ok = 0; + var skipped = 0; + var failed = 0; + + foreach (var group in targets) + { + var executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync( + group, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + if (executeAccountId is not > 0) + { + skipped++; + lines.Add($"{group.TelegramId}\t{group.Title}\t(无可用执行账号)"); + continue; + } + + try + { + var link = await GroupService.ExportJoinLinkAsync(executeAccountId.Value, group.TelegramId); + ok++; + lines.Add($"{group.TelegramId}\t{group.Title}\t{link}"); + } + catch + { + failed++; + lines.Add($"{group.TelegramId}\t{group.Title}\t(无法生成/不可见/无权限)"); + } + } + + var text = string.Join(Environment.NewLine, lines); + await JS.InvokeVoidAsync("telegramPanel.copyText", text); + Snackbar.Add($"已复制加入链接:成功 {ok}/{targets.Count},失败 {failed}/{targets.Count},跳过 {skipped}/{targets.Count}", failed == 0 ? Severity.Success : Severity.Warning); + } + catch (Exception ex) + { + Snackbar.Add($"复制失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task BatchExportLinksSelected() + { + if (loading) + return; + + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认导出", + $"将为 {targets.Count} 个群组生成加入链接并导出。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (result != true) + return; + + var ids = string.Join(",", targets.Select(x => x.TelegramId)); + var url = $"/downloads/groups/invites.txt?ids={Uri.EscapeDataString(ids)}"; + if (filterAccount > 0) + url += $"&accountId={filterAccount}"; + + Navigation.NavigateTo(url, forceLoad: true); + await Task.CompletedTask; + } + + private async Task SyncCurrentAccountGroups() + { + if (loading || filterAccount <= 0) + return; + + loading = true; + try + { + var summary = await DataSync.SyncAccountAsync(filterAccount, CancellationToken.None); + + foreach (var failure in summary.AccountFailures) + Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning); + + Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"同步失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task SyncAllAccountsGroups() + { + if (loading) + return; + + loading = true; + try + { + var summary = await DataSync.SyncAllActiveAccountsAsync(CancellationToken.None); + + foreach (var failure in summary.AccountFailures) + Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning); + + Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"同步失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task ShowDetails(Group group) { var parameters = new DialogParameters { ["GroupDbId"] = group.Id }; var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; DialogService.Show<GroupDetailsDialog>("群组详情", parameters, options); + await Task.CompletedTask; } - private async Task CopyLink(Group group) + private async Task CopyLinkAsync(Group group) { try { - var link = await GroupService.ExportJoinLinkAsync(group.CreatorAccountId, group.TelegramId); + var executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync( + group, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + if (executeAccountId is not > 0) + { + Snackbar.Add("无法导出链接:该群组暂无可用执行账号,请先同步对应账号的群组数据", Severity.Warning); + return; + } + + var link = await GroupService.ExportJoinLinkAsync(executeAccountId.Value, group.TelegramId); await JS.InvokeVoidAsync("telegramPanel.copyText", link); Snackbar.Add("已复制链接", Severity.Success); } @@ -175,10 +574,48 @@ } } + private async Task DeleteSelectedGroups() + { + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认删除", + $"确定要删除选中的 {targets.Count} 个群组吗?此操作不可恢复。", + yesText: "删除", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + try + { + foreach (var group in targets) + await GroupManagement.DeleteGroupAsync(group.Id); + + 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 DeleteGroup(int id) { var group = groups.FirstOrDefault(g => g.Id == id); - if (group == null) return; + if (group == null) + return; bool? result = await DialogService.ShowMessageBox( "确认删除", @@ -190,7 +627,7 @@ try { await GroupManagement.DeleteGroupAsync(id); - groups.Remove(group); + await ReloadTable(); Snackbar.Add("删除成功", Severity.Success); } catch (Exception ex) diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 2aa8400..f129238 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -142,9 +142,13 @@ <MudCardContent> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mb-3"> <MudText Typo="Typo.body2"> - <strong>同步说明:</strong>将每个活跃账号“创建的频道 + 创建的群组”拉取并写入本地数据库,用于频道/群组列表展示,以及批量任务自动选择执行账号。 + <strong>同步说明:</strong>将每个活跃账号“当前可见的频道 + 当前可见的群组”拉取并写入本地数据库,用于频道/群组列表展示,以及批量任务自动选择执行账号。 <br /> - <strong>不包含:</strong>Telegram 状态检测(冻结/封号等)与验证码收取。 + <strong>任务中心:</strong>自动同步与“立即同步”都会记录到【任务中心】,可查看是否正在运行、已同步到第几个账号以及失败数量。 + <br /> + <strong>补充:</strong>同步过程中若遇到连接异常,会顺带执行一次轻量 Telegram 状态刷新(不做创建频道深度探测)。 + <br /> + <strong>不包含:</strong>深度 Telegram 状态检测(创建频道探测)与验证码收取。 </MudText> </MudAlert> @@ -633,12 +637,13 @@ try { - var summary = await DataSync.SyncAllActiveAccountsAsync(CancellationToken.None); + var run = await DataSync.RunAllActiveAccountsTrackedAsync("manual", CancellationToken.None); + var summary = run.Summary; foreach (var failure in summary.AccountFailures) Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning); - Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); + Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组。任务 #{run.TaskId} 已记录到任务中心", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); } catch (Exception ex) { diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index efc9249..2e01ec6 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -298,6 +298,7 @@ BatchTaskTypes.UserChatActive => "user", BatchTaskTypes.BotChannelSetAdminsByAccount => "bot", BatchTaskTypes.BotSetAdmins => "bot", + BatchTaskTypes.AccountAutoSync => "system", _ => "other" }; } @@ -329,7 +330,11 @@ && !string.IsNullOrWhiteSpace(reg.Definition.Icon)) return reg.Definition.Icon; - return Icons.Material.Filled.Task; + return type switch + { + BatchTaskTypes.AccountAutoSync => Icons.Material.Filled.Sync, + _ => Icons.Material.Filled.Task + }; } private string GetTaskTypeName(string type) @@ -344,6 +349,7 @@ BatchTaskTypes.UserChatActive => "账号持续活跃(群组/频道)", BatchTaskTypes.BotChannelSetAdminsByAccount => "Bot频道批量设置管理员(账号执行)", BatchTaskTypes.BotSetAdmins => "Bot频道批量设置管理员(机器人执行)", + BatchTaskTypes.AccountAutoSync => "账号数据同步", _ => type }; } diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index b71ba68..8559b0c 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -1005,6 +1005,78 @@ var channelInvitesDownload = app.MapGet("/downloads/channels/invites.txt", async if (adminAuthEnabled) channelInvitesDownload.RequireAuthorization(); +// 下载:导出群组加入链接(文本) +var groupInvitesDownload = app.MapGet("/downloads/groups/invites.txt", async ( + HttpContext http, + GroupManagementService groupManagement, + IGroupService groupService, + CancellationToken cancellationToken) => +{ + var idsRaw = http.Request.Query["ids"].ToString(); + IReadOnlyList<long> telegramIds; + if (string.IsNullOrWhiteSpace(idsRaw)) + { + telegramIds = (await groupManagement.GetAllGroupsAsync()).Select(x => x.TelegramId).Where(x => x > 0).Distinct().ToList(); + } + else + { + telegramIds = idsRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var x) ? x : 0) + .Where(x => x > 0) + .Distinct() + .ToList(); + } + + var preferredAccountIdRaw = http.Request.Query["accountId"].ToString(); + var preferredAccountId = int.TryParse(preferredAccountIdRaw, out var x) ? x : 0; + if (preferredAccountId <= 0) + preferredAccountId = 0; + + var lines = new List<string> + { + $"# ExportedAtUtc: {DateTime.UtcNow:O}", + "# Format: <TelegramId>\\t<Title>\\t<Link>", + "" + }; + + foreach (var telegramId in telegramIds) + { + cancellationToken.ThrowIfCancellationRequested(); + + var group = await groupManagement.GetGroupByTelegramIdAsync(telegramId); + if (group == null) + { + lines.Add($"{telegramId}\t(unknown)\t(群组不存在)"); + continue; + } + + var executeAccountId = await groupManagement.ResolveExecuteAccountIdAsync(group, preferredAccountId: preferredAccountId); + if (executeAccountId is not > 0) + { + lines.Add($"{telegramId}\t{group.Title}\t(无可用执行账号)"); + continue; + } + + try + { + var link = await groupService.ExportJoinLinkAsync(executeAccountId.Value, telegramId); + lines.Add($"{telegramId}\t{group.Title}\t{link}"); + } + catch + { + lines.Add($"{telegramId}\t{group.Title}\t(无法生成/不可见/无权限)"); + } + } + + var text = string.Join(Environment.NewLine, lines); + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + var fileName = $"telegram-panel-group-invites-{DateTime.UtcNow:yyyyMMdd-HHmmss}.txt"; + return Results.File(bytes, "text/plain; charset=utf-8", fileName); +}).DisableAntiforgery(); +if (adminAuthEnabled) + groupInvitesDownload.RequireAuthorization(); + // Telegram Bot Webhook 端点 // 接收 Telegram 服务器推送的更新,用于 Webhook 模式 app.MapPost("/api/bot/webhook/{secretToken}", async ( diff --git a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs index 086d387..0785b60 100644 --- a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs @@ -67,15 +67,23 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService var dataSync = scope.ServiceProvider.GetRequiredService<DataSyncService>(); _logger.LogInformation("Account auto sync started"); - var summary = await dataSync.SyncAllActiveAccountsAsync(stoppingToken); + var run = await dataSync.RunAllActiveAccountsTrackedAsync("auto", stoppingToken); + var summary = run.Summary; + if (summary.AccountFailures.Count > 0) { foreach (var f in summary.AccountFailures) _logger.LogWarning("Account auto sync failed: {Phone} {Error}", f.Phone, f.Error); } - _logger.LogInformation("Account auto sync completed: {Channels} channels, {Groups} groups (failures={Failures})", - summary.TotalChannelsSynced, summary.TotalGroupsSynced, summary.AccountFailures.Count); + _logger.LogInformation( + "Account auto sync completed: taskId={TaskId}, accounts={Processed}/{Total}, channels={Channels}, groups={Groups}, failures={Failures}", + run.TaskId, + summary.ProcessedAccounts, + summary.TotalAccounts, + summary.TotalChannelsSynced, + summary.TotalGroupsSynced, + summary.AccountFailures.Count); // 记录“上次自动同步时间”,用于容器重启后继续按间隔调度,避免“重启即跑一轮”导致限流。 await PersistLastAutoSyncAtUtcAsync(DateTime.UtcNow, stoppingToken); @@ -84,6 +92,11 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService { // ignore } + catch (InvalidOperationException ex) when (ex.Message.Contains("已在运行", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Account auto sync skipped: {Message}", ex.Message); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } catch (Exception ex) { _logger.LogWarning(ex, "Account auto sync failed"); @@ -151,4 +164,3 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService return created; } } - diff --git a/src/TelegramPanel.Web/Services/DataSyncService.cs b/src/TelegramPanel.Web/Services/DataSyncService.cs index 88ad8aa..a1e8b08 100644 --- a/src/TelegramPanel.Web/Services/DataSyncService.cs +++ b/src/TelegramPanel.Web/Services/DataSyncService.cs @@ -1,5 +1,7 @@ +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Services; using TelegramPanel.Core.Services.Telegram; @@ -12,12 +14,15 @@ namespace TelegramPanel.Web.Services; /// </summary> public class DataSyncService { + private static readonly SemaphoreSlim SyncGate = new(1, 1); + private readonly AccountManagementService _accountManagement; private readonly ChannelManagementService _channelManagement; private readonly GroupManagementService _groupManagement; private readonly IChannelService _channelService; private readonly IGroupService _groupService; private readonly AccountTelegramToolsService _telegramTools; + private readonly BatchTaskManagementService _taskManagement; private readonly IConfiguration _configuration; private readonly ILogger<DataSyncService> _logger; @@ -28,6 +33,7 @@ public class DataSyncService IChannelService channelService, IGroupService groupService, AccountTelegramToolsService telegramTools, + BatchTaskManagementService taskManagement, IConfiguration configuration, ILogger<DataSyncService> logger) { @@ -37,29 +43,144 @@ public class DataSyncService _channelService = channelService; _groupService = groupService; _telegramTools = telegramTools; + _taskManagement = taskManagement; _configuration = configuration; _logger = logger; } - public async Task<SyncSummary> SyncAllActiveAccountsAsync(CancellationToken cancellationToken) + public async Task<TrackedSyncResult> RunAllActiveAccountsTrackedAsync(string trigger, CancellationToken cancellationToken) { - var accounts = await _accountManagement.GetActiveAccountsAsync(); - return await SyncAccountsAsync(accounts, cancellationToken); + if (!await SyncGate.WaitAsync(0, cancellationToken)) + throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度"); + + BatchTask? task = null; + try + { + var accounts = (await _accountManagement.GetActiveAccountsAsync()) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + + task = await _taskManagement.CreateTaskAsync(new BatchTask + { + TaskType = BatchTaskTypes.AccountAutoSync, + Total = accounts.Count, + Config = BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: accounts.Count, + processedAccounts: 0, + failedAccounts: 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty<SyncFailureItem>(), + error: null) + }); + + await _taskManagement.StartTaskAsync(task.Id); + + var summary = await SyncAccountsAsync( + accounts, + cancellationToken, + progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(task.Id, progress.ProcessedAccounts, progress.FailedAccounts)); + + await _taskManagement.UpdateTaskProgressAsync(task.Id, summary.ProcessedAccounts, summary.FailedAccountsCount); + await _taskManagement.UpdateTaskConfigAsync( + task.Id, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: summary.TotalAccounts, + processedAccounts: summary.ProcessedAccounts, + failedAccounts: summary.FailedAccountsCount, + totalChannelsSynced: summary.TotalChannelsSynced, + totalGroupsSynced: summary.TotalGroupsSynced, + failures: summary.AccountFailures.Select(ToFailureItem).ToList(), + error: null)); + await _taskManagement.CompleteTaskAsync(task.Id, success: true); + + return new TrackedSyncResult(task.Id, summary); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (task != null) + { + var snapshot = await _taskManagement.GetTaskAsync(task.Id); + await _taskManagement.UpdateTaskConfigAsync( + task.Id, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: snapshot?.Total ?? 0, + processedAccounts: snapshot?.Completed ?? 0, + failedAccounts: snapshot?.Failed ?? 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty<SyncFailureItem>(), + error: "已取消")); + await _taskManagement.CompleteTaskAsync(task.Id, success: false); + } + + throw; + } + catch (Exception ex) + { + if (task != null) + { + var snapshot = await _taskManagement.GetTaskAsync(task.Id); + await _taskManagement.UpdateTaskConfigAsync( + task.Id, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: snapshot?.Total ?? 0, + processedAccounts: snapshot?.Completed ?? 0, + failedAccounts: snapshot?.Failed ?? 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty<SyncFailureItem>(), + error: ex.Message)); + await _taskManagement.CompleteTaskAsync(task.Id, success: false); + } + + throw; + } + finally + { + SyncGate.Release(); + } } - public async Task<SyncSummary> SyncAccountAsync(int accountId, CancellationToken cancellationToken) + public async Task<SyncSummary> SyncAllActiveAccountsAsync( + CancellationToken cancellationToken, + Func<SyncProgress, Task>? progressCallback = null) + { + var accounts = await _accountManagement.GetActiveAccountsAsync(); + return await SyncAccountsAsync(accounts, cancellationToken, progressCallback); + } + + public async Task<SyncSummary> SyncAccountAsync( + int accountId, + CancellationToken cancellationToken, + Func<SyncProgress, Task>? progressCallback = null) { var account = await _accountManagement.GetAccountAsync(accountId) ?? throw new InvalidOperationException($"账号不存在:{accountId}"); - return await SyncAccountsAsync(new[] { account }, cancellationToken); + return await SyncAccountsAsync(new[] { account }, cancellationToken, progressCallback); } - public async Task<SyncSummary> SyncAccountsAsync(IEnumerable<Account> accounts, CancellationToken cancellationToken) + public async Task<SyncSummary> SyncAccountsAsync( + IEnumerable<Account> accounts, + CancellationToken cancellationToken, + Func<SyncProgress, Task>? progressCallback = null) { var summary = new SyncSummary(); - var accountList = accounts.ToList(); + var accountList = accounts + .Where(x => x != null) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + + summary.TotalAccounts = accountList.Count; + var delayMs = _configuration.GetValue("Telegram:DefaultDelayMs", 2000); if (delayMs < 0) delayMs = 0; if (delayMs > 60000) delayMs = 60000; @@ -67,19 +188,20 @@ public class DataSyncService for (var index = 0; index < accountList.Count; index++) { var account = accountList[index]; + var accountFailed = false; cancellationToken.ThrowIfCancellationRequested(); try { - // 同步频道:仅同步“频道创建人=本账号”的频道 - var channelInfos = await _channelService.GetOwnedChannelsAsync(account.Id); + // 同步频道:拉取账号当前可见的全部频道,并记录该账号在频道中的角色关系。 + var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id); var keepChannelIds = new List<int>(capacity: channelInfos.Count); foreach (var channelInfo in channelInfos) { cancellationToken.ThrowIfCancellationRequested(); - var channel = new TelegramPanel.Data.Entities.Channel + var channel = new Channel { TelegramId = channelInfo.TelegramId, AccessHash = channelInfo.AccessHash, @@ -88,41 +210,65 @@ public class DataSyncService IsBroadcast = channelInfo.IsBroadcast, MemberCount = channelInfo.MemberCount, About = channelInfo.About, - CreatorAccountId = account.Id, + CreatorAccountId = channelInfo.IsCreator ? account.Id : null, CreatedAt = channelInfo.CreatedAt }; var saved = await _channelManagement.CreateOrUpdateChannelAsync(channel); keepChannelIds.Add(saved.Id); + await _channelManagement.UpsertAccountChannelAsync( + accountId: account.Id, + channelId: saved.Id, + isCreator: channelInfo.IsCreator, + isAdmin: channelInfo.IsAdmin, + syncedAtUtc: DateTime.UtcNow); + summary.TotalChannelsSynced++; } - // 同步群组:保持原逻辑(仅创建的群组) - var groups = await _groupService.GetOwnedGroupsAsync(account.Id); - foreach (var groupInfo in groups) + await _channelManagement.DeleteStaleAccountChannelsAsync(account.Id, keepChannelIds); + + // 同步群组:拉取账号当前可见的全部群组,并记录该账号在群组中的角色关系。 + var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id); + var keepGroupIds = new List<int>(capacity: groupInfos.Count); + + foreach (var groupInfo in groupInfos) { cancellationToken.ThrowIfCancellationRequested(); - var group = new TelegramPanel.Data.Entities.Group + var group = new Group { TelegramId = groupInfo.TelegramId, AccessHash = groupInfo.AccessHash, Title = groupInfo.Title, Username = groupInfo.Username, MemberCount = groupInfo.MemberCount, - About = null, - CreatorAccountId = account.Id + About = groupInfo.About, + CreatorAccountId = groupInfo.IsCreator ? account.Id : null, + CreatedAt = groupInfo.CreatedAt }; - await _groupManagement.CreateOrUpdateGroupAsync(group); + var saved = await _groupManagement.CreateOrUpdateGroupAsync(group); + keepGroupIds.Add(saved.Id); + + await _groupManagement.UpsertAccountGroupAsync( + accountId: account.Id, + groupId: saved.Id, + isCreator: groupInfo.IsCreator, + isAdmin: groupInfo.IsAdmin, + syncedAtUtc: DateTime.UtcNow); + summary.TotalGroupsSynced++; } + await _groupManagement.DeleteStaleAccountGroupsAsync(account.Id, keepGroupIds); + await _accountManagement.UpdateLastSyncTimeAsync(account.Id); } catch (Exception ex) { + accountFailed = true; _logger.LogDebug(ex, "Account sync failed (debug details): {AccountId}", account.Id); summary.AccountFailures.Add((account.Id, account.Phone, ex.Message)); @@ -175,6 +321,20 @@ public class DataSyncService _logger.LogWarning(statusEx, "Failed to update Telegram status for account {AccountId}", account.Id); } } + finally + { + summary.ProcessedAccounts++; + if (!accountFailed) + summary.SucceededAccounts++; + + if (progressCallback != null) + { + await progressCallback(new SyncProgress( + TotalAccounts: summary.TotalAccounts, + ProcessedAccounts: summary.ProcessedAccounts, + FailedAccounts: summary.FailedAccountsCount)); + } + } // 降速:同步多个账号时插入延迟,降低触发 FLOOD_WAIT 的概率 if (delayMs > 0 && index < accountList.Count - 1) @@ -187,10 +347,68 @@ public class DataSyncService return summary; } + private static SyncFailureItem ToFailureItem((int AccountId, string Phone, string Error) failure) + { + return new SyncFailureItem(failure.AccountId, failure.Phone, failure.Error); + } + + private static string BuildSyncTaskConfig( + string trigger, + int totalAccounts, + int processedAccounts, + int failedAccounts, + int totalChannelsSynced, + int totalGroupsSynced, + IReadOnlyCollection<SyncFailureItem> failures, + string? error) + { + var payload = new + { + trigger = string.IsNullOrWhiteSpace(trigger) ? "manual" : trigger.Trim(), + scope = "all_active_accounts", + includes = new[] + { + "visible_channels_sync", + "visible_groups_sync", + "lightweight_telegram_status_refresh_on_sync_error" + }, + excludes = new[] + { + "deep_telegram_status_probe", + "verification_code_collection" + }, + progress = new + { + totalAccounts, + processedAccounts, + failedAccounts + }, + result = new + { + totalChannelsSynced, + totalGroupsSynced + }, + failures = failures.Take(50).ToList(), + error + }; + + return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + } + + private sealed record SyncFailureItem(int AccountId, string Phone, string Error); + public sealed class SyncSummary { + public int TotalAccounts { get; set; } + public int ProcessedAccounts { get; set; } + public int SucceededAccounts { get; set; } + public int FailedAccountsCount => AccountFailures.Count; public int TotalChannelsSynced { get; set; } public int TotalGroupsSynced { get; set; } public List<(int AccountId, string Phone, string Error)> AccountFailures { get; } = new(); } + + public readonly record struct SyncProgress(int TotalAccounts, int ProcessedAccounts, int FailedAccounts); + + public readonly record struct TrackedSyncResult(int TaskId, SyncSummary Summary); } From 7dc1eea3c06bc059cb31a4abef4792e3cf43bb97 Mon Sep 17 00:00:00 2001 From: meoacgx <a1478882500@gmail.com> Date: Mon, 9 Mar 2026 01:28:09 +0800 Subject: [PATCH 3/6] feat: add category filters and sync action --- .../ServiceCollectionExtensions.cs | 1 + .../Services/ChannelManagementService.cs | 3 +- .../GroupCategoryManagementService.cs | 49 ++ .../Services/GroupManagementService.cs | 16 +- src/TelegramPanel.Data/AppDbContext.cs | 25 +- src/TelegramPanel.Data/Entities/Group.cs | 2 + .../Entities/GroupCategory.cs | 15 + ...60308172014_AddGroupCategories.Designer.cs | 731 ++++++++++++++++++ .../20260308172014_AddGroupCategories.cs | 74 ++ .../Migrations/AppDbContextModelSnapshot.cs | 43 ++ .../Repositories/ChannelRepository.cs | 51 +- .../Repositories/GroupCategoryRepository.cs | 29 + .../Repositories/GroupRepository.cs | 40 +- .../Repositories/IChannelRepository.cs | 1 + .../Repositories/IGroupCategoryRepository.cs | 11 + .../Repositories/IGroupRepository.cs | 1 + .../ServiceCollectionExtensions.cs | 1 + .../Dialogs/BatchSetChannelGroupDialog.razor | 6 +- .../Dialogs/BatchSetGroupCategoryDialog.razor | 40 + .../Components/Layout/NavMenu.razor | 3 +- .../Components/Pages/ChannelGroups.razor | 189 +++-- .../Components/Pages/Channels.razor | 37 +- .../Components/Pages/GroupCategories.razor | 341 ++++++++ .../Components/Pages/Groups.razor | 86 ++- .../Components/Pages/Settings.razor | 22 +- 25 files changed, 1707 insertions(+), 110 deletions(-) create mode 100644 src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs create mode 100644 src/TelegramPanel.Data/Entities/GroupCategory.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.cs create mode 100644 src/TelegramPanel.Data/Repositories/GroupCategoryRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IGroupCategoryRepository.cs create mode 100644 src/TelegramPanel.Web/Components/Dialogs/BatchSetGroupCategoryDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Pages/GroupCategories.razor 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<ChannelManagementService>(); services.AddScoped<ChannelGroupManagementService>(); services.AddScoped<GroupManagementService>(); + services.AddScoped<GroupCategoryManagementService>(); services.AddScoped<BatchTaskManagementService>(); services.AddScoped<BotManagementService>(); 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 /// </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) 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; + +/// <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); + } +} 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<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(); 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<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 导入场景可能为 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<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 => { 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<AccountGroup> AccountGroups { get; set; } = new List<AccountGroup>(); } 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; + +/// <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>(); +} 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 @@ +// <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 + } + } +} 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 +{ + /// <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"); + } + } +} 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<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 } } 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<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) 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; + +/// <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); + } +} 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<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) 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<Channel> Task<(IReadOnlyList<Channel> 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; + +/// <summary> +/// 群组分类仓储接口 +/// </summary> +public interface IGroupCategoryRepository : IRepository<GroupCategory> +{ + Task<GroupCategory?> 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<Group> Task<IEnumerable<Group>> GetByCreatorAccountAsync(int accountId); Task<(IReadOnlyList<Group> 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<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>(); 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 @@ <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> 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 + +<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)); + } +} 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 @@ <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"> 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 -<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; + } } 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 @@ <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 { 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 + +<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; + } +} 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 @@ <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) 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 @@ <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> From 3c32cfb07305f95f8f3a4e2be5add215e3056b93 Mon Sep 17 00:00:00 2001 From: meoacgx <a1478882500@gmail.com> Date: Mon, 9 Mar 2026 12:36:51 +0800 Subject: [PATCH 4/6] feat: enhance task center and chat account visibility --- .../Interfaces/IChannelService.cs | 12 +- .../Interfaces/IGroupService.cs | 17 +- .../Services/BatchTaskManagementService.cs | 54 +- .../Services/ChannelManagementService.cs | 37 + .../Services/GroupManagementService.cs | 37 + .../Telegram/AccountTelegramToolsService.cs | 107 ++- .../Services/Telegram/ChannelService.cs | 153 +++- .../Services/Telegram/GroupService.cs | 257 +++++- src/TelegramPanel.Data/AppDbContext.cs | 7 + ...181608_OptimizeChatListIndexes.Designer.cs | 739 ++++++++++++++++++ .../20260308181608_OptimizeChatListIndexes.cs | 54 ++ .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Repositories/AccountChannelRepository.cs | 36 + .../Repositories/AccountGroupRepository.cs | 36 + .../Repositories/AccountRepository.cs | 8 +- .../Repositories/BatchTaskRepository.cs | 20 + .../Repositories/ChannelRepository.cs | 65 +- .../Repositories/GroupRepository.cs | 55 +- .../Repositories/IAccountChannelRepository.cs | 3 + .../Repositories/IAccountGroupRepository.cs | 3 + .../Repositories/IBatchTaskRepository.cs | 1 + .../Repositories/Repository.cs | 4 +- .../ModuleContributions.cs | 84 +- .../AccountChannelMembershipsDialog.razor | 115 +++ .../AccountGroupMembershipsDialog.razor | 115 +++ .../Dialogs/AdminPasswordConfirmDialog.razor | 89 +++ .../Dialogs/ChannelDetailsDialog.razor | 120 +++ .../Dialogs/ChannelSystemAccountsDialog.razor | 134 ++++ .../Dialogs/CreateBatchTaskDialog.razor | 21 +- .../Dialogs/EditModuleTaskDialog.razor | 200 +++++ .../Dialogs/GroupDetailsDialog.razor | 120 +++ .../Dialogs/GroupSystemAccountsDialog.razor | 134 ++++ .../Components/Layout/NavMenu.razor | 1 + .../Components/Pages/Accounts.razor | 26 + .../Components/Pages/Channels.razor | 237 +++++- .../Components/Pages/GroupCreate.razor | 224 ++++++ .../Components/Pages/Groups.razor | 245 +++++- .../Components/Pages/Settings.razor | 36 +- .../Modules/BuiltIn/TaskCatalogModule.cs | 13 +- .../Modules/ModuleContributionRegistry.cs | 3 + .../Services/DataSyncService.cs | 174 +++-- .../ModuleTaskEditorComponentResolver.cs | 42 + .../UserChatActiveTaskRerunBuilder.cs | 120 +++ 43 files changed, 3811 insertions(+), 155 deletions(-) create mode 100644 src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs create mode 100644 src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Pages/GroupCreate.razor create mode 100644 src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs create mode 100644 src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs index 479d2c6..62a54d7 100644 --- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs +++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs @@ -20,7 +20,7 @@ public interface IChannelService /// <summary> /// 获取账号当前可见的全部频道(创建者/管理员/普通成员) /// </summary> - Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId); + Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default); /// <summary> /// 创建新频道 @@ -57,6 +57,16 @@ public interface IChannelService /// </summary> Task<bool> SetForwardingAllowedAsync(int accountId, long channelId, bool allowed); + /// <summary> + /// 退出频道或取消订阅。 + /// </summary> + Task<bool> LeaveChannelAsync(int accountId, long channelId); + + /// <summary> + /// 解散频道(需要频道创建者权限)。 + /// </summary> + Task<bool> DisbandChannelAsync(int accountId, long channelId); + /// <summary> /// 导出频道加入链接:公开频道返回 t.me 链接;私密频道导出邀请链接(需要权限)。 /// </summary> diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs index d952ce5..c0d939d 100644 --- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs +++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs @@ -12,16 +12,31 @@ public interface IGroupService /// </summary> Task<List<GroupInfo>> GetOwnedGroupsAsync(int accountId); + /// <summary> + /// 创建群组(超级群组) + /// </summary> + Task<GroupInfo> CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null); + /// <summary> /// 获取账号当前可见的全部群组(创建者/管理员/普通成员) /// </summary> - Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId); + Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default); /// <summary> /// 获取群组详情 /// </summary> Task<GroupInfo?> GetGroupInfoAsync(int accountId, long groupId); + /// <summary> + /// 退出群组。 + /// </summary> + Task<bool> LeaveGroupAsync(int accountId, long groupId); + + /// <summary> + /// 解散群组(超级群需要创建者权限;基础群暂不支持)。 + /// </summary> + Task<bool> DisbandGroupAsync(int accountId, long groupId); + /// <summary> /// 导出加入链接:公开群组返回 t.me 链接;否则导出邀请链接。 /// </summary> diff --git a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs index cbcff7e..c1542a2 100644 --- a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs +++ b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; @@ -9,10 +11,17 @@ namespace TelegramPanel.Core.Services; public class BatchTaskManagementService { private readonly IBatchTaskRepository _batchTaskRepository; + private readonly IConfiguration _configuration; + private readonly ILogger<BatchTaskManagementService> _logger; - public BatchTaskManagementService(IBatchTaskRepository batchTaskRepository) + public BatchTaskManagementService( + IBatchTaskRepository batchTaskRepository, + IConfiguration configuration, + ILogger<BatchTaskManagementService> logger) { _batchTaskRepository = batchTaskRepository; + _configuration = configuration; + _logger = logger; } public async Task<BatchTask?> GetTaskAsync(int id) @@ -40,6 +49,11 @@ public class BatchTaskManagementService return await _batchTaskRepository.GetRecentTasksAsync(count); } + public async Task<int> TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default) + { + return await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken); + } + public async Task<BatchTask> CreateTaskAsync(BatchTask task) { task.CreatedAt = DateTime.UtcNow; @@ -123,12 +137,13 @@ public class BatchTaskManagementService public async Task CompleteTaskAsync(int taskId, bool success = true) { var task = await _batchTaskRepository.GetByIdAsync(taskId); - if (task != null) - { - task.Status = success ? "completed" : "failed"; - task.CompletedAt = DateTime.UtcNow; - await _batchTaskRepository.UpdateAsync(task); - } + if (task == null) + return; + + task.Status = success ? "completed" : "failed"; + task.CompletedAt = DateTime.UtcNow; + await _batchTaskRepository.UpdateAsync(task); + await TrimHistoryTasksIfNeededAsync(); } public async Task DeleteTaskAsync(int id) @@ -139,4 +154,29 @@ public class BatchTaskManagementService await _batchTaskRepository.DeleteAsync(task); } } + + private async Task TrimHistoryTasksIfNeededAsync(CancellationToken cancellationToken = default) + { + var keepCount = GetHistoryRetentionLimit(); + if (keepCount <= 0) + return; + + var deletedCount = await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken); + if (deletedCount > 0) + { + _logger.LogInformation( + "Trimmed {DeletedCount} historical batch tasks, keepCount={KeepCount}", + deletedCount, + keepCount); + } + } + + private int GetHistoryRetentionLimit() + { + var rawValue = _configuration["BatchTasks:HistoryRetentionLimit"]; + if (!int.TryParse(rawValue, out var keepCount) || keepCount < 0) + return 0; + + return Math.Min(keepCount, 5000); + } } diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 783a6bb..15e901c 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -187,6 +187,43 @@ public class ChannelManagementService await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds); } + public async Task<IReadOnlyList<AccountChannel>> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _accountChannelRepository.GetByAccountAsync(accountId, cancellationToken); + } + + public async Task<IReadOnlyList<AccountChannel>> GetChannelAccountMembershipsAsync(int channelId, CancellationToken cancellationToken = default) + { + return await _accountChannelRepository.GetByChannelAsync(channelId, cancellationToken); + } + + public async Task RemoveAccountChannelAsync(int channelId, int accountId) + { + var channel = await _channelRepository.GetByIdAsync(channelId); + if (channel == null) + return; + + var hasRemainingRelations = channel.AccountChannels.Any(x => x.AccountId != accountId); + var creatorRemoved = channel.CreatorAccountId == accountId; + + await _accountChannelRepository.DeleteAsync(accountId, channelId); + + if (creatorRemoved) + channel.CreatorAccountId = null; + + if (!hasRemainingRelations && channel.CreatorAccountId == null) + { + await _channelRepository.DeleteAsync(channel); + return; + } + + if (!creatorRemoved) + return; + + channel.SyncedAt = DateTime.UtcNow; + await _channelRepository.UpdateAsync(channel); + } + /// <summary> /// 解析频道操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 53de5be..4a2f7f6 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -130,6 +130,43 @@ public class GroupManagementService await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds); } + public async Task<IReadOnlyList<AccountGroup>> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _accountGroupRepository.GetByAccountAsync(accountId, cancellationToken); + } + + public async Task<IReadOnlyList<AccountGroup>> GetGroupAccountMembershipsAsync(int groupId, CancellationToken cancellationToken = default) + { + return await _accountGroupRepository.GetByGroupAsync(groupId, cancellationToken); + } + + public async Task RemoveAccountGroupAsync(int groupId, int accountId) + { + var group = await _groupRepository.GetByIdAsync(groupId); + if (group == null) + return; + + var hasRemainingRelations = group.AccountGroups.Any(x => x.AccountId != accountId); + var creatorRemoved = group.CreatorAccountId == accountId; + + await _accountGroupRepository.DeleteAsync(accountId, groupId); + + if (creatorRemoved) + group.CreatorAccountId = null; + + if (!hasRemainingRelations && group.CreatorAccountId == null) + { + await _groupRepository.DeleteAsync(group); + return; + } + + if (!creatorRemoved) + return; + + group.SyncedAt = DateTime.UtcNow; + await _groupRepository.UpdateAsync(group); + } + /// <summary> /// 解析群组操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 05a0445..52ad472 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -50,7 +50,12 @@ public class AccountTelegramToolsService var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - var users = await client.Users_GetUsers(InputUser.Self); + var users = await ExecuteTelegramRequestAsync( + accountId, + "拉取账号资料", + () => client.Users_GetUsers(InputUser.Self), + cancellationToken, + resetClientOnTimeout: true); cancellationToken.ThrowIfCancellationRequested(); var self = users.OfType<User>().FirstOrDefault(); @@ -1409,10 +1414,22 @@ public class AccountTelegramToolsService try { - await client.ConnectAsync(); + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); cancellationToken.ThrowIfCancellationRequested(); if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - await client.LoginUserIfNeeded(reloginOnFailedResume: false); + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } } catch (Exception ex) { @@ -1433,6 +1450,70 @@ public class AccountTelegramToolsService return client; } + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func<Task> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + + private async Task<T> ExecuteTelegramRequestAsync<T>( + int accountId, + string operation, + Func<Task<T>> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + private int ResolveApiId(Account account) { if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) @@ -1492,7 +1573,12 @@ public class AccountTelegramToolsService UpdatesBase updates; try { - updates = await client.Channels_CreateChannel(title: title, about: about, broadcast: true); + updates = await ExecuteTelegramRequestAsync( + accountId, + "创建测试频道探测账号状态", + () => client.Channels_CreateChannel(title: title, about: about, broadcast: true), + cancellationToken, + resetClientOnTimeout: true); } catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase)) { @@ -1509,7 +1595,12 @@ public class AccountTelegramToolsService { // 立即删除,避免留下垃圾频道 var input = new InputChannel(channel.id, channel.access_hash); - await client.Channels_DeleteChannel(input); + await ExecuteTelegramRequestAsync( + accountId, + $"删除测试频道({channel.id})", + () => client.Channels_DeleteChannel(input), + cancellationToken, + resetClientOnTimeout: false); } catch (Exception ex) { @@ -1535,6 +1626,12 @@ public class AccountTelegramToolsService { var msg = ex.Message ?? string.Empty; + if (ex is TimeoutException + || msg.Contains("请求超时", StringComparison.OrdinalIgnoreCase) + || msg.Contains("timed out", StringComparison.OrdinalIgnoreCase) + || msg.Contains("timeout", StringComparison.OrdinalIgnoreCase)) + return ("请求超时", msg); + if (msg.Contains("EMAIL_HASH_EXPIRED", StringComparison.OrdinalIgnoreCase)) return ( "邮箱验证码已过期(EMAIL_HASH_EXPIRED)", diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs index a6f6f05..905b93d 100644 --- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs @@ -39,7 +39,7 @@ public class ChannelService : IChannelService foreach (var (_, chat) in dialogs.chats) { - if (chat is not Channel channel || !channel.IsActive) + if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel) continue; try @@ -98,7 +98,7 @@ public class ChannelService : IChannelService foreach (var (_, chat) in dialogs.chats) { - if (chat is not Channel channel || !channel.IsActive) + if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel) continue; try @@ -152,16 +152,25 @@ public class ChannelService : IChannelService return channels; } - public async Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId) + public async Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default) { - var client = await GetOrCreateConnectedClientAsync(accountId); + cancellationToken.ThrowIfCancellationRequested(); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); var channels = new List<ChannelInfo>(); - var dialogs = await client.Messages_GetAllDialogs(); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + cancellationToken, + resetClientOnTimeout: true); foreach (var (_, chat) in dialogs.chats) { - if (chat is not Channel channel || !channel.IsActive) + cancellationToken.ThrowIfCancellationRequested(); + + if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel) continue; try @@ -176,7 +185,12 @@ public class ChannelService : IChannelService string? about = null; try { - var fullChannel = await client.Channels_GetFullChannel(channel); + var fullChannel = await ExecuteTelegramRequestAsync( + accountId, + $"拉取频道详情({channel.id})", + () => client.Channels_GetFullChannel(channel), + cancellationToken, + resetClientOnTimeout: false); memberCount = fullChannel.full_chat.ParticipantsCount; about = (fullChannel.full_chat as ChannelFull)?.about; } @@ -725,6 +739,46 @@ public class ChannelService : IChannelService return true; } + public async Task<bool> LeaveChannelAsync(int accountId, long channelId) + { + try + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var channel = await GetChannelByIdAsync(client, channelId) + ?? throw new InvalidOperationException($"频道 {channelId} not found"); + + await ExecuteTelegramRequestAsync( + accountId, + $"退出频道({channel.id})", + () => client.LeaveChat(channel.ToInputPeer()), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + public async Task<bool> DisbandChannelAsync(int accountId, long channelId) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var channel = await GetChannelByIdAsync(client, channelId) + ?? throw new InvalidOperationException($"频道 {channelId} not found"); + + var input = new InputChannel(channel.id, channel.access_hash); + await ExecuteTelegramRequestAsync( + accountId, + $"解散频道({channel.id})", + () => client.Channels_DeleteChannel(input), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + public async Task<string> ExportJoinLinkAsync(int accountId, long channelId) { var client = await GetOrCreateConnectedClientAsync(accountId); @@ -891,8 +945,10 @@ public class ChannelService : IChannelService .FirstOrDefault(c => candidateSet?.Contains(c.id) ?? c.id == channelId); } - private async Task<Client> GetOrCreateConnectedClientAsync(int accountId) + private async Task<Client> GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var existing = _clientPool.GetClient(accountId); if (existing?.User != null) return existing; @@ -932,9 +988,22 @@ public class ChannelService : IChannelService try { - await client.ConnectAsync(); + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); + if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - await client.LoginUserIfNeeded(reloginOnFailedResume: false); + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } } catch (Exception ex) { @@ -955,6 +1024,70 @@ public class ChannelService : IChannelService return client; } + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func<Task> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + + private async Task<T> ExecuteTelegramRequestAsync<T>( + int accountId, + string operation, + Func<Task<T>> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + private int ResolveApiId(TelegramPanel.Data.Entities.Account account) { if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs index 040feb9..dd2743a 100644 --- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs @@ -36,15 +36,91 @@ public class GroupService : IGroupService return groups.Where(x => x.IsCreator).ToList(); } - public async Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId) + public async Task<GroupInfo> CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null) { var client = await GetOrCreateConnectedClientAsync(accountId); + _logger.LogInformation("Creating group '{Title}' for account {AccountId}", title, accountId); + + if (isPublic) + { + username = username?.Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(username)) + throw new InvalidOperationException("公开群组需要设置用户名"); + } + + UpdatesBase updates; + try + { + updates = await client.Channels_CreateChannel( + title: title, + about: about, + broadcast: false, + megagroup: true + ); + } + catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Telegram 返回 FROZEN_METHOD_INVALID:当前 ApiId/ApiHash 或账号被 Telegram 限制调用创建群组接口。" + + "建议:在【系统设置】更换全局 ApiId/ApiHash(推荐使用你自己在 my.telegram.org 申请的)," + + "并重新导入/重新登录生成 session 后再试。", + ex); + } + + var group = updates.Chats.Values.OfType<Channel>().FirstOrDefault(c => !c.IsChannel) + ?? throw new InvalidOperationException("群组创建失败"); + + if (isPublic && !string.IsNullOrWhiteSpace(username)) + { + var available = await client.Channels_CheckUsername(group, username); + if (!available) + throw new InvalidOperationException($"用户名 '{username}' 不可用"); + + try + { + await client.Channels_UpdateUsername(group, username); + } + catch (RpcException ex) when (ex.Message.Contains("USERNAME_NOT_MODIFIED", StringComparison.OrdinalIgnoreCase)) + { + // 视为成功 + } + } + + return new GroupInfo + { + TelegramId = group.id, + AccessHash = group.access_hash, + Title = group.title, + Username = isPublic ? username : group.MainUsername, + MemberCount = 0, + About = about, + CreatorAccountId = accountId, + IsCreator = true, + IsAdmin = true, + CreatedAt = group.date, + SyncedAt = DateTime.UtcNow + }; + } + + public async Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var groups = new List<GroupInfo>(); - var dialogs = await client.Messages_GetAllDialogs(); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + cancellationToken, + resetClientOnTimeout: true); foreach (var (_, chat) in dialogs.chats) { + cancellationToken.ThrowIfCancellationRequested(); + // 基础群组(Chat 类型,非 Channel) if (chat is Chat basicChat && basicChat.IsActive) { @@ -56,7 +132,12 @@ public class GroupService : IGroupService try { - var fullChat = await client.Messages_GetFullChat(basicChat.id); + var fullChat = await ExecuteTelegramRequestAsync( + accountId, + $"拉取基础群详情({basicChat.id})", + () => client.Messages_GetFullChat(basicChat.id), + cancellationToken, + resetClientOnTimeout: false); if (fullChat.full_chat is ChatFull cf) { about = ReadString(cf, about, "about", "About"); @@ -117,7 +198,12 @@ public class GroupService : IGroupService string? about = null; try { - var fullChannel = await client.Channels_GetFullChannel(channel); + var fullChannel = await ExecuteTelegramRequestAsync( + accountId, + $"拉取超级群详情({channel.id})", + () => client.Channels_GetFullChannel(channel), + cancellationToken, + resetClientOnTimeout: false); memberCount = fullChannel.full_chat.ParticipantsCount; about = (fullChannel.full_chat as ChannelFull)?.about; } @@ -330,6 +416,84 @@ public class GroupService : IGroupService return groups.FirstOrDefault(g => g.TelegramId == groupId); } + public async Task<bool> LeaveGroupAsync(int accountId, long groupId) + { + try + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + CancellationToken.None, + resetClientOnTimeout: true); + + var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId); + var chat = dialogs.chats.Values.FirstOrDefault(x => + x switch + { + Chat basicChat => basicChat.id == normalizedId, + Channel channel => !channel.IsChannel && channel.id == normalizedId, + _ => false + }); + + if (chat == null) + throw new InvalidOperationException($"群组 {groupId} not found"); + + await ExecuteTelegramRequestAsync( + accountId, + $"退出群组({normalizedId})", + () => client.LeaveChat(chat.ToInputPeer()), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + public async Task<bool> DisbandGroupAsync(int accountId, long groupId) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + CancellationToken.None, + resetClientOnTimeout: true); + + var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId); + var chat = dialogs.chats.Values.FirstOrDefault(x => + x switch + { + Chat basicChat => basicChat.id == normalizedId, + Channel channel => !channel.IsChannel && channel.id == normalizedId, + _ => false + }); + + if (chat == null) + throw new InvalidOperationException($"群组 {groupId} not found"); + + if (chat is Chat) + throw new InvalidOperationException("基础群暂不支持一键解散,请在 Telegram 客户端手动操作或先升级为超级群"); + + if (chat is not Channel megaGroup) + throw new InvalidOperationException("仅支持解散超级群"); + + var input = new InputChannel(megaGroup.id, megaGroup.access_hash); + await ExecuteTelegramRequestAsync( + accountId, + $"解散群组({normalizedId})", + () => client.Channels_DeleteChannel(input), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + public async Task<string> ExportJoinLinkAsync(int accountId, long groupId) { var client = await GetOrCreateConnectedClientAsync(accountId); @@ -468,8 +632,10 @@ public class GroupService : IGroupService throw new InvalidOperationException($"群组 {groupId} not found"); } - private async Task<WTelegram.Client> GetOrCreateConnectedClientAsync(int accountId) + private async Task<WTelegram.Client> GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var existing = _clientPool.GetClient(accountId); if (existing?.User != null) return existing; @@ -509,9 +675,22 @@ public class GroupService : IGroupService try { - await client.ConnectAsync(); + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); + if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - await client.LoginUserIfNeeded(reloginOnFailedResume: false); + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } } catch (Exception ex) { @@ -532,6 +711,70 @@ public class GroupService : IGroupService return client; } + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func<Task> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + + private async Task<T> ExecuteTelegramRequestAsync<T>( + int accountId, + string operation, + Func<Task<T>> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + private int ResolveApiId(TelegramPanel.Data.Entities.Account account) { if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 56169a7..0a3ab3b 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -75,6 +75,9 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); + entity.HasIndex(e => e.CreatorAccountId); + entity.HasIndex(e => e.GroupId); + entity.HasIndex(e => e.SyncedAt); entity.HasOne(e => e.CreatorAccount) .WithMany(a => a.Channels) @@ -93,6 +96,7 @@ public class AppDbContext : DbContext entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.AccountId, e.ChannelId }).IsUnique(); entity.HasIndex(e => e.ChannelId); + entity.HasIndex(e => new { e.ChannelId, e.IsCreator, e.IsAdmin }); entity.HasOne(e => e.Account) .WithMany(a => a.AccountChannels) @@ -126,6 +130,8 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); entity.HasIndex(e => e.CategoryId); + entity.HasIndex(e => e.CreatorAccountId); + entity.HasIndex(e => e.SyncedAt); entity.HasOne(e => e.CreatorAccount) .WithMany(a => a.Groups) @@ -144,6 +150,7 @@ public class AppDbContext : DbContext entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.AccountId, e.GroupId }).IsUnique(); entity.HasIndex(e => e.GroupId); + entity.HasIndex(e => new { e.GroupId, e.IsCreator, e.IsAdmin }); entity.HasOne(e => e.Account) .WithMany(a => a.AccountGroups) diff --git a/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs new file mode 100644 index 0000000..ea487e7 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs @@ -0,0 +1,739 @@ +// <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("20260308181608_OptimizeChatListIndexes")] + partial class OptimizeChatListIndexes + { + /// <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.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + + 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.HasIndex("GroupId", "IsCreator", "IsAdmin"); + + 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("SyncedAt"); + + 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("SyncedAt"); + + 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 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs new file mode 100644 index 0000000..17a9dd6 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// <inheritdoc /> + public partial class OptimizeChatListIndexes : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Groups_SyncedAt", + table: "Groups", + column: "SyncedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Channels_SyncedAt", + table: "Channels", + column: "SyncedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin", + table: "AccountGroups", + columns: new[] { "GroupId", "IsCreator", "IsAdmin" }); + + migrationBuilder.CreateIndex( + name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin", + table: "AccountChannels", + columns: new[] { "ChannelId", "IsCreator", "IsAdmin" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Groups_SyncedAt", + table: "Groups"); + + migrationBuilder.DropIndex( + name: "IX_Channels_SyncedAt", + table: "Channels"); + + migrationBuilder.DropIndex( + name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin", + table: "AccountGroups"); + + migrationBuilder.DropIndex( + name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin", + table: "AccountChannels"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 90970e3..9ce8981 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -159,6 +159,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("AccountId", "ChannelId") .IsUnique(); + b.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + b.ToTable("AccountChannels"); }); @@ -190,6 +192,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("AccountId", "GroupId") .IsUnique(); + b.HasIndex("GroupId", "IsCreator", "IsAdmin"); + b.ToTable("AccountGroups"); }); @@ -447,6 +451,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("GroupId"); + b.HasIndex("SyncedAt"); + b.HasIndex("TelegramId") .IsUnique(); @@ -527,6 +533,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("CreatorAccountId"); + b.HasIndex("SyncedAt"); + b.HasIndex("TelegramId") .IsUnique(); diff --git a/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs b/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs index 5013348..7f11b8a 100644 --- a/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs @@ -17,6 +17,32 @@ public class AccountChannelRepository : Repository<AccountChannel>, IAccountChan return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.ChannelId == channelId); } + public async Task<IReadOnlyList<AccountChannel>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.AccountId == accountId) + .Include(x => x.Channel) + .ThenInclude(x => x.Group) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToListAsync(cancellationToken); + } + + public async Task<IReadOnlyList<AccountChannel>> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.ChannelId == channelId) + .Include(x => x.Account) + .ThenInclude(x => x.Category) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToListAsync(cancellationToken); + } + public async Task UpsertAsync(AccountChannel link) { var existing = await GetAsync(link.AccountId, link.ChannelId); @@ -35,6 +61,16 @@ public class AccountChannelRepository : Repository<AccountChannel>, IAccountChan await _context.SaveChangesAsync(); } + public async Task DeleteAsync(int accountId, int channelId) + { + var existing = await GetAsync(accountId, channelId); + if (existing == null) + return; + + _dbSet.Remove(existing); + await _context.SaveChangesAsync(); + } + public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepChannelIds) { var keep = keepChannelIds.ToHashSet(); diff --git a/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs b/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs index 03f10a1..d14d2ad 100644 --- a/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs @@ -17,6 +17,32 @@ public class AccountGroupRepository : Repository<AccountGroup>, IAccountGroupRep return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.GroupId == groupId); } + public async Task<IReadOnlyList<AccountGroup>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.AccountId == accountId) + .Include(x => x.Group) + .ThenInclude(x => x.Category) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToListAsync(cancellationToken); + } + + public async Task<IReadOnlyList<AccountGroup>> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.GroupId == groupId) + .Include(x => x.Account) + .ThenInclude(x => x.Category) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToListAsync(cancellationToken); + } + public async Task UpsertAsync(AccountGroup link) { var existing = await GetAsync(link.AccountId, link.GroupId); @@ -35,6 +61,16 @@ public class AccountGroupRepository : Repository<AccountGroup>, IAccountGroupRep await _context.SaveChangesAsync(); } + public async Task DeleteAsync(int accountId, int groupId) + { + var existing = await GetAsync(accountId, groupId); + if (existing == null) + return; + + _dbSet.Remove(existing); + await _context.SaveChangesAsync(); + } + public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepGroupIds) { var keep = keepGroupIds.ToHashSet(); diff --git a/src/TelegramPanel.Data/Repositories/AccountRepository.cs b/src/TelegramPanel.Data/Repositories/AccountRepository.cs index 9efff0f..30774f7 100644 --- a/src/TelegramPanel.Data/Repositories/AccountRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountRepository.cs @@ -205,13 +205,17 @@ public class AccountRepository : Repository<Account>, IAccountRepository var linkedChannelPairs = await _context.Set<AccountChannel>() .AsNoTracking() + .Join( + _context.Set<Channel>().AsNoTracking().Where(x => x.IsBroadcast), + link => link.ChannelId, + channel => channel.Id, + (link, channel) => new { link.AccountId, link.ChannelId }) .Where(x => accountIds.Contains(x.AccountId)) - .Select(x => new { x.AccountId, x.ChannelId }) .ToListAsync(cancellationToken); var createdChannelPairs = await _context.Set<Channel>() .AsNoTracking() - .Where(x => x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value)) + .Where(x => x.IsBroadcast && x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value)) .Select(x => new { AccountId = x.CreatorAccountId!.Value, ChannelId = x.Id }) .ToListAsync(cancellationToken); diff --git a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs index 0438d58..e33169c 100644 --- a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs @@ -35,4 +35,24 @@ public class BatchTaskRepository : Repository<BatchTask>, IBatchTaskRepository .Take(count) .ToListAsync(); } + + public async Task<int> TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default) + { + if (keepCount <= 0) + return 0; + + var staleTasks = await _dbSet + .Where(t => t.Status == "completed" || t.Status == "failed") + .OrderByDescending(t => t.CreatedAt) + .ThenByDescending(t => t.Id) + .Skip(keepCount) + .ToListAsync(cancellationToken); + + if (staleTasks.Count == 0) + return 0; + + _dbSet.RemoveRange(staleTasks); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + return staleTasks.Count; + } } diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index 51023eb..97dd136 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -12,16 +12,13 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository { } - private IQueryable<Channel> BuildForViewQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search) + private IQueryable<Channel> BuildFilterQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search) { var query = _dbSet .AsNoTracking() - .Include(c => c.CreatorAccount) - .Include(c => c.Group) - .Include(c => c.AccountChannels) - .AsSplitQuery() .AsQueryable(); + query = query.Where(c => c.IsBroadcast); query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any()); membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant(); @@ -70,7 +67,25 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository || (c.Group != null && EF.Functions.Like(c.Group.Name, like))); } - return query.OrderByDescending(c => c.SyncedAt); + return query; + } + + private IQueryable<Channel> BuildPagedDetailQuery(IReadOnlyCollection<int> ids, int accountId) + { + IQueryable<Channel> query = _dbSet + .AsNoTracking() + .Where(c => ids.Contains(c.Id)) + .Include(c => c.CreatorAccount) + .Include(c => c.Group); + + if (accountId > 0) + { + query = query + .Include(c => c.AccountChannels.Where(x => x.AccountId == accountId)) + .AsSplitQuery(); + } + + return query; } public override async Task<Channel?> GetByIdAsync(int id) @@ -90,6 +105,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() + .Where(c => c.IsBroadcast) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -111,7 +127,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => c.CreatorAccountId != null) + .Where(c => c.IsBroadcast && c.CreatorAccountId != null) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -123,7 +139,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => c.CreatorAccountId == accountId) + .Where(c => c.IsBroadcast && c.CreatorAccountId == accountId) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -138,7 +154,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => links.Any(x => x.ChannelId == c.Id)) + .Where(c => c.IsBroadcast && links.Any(x => x.ChannelId == c.Id)) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -150,7 +166,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => c.GroupId == groupId) + .Where(c => c.IsBroadcast && c.GroupId == groupId) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -181,13 +197,34 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildForViewQuery(accountId, groupId, filterType, membershipRole, search); - var total = await query.CountAsync(cancellationToken); - var items = await query + var filteredQuery = BuildFilterQuery(accountId, groupId, filterType, membershipRole, search); + var total = await filteredQuery.CountAsync(cancellationToken); + + if (total == 0) + return (Array.Empty<Channel>(), 0); + + var pageIds = await filteredQuery + .OrderByDescending(c => c.SyncedAt) + .ThenByDescending(c => c.Id) .Skip(pageIndex * pageSize) .Take(pageSize) + .Select(c => c.Id) .ToListAsync(cancellationToken); - return (items, total); + if (pageIds.Count == 0) + return (Array.Empty<Channel>(), total); + + var orderMap = pageIds + .Select((id, index) => new { id, index }) + .ToDictionary(x => x.id, x => x.index); + + var items = await BuildPagedDetailQuery(pageIds, accountId) + .ToListAsync(cancellationToken); + + var orderedItems = items + .OrderBy(c => orderMap[c.Id]) + .ToList(); + + return (orderedItems, total); } } diff --git a/src/TelegramPanel.Data/Repositories/GroupRepository.cs b/src/TelegramPanel.Data/Repositories/GroupRepository.cs index 45a4606..9149b75 100644 --- a/src/TelegramPanel.Data/Repositories/GroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/GroupRepository.cs @@ -12,14 +12,10 @@ public class GroupRepository : Repository<Group>, IGroupRepository { } - private IQueryable<Group> BuildForViewQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search) + private IQueryable<Group> BuildFilterQuery(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()); @@ -70,7 +66,25 @@ public class GroupRepository : Repository<Group>, IGroupRepository || (g.Category != null && EF.Functions.Like(g.Category.Name, like))); } - return query.OrderByDescending(g => g.SyncedAt); + return query; + } + + private IQueryable<Group> BuildPagedDetailQuery(IReadOnlyCollection<int> ids, int accountId) + { + IQueryable<Group> query = _dbSet + .AsNoTracking() + .Where(g => ids.Contains(g.Id)) + .Include(g => g.CreatorAccount) + .Include(g => g.Category); + + if (accountId > 0) + { + query = query + .Include(g => g.AccountGroups.Where(x => x.AccountId == accountId)) + .AsSplitQuery(); + } + + return query; } public override async Task<Group?> GetByIdAsync(int id) @@ -131,13 +145,34 @@ public class GroupRepository : Repository<Group>, IGroupRepository if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildForViewQuery(accountId, categoryId, filterType, membershipRole, search); - var total = await query.CountAsync(cancellationToken); - var items = await query + var filteredQuery = BuildFilterQuery(accountId, categoryId, filterType, membershipRole, search); + var total = await filteredQuery.CountAsync(cancellationToken); + + if (total == 0) + return (Array.Empty<Group>(), 0); + + var pageIds = await filteredQuery + .OrderByDescending(g => g.SyncedAt) + .ThenByDescending(g => g.Id) .Skip(pageIndex * pageSize) .Take(pageSize) + .Select(g => g.Id) .ToListAsync(cancellationToken); - return (items, total); + if (pageIds.Count == 0) + return (Array.Empty<Group>(), total); + + var orderMap = pageIds + .Select((id, index) => new { id, index }) + .ToDictionary(x => x.id, x => x.index); + + var items = await BuildPagedDetailQuery(pageIds, accountId) + .ToListAsync(cancellationToken); + + var orderedItems = items + .OrderBy(g => orderMap[g.Id]) + .ToList(); + + return (orderedItems, total); } } diff --git a/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs index a9cc9a9..d00296e 100644 --- a/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs @@ -8,7 +8,10 @@ namespace TelegramPanel.Data.Repositories; public interface IAccountChannelRepository : IRepository<AccountChannel> { Task<AccountChannel?> GetAsync(int accountId, int channelId); + Task<IReadOnlyList<AccountChannel>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default); + Task<IReadOnlyList<AccountChannel>> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default); Task UpsertAsync(AccountChannel link); + Task DeleteAsync(int accountId, int channelId); Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepChannelIds); Task<int?> GetPreferredAdminAccountIdAsync(int channelId); } diff --git a/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs index 893c4b5..3d3e70c 100644 --- a/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs @@ -8,7 +8,10 @@ namespace TelegramPanel.Data.Repositories; public interface IAccountGroupRepository : IRepository<AccountGroup> { Task<AccountGroup?> GetAsync(int accountId, int groupId); + Task<IReadOnlyList<AccountGroup>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default); + Task<IReadOnlyList<AccountGroup>> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default); Task UpsertAsync(AccountGroup link); + Task DeleteAsync(int accountId, int groupId); Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepGroupIds); Task<int?> GetPreferredAdminAccountIdAsync(int groupId); } diff --git a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs index 5fdafd1..68c6a5a 100644 --- a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs @@ -10,4 +10,5 @@ public interface IBatchTaskRepository : IRepository<BatchTask> Task<IEnumerable<BatchTask>> GetByStatusAsync(string status); Task<IEnumerable<BatchTask>> GetRunningTasksAsync(); Task<IEnumerable<BatchTask>> GetRecentTasksAsync(int count = 20); + Task<int> TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Data/Repositories/Repository.cs b/src/TelegramPanel.Data/Repositories/Repository.cs index a64d413..34b93c8 100644 --- a/src/TelegramPanel.Data/Repositories/Repository.cs +++ b/src/TelegramPanel.Data/Repositories/Repository.cs @@ -59,7 +59,7 @@ public class Repository<T> : IRepository<T> where T : class : await _dbSet.CountAsync(predicate); } - private async Task SaveChangesWithSqliteLockRetryAsync() + protected async Task SaveChangesWithSqliteLockRetryAsync(CancellationToken cancellationToken = default) { // SQLite 在云端(容器/卷/后台任务)场景更容易遇到瞬时写锁;这里做有限重试来提升稳定性 const int maxAttempts = 5; @@ -69,7 +69,7 @@ public class Repository<T> : IRepository<T> where T : class { try { - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(cancellationToken); return; } catch (DbUpdateException ex) when (IsSqliteLock(ex)) diff --git a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs index 9e7cf39..bc0c9f7 100644 --- a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs +++ b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs @@ -85,16 +85,97 @@ public sealed class ModuleTaskDefinition public string? CreateRoute { get; set; } /// <summary> - /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback<ModuleTaskDraft>)\n /// </summary> + /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。 + /// 该组件需要支持参数: + /// - Draft (ModuleTaskDraft) + /// - DraftChanged (EventCallback<ModuleTaskDraft>) + /// 编辑场景下还应支持可选参数: + /// - InitialConfigJson (string?) + /// </summary> [JsonPropertyName("editorComponentType")] public string? EditorComponentType { get; set; } + /// <summary> + /// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。 + /// </summary> + [JsonPropertyName("taskCenter")] + public ModuleTaskCenterCapabilities TaskCenter { get; set; } = new(); + [JsonPropertyName("order")] public int Order { get; set; } = 0; } +public sealed class ModuleTaskCenterCapabilities +{ + [JsonPropertyName("canPause")] + public bool CanPause { get; set; } + + [JsonPropertyName("canResume")] + public bool CanResume { get; set; } + + [JsonPropertyName("canEdit")] + public bool CanEdit { get; set; } + + [JsonPropertyName("canRerun")] + public bool CanRerun { get; set; } + + /// <summary> + /// 编辑时使用的组件类型;为空时回退到 EditorComponentType。 + /// </summary> + [JsonPropertyName("editComponentType")] + public string? EditComponentType { get; set; } + + /// <summary> + /// 若任务仍在运行,打开编辑器前是否自动先暂停。 + /// </summary> + [JsonPropertyName("autoPauseBeforeEdit")] + public bool AutoPauseBeforeEdit { get; set; } +} + public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError); +public sealed class ModuleTaskSnapshot +{ + [JsonPropertyName("taskId")] + public int TaskId { get; set; } + + [JsonPropertyName("taskType")] + public string TaskType { get; set; } = ""; + + [JsonPropertyName("status")] + public string Status { get; set; } = ""; + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("completed")] + public int Completed { get; set; } + + [JsonPropertyName("failed")] + public int Failed { get; set; } + + [JsonPropertyName("config")] + public string? Config { get; set; } +} + +public sealed class ModuleTaskCreateRequest +{ + [JsonPropertyName("taskType")] + public string TaskType { get; set; } = ""; + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("config")] + public string? Config { get; set; } +} + +public interface IModuleTaskRerunBuilder +{ + string TaskType { get; } + ModuleTaskCreateRequest Build(ModuleTaskSnapshot task); +} + public interface IModuleApiProvider { IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context); @@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost Task<bool> IsStillRunningAsync(CancellationToken cancellationToken); Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken); } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor new file mode 100644 index 0000000..5393251 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor @@ -0,0 +1,115 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ChannelManagementService ChannelManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Data.Entities + +<MudDialog> + <DialogContent> + @if (loading) + { + <MudProgressLinear Indeterminate="true" /> + } + else + { + <MudStack Spacing="2"> + <MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个频道</MudText> + + @if (memberships.Count == 0) + { + <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该账号当前没有已同步的频道记录</MudAlert> + } + else + { + <MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"> + <HeaderContent> + <MudTh>频道名称</MudTh> + <MudTh>用户名</MudTh> + <MudTh>角色</MudTh> + <MudTh>频道分类</MudTh> + <MudTh>成员数</MudTh> + <MudTh>最后同步</MudTh> + </HeaderContent> + <RowTemplate> + <MudTd DataLabel="频道名称">@context.Channel.Title</MudTd> + <MudTd DataLabel="用户名">@FormatUsername(context.Channel.Username)</MudTd> + <MudTd DataLabel="角色"> + <MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip> + </MudTd> + <MudTd DataLabel="频道分类">@(context.Channel.Group?.Name ?? "未分类")</MudTd> + <MudTd DataLabel="成员数">@context.Channel.MemberCount</MudTd> + <MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd> + </RowTemplate> + </MudTable> + } + </MudStack> + } + </DialogContent> + <DialogActions> + <MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton> + </DialogActions> +</MudDialog> + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int AccountId { get; set; } + + private bool loading = true; + private List<AccountChannel> memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await ChannelManagement.GetAccountChannelMembershipsAsync(AccountId)) + .Where(x => x.Channel != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号频道关系失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountChannel membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountChannel membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private void Close() + { + MudDialog.Close(); + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor new file mode 100644 index 0000000..83034aa --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor @@ -0,0 +1,115 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject GroupManagementService GroupManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Data.Entities + +<MudDialog> + <DialogContent> + @if (loading) + { + <MudProgressLinear Indeterminate="true" /> + } + else + { + <MudStack Spacing="2"> + <MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个群组</MudText> + + @if (memberships.Count == 0) + { + <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该账号当前没有已同步的群组记录</MudAlert> + } + else + { + <MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"> + <HeaderContent> + <MudTh>群组名称</MudTh> + <MudTh>用户名</MudTh> + <MudTh>角色</MudTh> + <MudTh>群组分类</MudTh> + <MudTh>成员数</MudTh> + <MudTh>最后同步</MudTh> + </HeaderContent> + <RowTemplate> + <MudTd DataLabel="群组名称">@context.Group.Title</MudTd> + <MudTd DataLabel="用户名">@FormatUsername(context.Group.Username)</MudTd> + <MudTd DataLabel="角色"> + <MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip> + </MudTd> + <MudTd DataLabel="群组分类">@(context.Group.Category?.Name ?? "未分类")</MudTd> + <MudTd DataLabel="成员数">@context.Group.MemberCount</MudTd> + <MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd> + </RowTemplate> + </MudTable> + } + </MudStack> + } + </DialogContent> + <DialogActions> + <MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton> + </DialogActions> +</MudDialog> + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int AccountId { get; set; } + + private bool loading = true; + private List<AccountGroup> memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await GroupManagement.GetAccountGroupMembershipsAsync(AccountId)) + .Where(x => x.Group != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号群组关系失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountGroup membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountGroup membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private void Close() + { + MudDialog.Close(); + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor new file mode 100644 index 0000000..0db010b --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor @@ -0,0 +1,89 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject TelegramPanel.Web.Services.AdminCredentialStore CredentialStore + +<MudDialog> + <DialogContent> + <MudStack Spacing="2"> + <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined"> + @Prompt + </MudAlert> + + <MudTextField @bind-Value="password" + Label="后台密码" + Variant="Variant.Outlined" + InputType="@passwordInputType" + Adornment="Adornment.End" + AdornmentIcon="@passwordAdornmentIcon" + OnAdornmentClick="TogglePasswordVisibility" + Immediate="true" + Required="true" + AutoFocus="true" /> + + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + <MudAlert Severity="Severity.Error" Dense="true">@errorMessage</MudAlert> + } + </MudStack> + </DialogContent> + <DialogActions> + <MudButton Variant="Variant.Text" Color="Color.Default" Disabled="@submitting" OnClick="Cancel">取消</MudButton> + <MudButton Variant="Variant.Filled" Color="Color.Error" Disabled="@submitting" OnClick="ConfirmAsync">@ConfirmText</MudButton> + </DialogActions> +</MudDialog> + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public string Prompt { get; set; } = "此操作需要输入后台密码确认"; + [Parameter] public string ConfirmText { get; set; } = "确认"; + + private string password = string.Empty; + private string? errorMessage; + private bool submitting; + private bool showPassword; + + private InputType passwordInputType => showPassword ? InputType.Text : InputType.Password; + private string passwordAdornmentIcon => showPassword ? Icons.Material.Filled.VisibilityOff : Icons.Material.Filled.Visibility; + + private void TogglePasswordVisibility() + { + showPassword = !showPassword; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task ConfirmAsync() + { + if (submitting) + return; + + errorMessage = null; + var value = (password ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + errorMessage = "请输入后台密码"; + return; + } + + submitting = true; + try + { + var ok = await CredentialStore.ValidateAsync(CredentialStore.Username, value); + if (!ok) + { + errorMessage = "后台密码错误"; + return; + } + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + errorMessage = $"校验失败:{ex.Message}"; + } + finally + { + submitting = false; + } + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor index 3559018..ad366dc 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor @@ -4,6 +4,7 @@ @inject IChannelService ChannelService @inject ISnackbar Snackbar @inject IDialogService DialogService +@using TelegramPanel.Core.Utils @using TelegramPanel.Data.Entities <MudDialog> @@ -85,6 +86,52 @@ <MudDivider /> + <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> + <MudText Typo="Typo.h6">本系统账号</MudText> + <MudSpacer /> + <MudButton Variant="Variant.Text" Color="Color.Primary" Disabled="@systemAccountsLoading" OnClick="ShowSystemAccounts"> + 查看完整列表 + </MudButton> + <MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" + Disabled="@systemAccountsLoading" OnClick="LoadSystemAccounts"> + 刷新 + </MudButton> + </MudStack> + + @if (systemAccountsLoading) + { + <MudProgressLinear Indeterminate="true" /> + } + else if (systemAccounts.Count == 0) + { + <MudText Typo="Typo.body2" Color="Color.Secondary">暂无本系统账号关联</MudText> + } + else + { + <MudTable Items="@systemAccounts" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"> + <HeaderContent> + <MudTh>手机号</MudTh> + <MudTh>昵称</MudTh> + <MudTh>用户名</MudTh> + <MudTh>状态</MudTh> + <MudTh>角色</MudTh> + </HeaderContent> + <RowTemplate> + <MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd> + <MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd> + <MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd> + <MudTd DataLabel="状态"> + <MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip> + </MudTd> + <MudTd DataLabel="角色"> + <MudChip T="string" Size="Size.Small" Color="@GetMembershipColor(context)">@GetMembershipLabel(context)</MudChip> + </MudTd> + </RowTemplate> + </MudTable> + } + + <MudDivider /> + <MudText Typo="Typo.h6">踢出成员</MudText> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true"> 输入 @@用户名 执行踢出;“非永久”会使用短时间封禁达到踢出效果,随后自动解除,方便对方重新加入。 @@ -130,6 +177,8 @@ private bool adminsLoading; private List<ChannelAdminInfo> admins = new(); + private bool systemAccountsLoading; + private List<AccountChannel> systemAccounts = new(); private string kickUsername = ""; private bool kickPermanent; @@ -139,6 +188,7 @@ { await LoadChannel(); await LoadAdmins(); + await LoadSystemAccounts(); } private async Task LoadChannel() @@ -202,6 +252,37 @@ } } + private async Task LoadSystemAccounts() + { + systemAccountsLoading = true; + try + { + systemAccounts = (await ChannelManagement.GetChannelAccountMembershipsAsync(ChannelDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + systemAccounts.Clear(); + Snackbar.Add($"加载本系统账号失败:{ex.Message}", Severity.Error); + } + finally + { + systemAccountsLoading = false; + } + } + + private Task ShowSystemAccounts() + { + var parameters = new DialogParameters { ["ChannelDbId"] = ChannelDbId }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show<ChannelSystemAccountsDialog>("本系统账号", parameters, options); + return Task.CompletedTask; + } + private async Task KickUser() { if (channel == null) @@ -243,9 +324,48 @@ { await LoadChannel(); await LoadAdmins(); + await LoadSystemAccounts(); } } + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetMembershipLabel(AccountChannel membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetMembershipColor(AccountChannel membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + private void Close() { MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor new file mode 100644 index 0000000..0771ef6 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor @@ -0,0 +1,134 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ChannelManagementService ChannelManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Core.Utils +@using TelegramPanel.Data.Entities + +<MudDialog> + <DialogContent> + @if (loading) + { + <MudProgressLinear Indeterminate="true" /> + } + else + { + <MudStack Spacing="2"> + <MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个本系统账号</MudText> + + @if (memberships.Count == 0) + { + <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该频道当前没有已同步的本系统账号关联</MudAlert> + } + else + { + <MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"> + <HeaderContent> + <MudTh>手机号</MudTh> + <MudTh>昵称</MudTh> + <MudTh>用户名</MudTh> + <MudTh>账号分类</MudTh> + <MudTh>状态</MudTh> + <MudTh>角色</MudTh> + <MudTh>最后同步</MudTh> + </HeaderContent> + <RowTemplate> + <MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd> + <MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd> + <MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd> + <MudTd DataLabel="账号分类">@(context.Account?.Category?.Name ?? "未分类")</MudTd> + <MudTd DataLabel="状态"> + <MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip> + </MudTd> + <MudTd DataLabel="角色"> + <MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip> + </MudTd> + <MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd> + </RowTemplate> + </MudTable> + } + </MudStack> + } + </DialogContent> + <DialogActions> + <MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton> + </DialogActions> +</MudDialog> + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int ChannelDbId { get; set; } + + private bool loading = true; + private List<AccountChannel> memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await ChannelManagement.GetChannelAccountMembershipsAsync(ChannelDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载频道关联账号失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountChannel membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountChannel membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor index 3d1e684..23f407d 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -204,11 +204,7 @@ if (!Contributions.TaskTypeToDefinition.TryGetValue(opt.TaskType, out var reg)) return; - var typeName = opt.EditorComponentType.Trim(); - var moduleTypeName = NormalizeModuleTypeName(typeName); - editorComponentType = - Type.GetType(typeName, throwOnError: false, ignoreCase: false) - ?? reg.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); + editorComponentType = ModuleTaskEditorComponentResolver.ResolveCreateEditor(reg); if (editorComponentType == null) return; @@ -224,21 +220,6 @@ { editorDraft = draft; } - - private static string NormalizeModuleTypeName(string typeName) - { - typeName = (typeName ?? "").Trim(); - if (typeName.Length == 0) - return typeName; - - // 兼容 AssemblyQualifiedName:Assembly.GetType 只接受纯 FullName - var comma = typeName.IndexOf(',', StringComparison.Ordinal); - if (comma > 0) - return typeName.Substring(0, comma).Trim(); - - return typeName; - } - private async Task Create() { if (creating) diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor new file mode 100644 index 0000000..96366af --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor @@ -0,0 +1,200 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject BatchTaskManagementService TaskManagement +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules +@using TelegramPanel.Web.Services +@using System.Reflection + +<MudDialog> + <DialogContent> + @if (loading) + { + <MudStack Spacing="2"> + <MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" /> + <MudText Typo="Typo.caption">正在加载任务配置...</MudText> + </MudStack> + } + else if (!string.IsNullOrWhiteSpace(loadError)) + { + <MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Dense="true"> + @loadError + </MudAlert> + } + else if (editorComponentType == null) + { + <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true"> + 该任务未提供可编辑组件。 + </MudAlert> + } + else + { + <MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true"> + 编辑后会更新当前任务配置。若该任务已完成或失败,可继续使用“重新运行”创建新任务执行。 + </MudAlert> + + <DynamicComponent Type="editorComponentType" Parameters="editorParameters" /> + + @if (!string.IsNullOrWhiteSpace(draft.ValidationError)) + { + <MudText Typo="Typo.caption" Color="Color.Error">@draft.ValidationError</MudText> + } + } + </DialogContent> + <DialogActions> + <MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@saving">关闭</MudButton> + <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Save" Disabled="@(saving || !CanSave)"> + @if (saving) + { + <MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" /> + <span>保存中...</span> + } + else + { + <span>保存配置</span> + } + </MudButton> + </DialogActions> +</MudDialog> + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int TaskId { get; set; } + + private BatchTask? task; + private RegisteredTaskDefinition? taskDefinition; + private Type? editorComponentType; + private Dictionary<string, object?> editorParameters = new(); + private ModuleTaskDraft draft = new(0, null, false, null); + private bool loading = true; + private bool saving; + private string? loadError; + + private bool CanSave => + task != null + && taskDefinition != null + && taskDefinition.Definition.TaskCenter.CanEdit + && editorComponentType != null + && !string.Equals(GetDisplayStatus(task), "running", StringComparison.OrdinalIgnoreCase) + && draft.CanSubmit + && !string.IsNullOrWhiteSpace(draft.Config); + + protected override async Task OnInitializedAsync() + { + try + { + if (TaskId <= 0) + { + loadError = "任务不存在或参数无效。"; + return; + } + + task = await TaskManagement.GetTaskAsync(TaskId); + if (task == null) + { + loadError = "任务不存在或已被删除。"; + return; + } + + if (!Contributions.TaskTypeToDefinition.TryGetValue((task.TaskType ?? string.Empty).Trim(), out var reg)) + { + loadError = "未找到该任务类型的定义,无法编辑。"; + return; + } + + taskDefinition = reg; + if (!reg.Definition.TaskCenter.CanEdit) + { + loadError = "该任务当前不支持编辑。"; + return; + } + + if (string.Equals(GetDisplayStatus(task), "running", StringComparison.OrdinalIgnoreCase)) + { + loadError = "该任务正在执行中,请先暂停后再编辑。"; + return; + } + + editorComponentType = ModuleTaskEditorComponentResolver.ResolveEditEditor(reg); + if (editorComponentType == null) + return; + + editorParameters = BuildEditorParameters(editorComponentType, task); + } + finally + { + loading = false; + } + } + + private Dictionary<string, object?> BuildEditorParameters(Type componentType, BatchTask currentTask) + { + var parameters = new Dictionary<string, object?> + { + ["Draft"] = draft, + ["DraftChanged"] = EventCallback.Factory.Create<ModuleTaskDraft>(this, OnDraftChanged) + }; + + AddOptionalParameter(parameters, componentType, "InitialConfigJson", currentTask.Config); + AddOptionalParameter(parameters, componentType, "TaskId", currentTask.Id); + return parameters; + } + + private static void AddOptionalParameter(Dictionary<string, object?> parameters, Type componentType, string parameterName, object? value) + { + var property = componentType.GetProperty(parameterName, BindingFlags.Public | BindingFlags.Instance); + if (property == null) + return; + + parameters[parameterName] = value; + } + + private static string GetDisplayStatus(BatchTask task) + { + return task.CompletedAt.HasValue + ? "completed" + : (task.Status ?? string.Empty).Trim().ToLowerInvariant(); + } + + private void OnDraftChanged(ModuleTaskDraft value) + { + draft = value; + } + + private async Task Save() + { + if (!CanSave || task == null || saving) + return; + + bool? confirm = await DialogService.ShowMessageBox( + "确认保存", + $"将更新任务 #{task.Id} 的配置,是否继续?", + yesText: "保存", cancelText: "取消"); + + if (confirm != true) + return; + + saving = true; + try + { + var config = draft.Config?.Trim(); + await TaskManagement.UpdateTaskDraftAsync(task.Id, draft.Total < 0 ? 0 : draft.Total, config); + Snackbar.Add($"任务 #{task.Id} 配置已更新", Severity.Success); + MudDialog.Close(DialogResult.Ok(task.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + finally + { + saving = false; + } + } + + private void Cancel() => MudDialog.Close(); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor index 92b3060..5d4fe97 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor @@ -3,7 +3,9 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Interfaces.IGroupService GroupService @inject ISnackbar Snackbar +@inject IDialogService DialogService @inject IJSRuntime JS +@using TelegramPanel.Core.Utils @using TelegramPanel.Data.Entities @using TelegramPanel.Core.Models @@ -83,6 +85,52 @@ </RowTemplate> </MudTable> } + + <MudDivider /> + + <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> + <MudText Typo="Typo.h6">本系统账号</MudText> + <MudSpacer /> + <MudButton Variant="Variant.Text" Color="Color.Primary" Disabled="@systemAccountsLoading" OnClick="ShowSystemAccounts"> + 查看完整列表 + </MudButton> + <MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" + Disabled="@systemAccountsLoading" OnClick="LoadSystemAccounts"> + 刷新 + </MudButton> + </MudStack> + + @if (systemAccountsLoading) + { + <MudProgressLinear Indeterminate="true" /> + } + else if (systemAccounts.Count == 0) + { + <MudText Typo="Typo.body2" Color="Color.Secondary">暂无本系统账号关联</MudText> + } + else + { + <MudTable Items="@systemAccounts" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"> + <HeaderContent> + <MudTh>手机号</MudTh> + <MudTh>昵称</MudTh> + <MudTh>用户名</MudTh> + <MudTh>状态</MudTh> + <MudTh>角色</MudTh> + </HeaderContent> + <RowTemplate> + <MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd> + <MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd> + <MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd> + <MudTd DataLabel="状态"> + <MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip> + </MudTd> + <MudTd DataLabel="角色"> + <MudChip T="string" Size="Size.Small" Color="@GetMembershipColor(context)">@GetMembershipLabel(context)</MudChip> + </MudTd> + </RowTemplate> + </MudTable> + } </MudStack> } </DialogContent> @@ -107,11 +155,14 @@ private bool adminsLoading; private List<ChannelAdminInfo> admins = new(); + private bool systemAccountsLoading; + private List<AccountGroup> systemAccounts = new(); protected override async Task OnInitializedAsync() { await LoadGroup(); await LoadAdmins(); + await LoadSystemAccounts(); } private async Task LoadGroup() @@ -175,6 +226,37 @@ } } + private async Task LoadSystemAccounts() + { + systemAccountsLoading = true; + try + { + systemAccounts = (await GroupManagement.GetGroupAccountMembershipsAsync(GroupDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + systemAccounts.Clear(); + Snackbar.Add($"加载本系统账号失败:{ex.Message}", Severity.Error); + } + finally + { + systemAccountsLoading = false; + } + } + + private Task ShowSystemAccounts() + { + var parameters = new DialogParameters { ["GroupDbId"] = GroupDbId }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show<GroupSystemAccountsDialog>("本系统账号", parameters, options); + return Task.CompletedTask; + } + private async Task CopyLink() { if (group == null) @@ -198,6 +280,44 @@ } } + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetMembershipLabel(AccountGroup membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetMembershipColor(AccountGroup membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + private void Close() { MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor new file mode 100644 index 0000000..92bfc04 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor @@ -0,0 +1,134 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject GroupManagementService GroupManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Core.Utils +@using TelegramPanel.Data.Entities + +<MudDialog> + <DialogContent> + @if (loading) + { + <MudProgressLinear Indeterminate="true" /> + } + else + { + <MudStack Spacing="2"> + <MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个本系统账号</MudText> + + @if (memberships.Count == 0) + { + <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该群组当前没有已同步的本系统账号关联</MudAlert> + } + else + { + <MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"> + <HeaderContent> + <MudTh>手机号</MudTh> + <MudTh>昵称</MudTh> + <MudTh>用户名</MudTh> + <MudTh>账号分类</MudTh> + <MudTh>状态</MudTh> + <MudTh>角色</MudTh> + <MudTh>最后同步</MudTh> + </HeaderContent> + <RowTemplate> + <MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd> + <MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd> + <MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd> + <MudTd DataLabel="账号分类">@(context.Account?.Category?.Name ?? "未分类")</MudTd> + <MudTd DataLabel="状态"> + <MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip> + </MudTd> + <MudTd DataLabel="角色"> + <MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip> + </MudTd> + <MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd> + </RowTemplate> + </MudTable> + } + </MudStack> + } + </DialogContent> + <DialogActions> + <MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton> + </DialogActions> +</MudDialog> + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int GroupDbId { get; set; } + + private bool loading = true; + private List<AccountGroup> memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await GroupManagement.GetGroupAccountMembershipsAsync(GroupDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载群组关联账号失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountGroup membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountGroup membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index 0cd9be2..9352e23 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -20,6 +20,7 @@ <MudNavGroup Title="群组管理" Icon="@Icons.Material.Filled.Group"> <MudNavLink Href="/groups" Icon="@Icons.Material.Filled.List">群组列表</MudNavLink> + <MudNavLink Href="/groups/create" Icon="@Icons.Material.Filled.Add">创建群组</MudNavLink> <MudNavLink Href="/groups/categories" Icon="@Icons.Material.Filled.Folder">群组分类</MudNavLink> </MudNavGroup> diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 7fdbef7..82baa4c 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -115,6 +115,7 @@ <MudTh>频道/群组</MudTh> <MudTh>状态</MudTh> <MudTh>Telegram 状态</MudTh> + <MudTh>注册时间</MudTh> <MudTh>最后数据同步</MudTh> <MudTh Style="min-width: 180px;">操作</MudTh> </HeaderContent> @@ -155,6 +156,7 @@ <MudChip T="string" Size="Size.Small" Color="Color.Default">未检测</MudChip> } </MudTd> + <MudTd DataLabel="注册时间"><PanelTime Value="context.CreatedAt" /></MudTd> <MudTd DataLabel="最后数据同步"><PanelTime Value="context.LastSyncAt" /></MudTd> <MudTd DataLabel="操作"> <MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;"> @@ -168,6 +170,8 @@ <MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true"> <MudMenuItem Icon="@Icons.Material.Filled.GroupAdd" Disabled="@loading" OnClick="@(() => OpenJoinSubscribe(context))">加群/订阅</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" Disabled="@loading" OnClick="@(() => OpenLeaveUnsubscribe(context))">退群/退订</MudMenuItem> + <MudMenuItem Icon="@Icons.Material.Filled.Campaign" Disabled="@loading" OnClick="@(() => OpenAccountChannels(context))">查看加入的频道</MudMenuItem> + <MudMenuItem Icon="@Icons.Material.Filled.Forum" Disabled="@loading" OnClick="@(() => OpenAccountGroups(context))">查看加入的群组</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.MarkEmailUnread" Disabled="@loading" OnClick="@(() => OpenSystemInbox(context))">系统通知(验证码)</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Devices" Disabled="@loading" OnClick="@(() => OpenDevices(context))">在线设备</MudMenuItem> <MudDivider /> @@ -322,6 +326,28 @@ await ReloadTable(); } + private Task OpenAccountChannels(Account account) + { + var parameters = new DialogParameters + { + ["AccountId"] = account.Id + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + DialogService.Show<TelegramPanel.Web.Components.Dialogs.AccountChannelMembershipsDialog>("加入的频道", parameters, options); + return Task.CompletedTask; + } + + private Task OpenAccountGroups(Account account) + { + var parameters = new DialogParameters + { + ["AccountId"] = account.Id + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + DialogService.Show<TelegramPanel.Web.Components.Dialogs.AccountGroupMembershipsDialog>("加入的群组", parameters, options); + return Task.CompletedTask; + } + private async Task OpenChangeTwoFactorPassword(Account account) { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/Channels.razor b/src/TelegramPanel.Web/Components/Pages/Channels.razor index 320395f..3bf8715 100644 --- a/src/TelegramPanel.Web/Components/Pages/Channels.razor +++ b/src/TelegramPanel.Web/Components/Pages/Channels.razor @@ -76,6 +76,9 @@ <MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="BatchExportInvitesSelected">批量导出邀请链接(已选)</MudMenuItem> <MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetAdminsSelected">批量设置管理员(已选)</MudMenuItem> <MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分类(已选)</MudMenuItem> + <MudDivider /> + <MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="LeaveSelectedChannels">批量退出频道(已选)</MudMenuItem> + <MudMenuItem Disabled="@(selectedChannels.Count == 0)" Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="DisbandSelectedChannels">批量解散频道(已选)</MudMenuItem> </MudMenu> @if (selectedChannels.Count > 0) @@ -174,8 +177,11 @@ <MudMenuItem Icon="@Icons.Material.Filled.PersonAdd" OnClick="@(async () => await OpenInvite(context))">邀请成员</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.AdminPanelSettings" OnClick="@(async () => await OpenSetAdmins(context))">设置管理员</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.PersonRemove" OnClick="@(async () => await KickUser(context))">踢人</MudMenuItem> + <MudMenuItem Icon="@Icons.Material.Filled.People" OnClick="@(async () => await ShowSystemAccounts(context))">本系统账号</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyLinkAsync(context))">复制链接</MudMenuItem> <MudDivider /> + <MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" OnClick="@(async () => await LeaveChannel(context))">退出频道</MudMenuItem> + <MudMenuItem Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="@(async () => await DisbandChannel(context))">解散频道</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(() => DeleteChannel(context.Id))">删除频道</MudMenuItem> </MudMenu> </MudStack> @@ -195,6 +201,9 @@ </MudCard> @code { + [SupplyParameterFromQuery(Name = "accountId")] + public int? QueryAccountId { get; set; } + private MudTable<Channel>? _table; private List<Channel> channels = new(); private List<Account> accounts = new(); @@ -207,6 +216,7 @@ private string filterType = "all"; private string filterMembershipRole = "all"; private HashSet<Channel> selectedChannels = new(); + private int? _lastAppliedQueryAccountId; private CancellationTokenSource? _searchDebounceCts; private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; @@ -232,6 +242,19 @@ await LoadGroups(); } + protected override async Task OnParametersSetAsync() + { + var queryAccountId = QueryAccountId.GetValueOrDefault(); + if (_lastAppliedQueryAccountId == queryAccountId) + return; + + _lastAppliedQueryAccountId = queryAccountId; + filterAccount = queryAccountId > 0 ? queryAccountId : 0; + + if (_table != null) + await ReloadTable(); + } + private async Task LoadAccounts() { try @@ -679,6 +702,14 @@ await ReloadTable(); } + private async Task ShowSystemAccounts(Channel channel) + { + var parameters = new DialogParameters { ["ChannelDbId"] = channel.Id }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show<ChannelSystemAccountsDialog>("本系统账号", parameters, options); + await Task.CompletedTask; + } + private async Task OpenBatchSetGroupSelected() { var targets = selectedChannels.ToList(); @@ -735,9 +766,7 @@ { try { - var executeAccountId = await ChannelManagement.ResolveExecuteAccountIdAsync( - channel, - preferredAccountId: filterAccount > 0 ? filterAccount : null); + var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel); if (executeAccountId is not > 0) { Snackbar.Add("无法导出链接:该频道暂无可用执行账号,请先同步对应账号的频道数据", Severity.Warning); @@ -755,6 +784,208 @@ } } + private async Task<int?> ResolveChannelExecuteAccountIdAsync(Channel channel) + { + return await ChannelManagement.ResolveExecuteAccountIdAsync( + channel, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + } + + private async Task<int?> ResolveChannelDisbandAccountIdAsync(Channel channel) + { + if (filterAccount > 0 && channel.CreatorAccountId == filterAccount) + return filterAccount; + + if (channel.CreatorAccountId is > 0) + return channel.CreatorAccountId.Value; + + return await ResolveChannelExecuteAccountIdAsync(channel); + } + + private async Task<bool> ConfirmAdminPasswordAsync(string prompt, string confirmText) + { + var parameters = new DialogParameters + { + ["Prompt"] = prompt, + ["ConfirmText"] = confirmText + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show<AdminPasswordConfirmDialog>("后台密码确认", parameters, options); + var result = await dialog.Result; + return result is { Canceled: false }; + } + + private void ShowBatchFailureWarnings(IReadOnlyList<string> failures, string actionName) + { + foreach (var failure in failures.Take(3)) + Snackbar.Add($"{actionName}失败:{failure}", Severity.Warning); + + if (failures.Count > 3) + Snackbar.Add($"另有 {failures.Count - 3} 条{actionName}失败,请重试或逐个处理", Severity.Warning); + } + + private async Task LeaveChannel(Channel channel) + { + bool? result = await DialogService.ShowMessageBox( + "确认退出", + $"确定要让执行账号退出频道 {channel.Title} 吗?若系统内其他账号仍在该频道中,频道记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + try + { + var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel); + if (executeAccountId is not > 0) + { + Snackbar.Add("退出失败:该频道暂无可用执行账号", Severity.Warning); + return; + } + + await ChannelService.LeaveChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.RemoveAccountChannelAsync(channel.Id, executeAccountId.Value); + await ReloadTable(); + Snackbar.Add($"已退出频道:{channel.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"退出失败:{ex.Message}", Severity.Error); + } + } + + private async Task LeaveSelectedChannels() + { + var targets = selectedChannels.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认批量退出", + $"确定要让执行账号退出选中的 {targets.Count} 个频道吗?若系统内其他账号仍在这些频道中,频道记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + var successCount = 0; + var failures = new List<string>(); + try + { + foreach (var channel in targets) + { + try + { + var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await ChannelService.LeaveChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.RemoveAccountChannelAsync(channel.Id, executeAccountId.Value); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{channel.Title}:{ex.Message}"); + } + } + + selectedChannels.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量退出完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "退出频道"); + } + finally + { + loading = false; + } + } + + private async Task DisbandChannel(Channel channel) + { + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散频道 {channel.Title} 吗?此操作会在 Telegram 侧删除频道,并从本地面板移除记录。", + "确认解散"); + if (!confirmed) + return; + + try + { + var executeAccountId = await ResolveChannelDisbandAccountIdAsync(channel); + if (executeAccountId is not > 0) + { + Snackbar.Add("解散失败:该频道暂无可用执行账号", Severity.Warning); + return; + } + + await ChannelService.DisbandChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.DeleteChannelAsync(channel.Id); + await ReloadTable(); + Snackbar.Add($"已解散频道:{channel.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"解散失败:{ex.Message}", Severity.Error); + } + } + + private async Task DisbandSelectedChannels() + { + var targets = selectedChannels.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散选中的 {targets.Count} 个频道吗?此操作会在 Telegram 侧删除频道,并从本地面板移除记录。", + "确认批量解散"); + if (!confirmed) + return; + + loading = true; + var successCount = 0; + var failures = new List<string>(); + try + { + foreach (var channel in targets) + { + try + { + var executeAccountId = await ResolveChannelDisbandAccountIdAsync(channel); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await ChannelService.DisbandChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.DeleteChannelAsync(channel.Id); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{channel.Title}:{ex.Message}"); + } + } + + selectedChannels.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量解散完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "解散频道"); + } + finally + { + loading = false; + } + } + private async Task DeleteChannel(int id) { var channel = channels.FirstOrDefault(c => c.Id == id); diff --git a/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor new file mode 100644 index 0000000..e84c1ba --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor @@ -0,0 +1,224 @@ +@page "/groups/create" +@inject IGroupService GroupService +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject GroupManagementService GroupManagement +@inject GroupCategoryManagementService GroupCategoryManagement +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@inject IDialogService DialogService +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Components.Dialogs + +<PageTitle>创建群组 - Telegram Panel</PageTitle> + +<MudText Typo="Typo.h4" Class="mb-4">创建群组</MudText> + +<MudGrid> + <MudItem xs="12" md="8" Class="mx-auto"> + <MudCard> + <MudCardContent> + <MudSelect @bind-Value="selectedAccountId" Label="选择账号" Variant="Variant.Outlined" Required="true"> + <MudSelectItem Value="0">-- 请选择账号 --</MudSelectItem> + @foreach (var account in accounts) + { + <MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem> + } + </MudSelect> + + <MudSelect @bind-Value="selectedCategoryId" Label="群组分类" Variant="Variant.Outlined" Class="mt-4"> + <MudSelectItem Value="0">未分类</MudSelectItem> + @foreach (var category in categories) + { + <MudSelectItem Value="@category.Id">@category.Name</MudSelectItem> + } + </MudSelect> + + <MudTextField @bind-Value="groupTitle" Label="群组名称" Variant="Variant.Outlined" Required="true" Class="mt-4" + HelperText="最多255个字符" MaxLength="255" /> + + <MudTextField @bind-Value="groupAbout" Label="群组简介" Variant="Variant.Outlined" Lines="3" Class="mt-4" + HelperText="最多255个字符" MaxLength="255" /> + + <MudSwitch @bind-Value="isPublic" Label="设为公开群组" Color="Color.Primary" Class="mt-4" /> + + @if (isPublic) + { + <MudTextField @bind-Value="username" Label="群组用户名" Variant="Variant.Outlined" Class="mt-3" + Adornment="Adornment.Start" AdornmentText="@" Required="true" + HelperText="5-32个字符,只能包含 a-z、0-9 和下划线" /> + } + + <MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mt-4"> + 当前创建的是 Telegram 超级群组。创建成功后会自动写入本地数据库,并绑定到当前执行账号。 + </MudAlert> + </MudCardContent> + <MudCardActions> + <MudButton Variant="Variant.Text" Color="Color.Default" + OnClick="@(async (MouseEventArgs _) => await Cancel())">取消</MudButton> + <MudSpacer /> + <MudButton Variant="Variant.Filled" Color="Color.Primary" + Disabled="@(selectedAccountId == 0 || string.IsNullOrWhiteSpace(groupTitle) || creating)" + OnClick="@(async (MouseEventArgs _) => await CreateGroup())"> + @if (creating) + { + <MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" /> + <span>创建中...</span> + } + else + { + <span>创建群组</span> + } + </MudButton> + </MudCardActions> + </MudCard> + </MudItem> +</MudGrid> + +@code { + private List<Account> accounts = new(); + private List<GroupCategory> categories = new(); + private int selectedAccountId; + private int selectedCategoryId; + private string groupTitle = string.Empty; + private string groupAbout = string.Empty; + private bool isPublic; + private string username = string.Empty; + private bool creating; + + protected override async Task OnInitializedAsync() + { + await LoadAccounts(); + await LoadCategories(); + } + + private async Task LoadAccounts() + { + try + { + var accountList = await AccountManagement.GetActiveAccountsAsync(); + accounts = accountList + .Where(a => a.Category?.ExcludeFromOperations != true) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号列表失败:{ex.Message}", Severity.Error); + } + } + + 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 CreateGroup() + { + if (selectedAccountId == 0) + { + Snackbar.Add("请选择账号", Severity.Error); + return; + } + + var title = groupTitle.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + Snackbar.Add("请输入群组名称", Severity.Error); + return; + } + + var about = string.IsNullOrWhiteSpace(groupAbout) ? string.Empty : groupAbout.Trim(); + var normalizedUsername = NormalizeUsername(username); + if (isPublic && string.IsNullOrWhiteSpace(normalizedUsername)) + { + Snackbar.Add("公开群组需要设置用户名", Severity.Error); + return; + } + + var account = await AccountManagement.GetAccountAsync(selectedAccountId); + if (account != null) + { + var riskCheck = RiskService.CheckLoginDuration(account); + if (riskCheck.IsRisky) + { + var parameters = new DialogParameters + { + ["Message"] = riskCheck.Message, + ["DetailedMessage"] = riskCheck.DetailedMessage, + ["ShowRecommendations"] = true + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium }; + var dialog = DialogService.Show<RiskWarningDialog>("风控警告", parameters, options); + var result = await dialog.Result; + if (result.Canceled) + return; + } + } + + creating = true; + try + { + var groupInfo = await GroupService.CreateGroupAsync(selectedAccountId, title, about, isPublic, normalizedUsername); + var now = DateTime.UtcNow; + + var group = new Group + { + TelegramId = groupInfo.TelegramId, + AccessHash = groupInfo.AccessHash, + Title = groupInfo.Title, + Username = groupInfo.Username, + MemberCount = Math.Max(groupInfo.MemberCount, 1), + About = groupInfo.About, + CreatorAccountId = selectedAccountId, + CategoryId = selectedCategoryId > 0 ? selectedCategoryId : null, + CreatedAt = groupInfo.CreatedAt ?? now, + SyncedAt = now + }; + + var savedGroup = await GroupManagement.CreateOrUpdateGroupAsync(group); + await GroupManagement.UpsertAccountGroupAsync(selectedAccountId, savedGroup.Id, isCreator: true, isAdmin: true, syncedAtUtc: now); + + Snackbar.Add($"群组 \"{title}\" 创建成功", Severity.Success); + Navigation.NavigateTo("/groups"); + } + catch (Exception ex) + { + Snackbar.Add($"创建失败:{ex.Message}", Severity.Error); + } + finally + { + creating = false; + } + } + + private Task Cancel() + { + Navigation.NavigateTo("/groups"); + return Task.CompletedTask; + } + + private static string? NormalizeUsername(string? value) + { + var normalized = value?.Trim().TrimStart('@'); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + 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 9ca5bf3..1dc4a00 100644 --- a/src/TelegramPanel.Web/Components/Pages/Groups.razor +++ b/src/TelegramPanel.Web/Components/Pages/Groups.razor @@ -60,6 +60,15 @@ @SelectionToggleText </MudButton> + <MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" + Href="/groups/create" Size="Size.Small"> + 创建群组 + </MudButton> + <MudButton Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.Folder" + Href="/groups/categories" Size="Size.Small"> + 分类管理 + </MudButton> + <MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync" Disabled="@(loading || filterAccount <= 0)" OnClick="SyncCurrentAccountGroups" Size="Size.Small"> 同步当前 @@ -75,6 +84,8 @@ <MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="BatchExportLinksSelected">批量导出加入链接(已选)</MudMenuItem> <MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="OpenBatchSetCategorySelected">批量修改分类(已选)</MudMenuItem> <MudDivider /> + <MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="LeaveSelectedGroups">批量退出群组(已选)</MudMenuItem> + <MudMenuItem Disabled="@(selectedGroups.Count == 0)" Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="DisbandSelectedGroups">批量解散群组(已选)</MudMenuItem> <MudMenuItem Disabled="@(selectedGroups.Count == 0)" Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="DeleteSelectedGroups">批量删除(已选)</MudMenuItem> </MudMenu> @@ -169,8 +180,11 @@ <MudIconButton Icon="@Icons.Material.Filled.Info" Size="Size.Small" Color="Color.Info" OnClick="@(async () => await ShowDetails(context))" /> <MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true"> + <MudMenuItem Icon="@Icons.Material.Filled.People" OnClick="@(async () => await ShowSystemAccounts(context))">本系统账号</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyLinkAsync(context))">复制链接</MudMenuItem> <MudDivider /> + <MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" OnClick="@(async () => await LeaveGroup(context))">退出群组</MudMenuItem> + <MudMenuItem Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="@(async () => await DisbandGroup(context))">解散群组</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(() => DeleteGroup(context.Id))">删除群组</MudMenuItem> </MudMenu> </MudStack> @@ -190,6 +204,9 @@ </MudCard> @code { + [SupplyParameterFromQuery(Name = "accountId")] + public int? QueryAccountId { get; set; } + private MudTable<Group>? _table; private List<Group> groups = new(); private List<Account> accounts = new(); @@ -202,6 +219,7 @@ private string filterType = "all"; private string filterMembershipRole = "all"; private HashSet<Group> selectedGroups = new(); + private int? _lastAppliedQueryAccountId; private CancellationTokenSource? _searchDebounceCts; private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; @@ -227,6 +245,19 @@ await LoadCategories(); } + protected override async Task OnParametersSetAsync() + { + var queryAccountId = QueryAccountId.GetValueOrDefault(); + if (_lastAppliedQueryAccountId == queryAccountId) + return; + + _lastAppliedQueryAccountId = queryAccountId; + filterAccount = queryAccountId > 0 ? queryAccountId : 0; + + if (_table != null) + await ReloadTable(); + } + private async Task LoadAccounts() { try @@ -627,13 +658,19 @@ await Task.CompletedTask; } + private async Task ShowSystemAccounts(Group group) + { + var parameters = new DialogParameters { ["GroupDbId"] = group.Id }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show<GroupSystemAccountsDialog>("本系统账号", parameters, options); + await Task.CompletedTask; + } + private async Task CopyLinkAsync(Group group) { try { - var executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync( - group, - preferredAccountId: filterAccount > 0 ? filterAccount : null); + var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group); if (executeAccountId is not > 0) { Snackbar.Add("无法导出链接:该群组暂无可用执行账号,请先同步对应账号的群组数据", Severity.Warning); @@ -650,6 +687,208 @@ } } + private async Task<int?> ResolveGroupExecuteAccountIdAsync(Group group) + { + return await GroupManagement.ResolveExecuteAccountIdAsync( + group, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + } + + private async Task<int?> ResolveGroupDisbandAccountIdAsync(Group group) + { + if (filterAccount > 0 && group.CreatorAccountId == filterAccount) + return filterAccount; + + if (group.CreatorAccountId is > 0) + return group.CreatorAccountId.Value; + + return await ResolveGroupExecuteAccountIdAsync(group); + } + + private async Task<bool> ConfirmAdminPasswordAsync(string prompt, string confirmText) + { + var parameters = new DialogParameters + { + ["Prompt"] = prompt, + ["ConfirmText"] = confirmText + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show<AdminPasswordConfirmDialog>("后台密码确认", parameters, options); + var result = await dialog.Result; + return result is { Canceled: false }; + } + + private void ShowBatchFailureWarnings(IReadOnlyList<string> failures, string actionName) + { + foreach (var failure in failures.Take(3)) + Snackbar.Add($"{actionName}失败:{failure}", Severity.Warning); + + if (failures.Count > 3) + Snackbar.Add($"另有 {failures.Count - 3} 条{actionName}失败,请重试或逐个处理", Severity.Warning); + } + + private async Task LeaveGroup(Group group) + { + bool? result = await DialogService.ShowMessageBox( + "确认退出", + $"确定要让执行账号退出群组 {group.Title} 吗?若系统内其他账号仍在该群组中,群组记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + try + { + var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group); + if (executeAccountId is not > 0) + { + Snackbar.Add("退出失败:该群组暂无可用执行账号", Severity.Warning); + return; + } + + await GroupService.LeaveGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.RemoveAccountGroupAsync(group.Id, executeAccountId.Value); + await ReloadTable(); + Snackbar.Add($"已退出群组:{group.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"退出失败:{ex.Message}", Severity.Error); + } + } + + private async Task LeaveSelectedGroups() + { + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认批量退出", + $"确定要让执行账号退出选中的 {targets.Count} 个群组吗?若系统内其他账号仍在这些群组中,群组记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + var successCount = 0; + var failures = new List<string>(); + try + { + foreach (var group in targets) + { + try + { + var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await GroupService.LeaveGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.RemoveAccountGroupAsync(group.Id, executeAccountId.Value); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{group.Title}:{ex.Message}"); + } + } + + selectedGroups.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量退出完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "退出群组"); + } + finally + { + loading = false; + } + } + + private async Task DisbandGroup(Group group) + { + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散群组 {group.Title} 吗?此操作会在 Telegram 侧删除群组,并从本地面板移除记录。", + "确认解散"); + if (!confirmed) + return; + + try + { + var executeAccountId = await ResolveGroupDisbandAccountIdAsync(group); + if (executeAccountId is not > 0) + { + Snackbar.Add("解散失败:该群组暂无可用执行账号", Severity.Warning); + return; + } + + await GroupService.DisbandGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.DeleteGroupAsync(group.Id); + await ReloadTable(); + Snackbar.Add($"已解散群组:{group.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"解散失败:{ex.Message}", Severity.Error); + } + } + + private async Task DisbandSelectedGroups() + { + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散选中的 {targets.Count} 个群组吗?此操作会在 Telegram 侧删除群组,并从本地面板移除记录。", + "确认批量解散"); + if (!confirmed) + return; + + loading = true; + var successCount = 0; + var failures = new List<string>(); + try + { + foreach (var group in targets) + { + try + { + var executeAccountId = await ResolveGroupDisbandAccountIdAsync(group); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await GroupService.DisbandGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.DeleteGroupAsync(group.Id); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{group.Title}:{ex.Message}"); + } + } + + selectedGroups.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量解散完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "解散群组"); + } + finally + { + loading = false; + } + } + private async Task DeleteSelectedGroups() { var targets = selectedGroups.ToList(); diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 26ab650..a50e46a 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -7,6 +7,7 @@ @inject IGroupService GroupService @inject ITelegramClientPool TelegramClientPool @inject TelegramPanel.Web.Services.DataSyncService DataSync +@inject BatchTaskManagementService TaskManagement @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IWebHostEnvironment Environment @@ -90,6 +91,8 @@ Min="1000" Max="10000" HelperText="建议2000-5000ms" /> <MudNumericField @bind-Value="maxConcurrent" Label="最大并发任务数" Variant="Variant.Outlined" Class="mt-3" Min="1" Max="10" HelperText="同时执行的任务数量" /> + <MudNumericField @bind-Value="historyRetentionLimit" Label="历史任务保留上限" Variant="Variant.Outlined" Class="mt-3" + Min="0" Max="5000" HelperText="仅保留最新的已完成/失败任务,0 表示不限制" /> <MudSwitch @bind-Value="autoRetry" Label="失败自动重试" Color="Color.Primary" Class="mt-3" /> @if (autoRetry) { @@ -265,6 +268,7 @@ private string apiHash = ""; private int defaultDelay = 2000; private int maxConcurrent = 1; + private int historyRetentionLimit = 0; private bool autoRetry = false; private int maxRetries = 3; private bool autoSync = true; @@ -314,6 +318,7 @@ apiHash = Configuration["Telegram:ApiHash"] ?? ""; defaultDelay = Configuration.GetValue("Telegram:DefaultDelayMs", 2000); maxConcurrent = Configuration.GetValue("BatchTasks:MaxConcurrent", 1); + historyRetentionLimit = Configuration.GetValue("BatchTasks:HistoryRetentionLimit", 0); maxRetries = Configuration.GetValue("Telegram:MaxRetries", 0); autoRetry = maxRetries > 0; if (maxRetries <= 0) maxRetries = 3; @@ -567,6 +572,12 @@ return; } + if (historyRetentionLimit < 0 || historyRetentionLimit > 5000) + { + Snackbar.Add("历史任务保留上限范围应为 0-5000", Severity.Error); + return; + } + try { var root = await LoadOrCreateLocalConfigAsync(); @@ -577,9 +588,19 @@ var batchTasks = EnsureObject(root, "BatchTasks"); batchTasks["MaxConcurrent"] = maxConcurrent; + batchTasks["HistoryRetentionLimit"] = historyRetentionLimit; await SaveLocalConfigAsync(root); - Snackbar.Add("批量操作配置已保存(配置热重载后生效)", Severity.Success); + + var deletedCount = historyRetentionLimit > 0 + ? await TaskManagement.TrimHistoryTasksAsync(historyRetentionLimit) + : 0; + + var message = deletedCount > 0 + ? $"批量操作配置已保存,并已清理 {deletedCount} 条超出上限的历史任务" + : "批量操作配置已保存(配置热重载后生效)"; + + Snackbar.Add(message, Severity.Success); StateHasChanged(); } catch (Exception ex) @@ -639,21 +660,16 @@ } syncing = true; - Snackbar.Add("开始同步频道和群组信息...", Severity.Info); + Snackbar.Add("正在后台创建同步任务...", Severity.Info); try { - var run = await DataSync.RunAllActiveAccountsTrackedAsync("manual", CancellationToken.None); - var summary = run.Summary; - - foreach (var failure in summary.AccountFailures) - Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning); - - Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组。任务 #{run.TaskId} 已记录到任务中心", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); + var taskId = await DataSync.StartAllActiveAccountsTrackedInBackgroundAsync("manual", CancellationToken.None); + Snackbar.Add($"已启动后台同步任务 #{taskId},请到任务中心查看进度", Severity.Success); } catch (Exception ex) { - Snackbar.Add($"同步失败:{ex.Message}", Severity.Error); + Snackbar.Add($"同步启动失败:{ex.Message}", Severity.Error); } finally { diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs index f8cbc4a..077c040 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -1,6 +1,7 @@ +using MudBlazor; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Modules; -using MudBlazor; +using TelegramPanel.Web.Services; namespace TelegramPanel.Web.Modules.BuiltIn; @@ -23,6 +24,7 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { // 内置任务的执行由宿主 BatchTaskBackgroundService 负责;这里只提供“元数据”用于 UI 展示与创建。 + services.AddSingleton<IModuleTaskRerunBuilder, UserChatActiveTaskRerunBuilder>(); } public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) @@ -40,6 +42,15 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide Description = "按账号分类持续向指定目标发送词典内容,支持间隔抖动、随机/队列循环。", Icon = Icons.Material.Filled.Chat, EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "", + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "", + AutoPauseBeforeEdit = true + }, Order = 120 }; diff --git a/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs b/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs index 5bddacd..3de3e78 100644 --- a/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs +++ b/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs @@ -150,6 +150,9 @@ public sealed class ModuleContributionRegistry if (string.IsNullOrWhiteSpace(t.CreateRoute)) t.CreateRoute = null; t.EditorComponentType = (t.EditorComponentType ?? "").Trim(); if (string.IsNullOrWhiteSpace(t.EditorComponentType)) t.EditorComponentType = null; + t.TaskCenter ??= new ModuleTaskCenterCapabilities(); + t.TaskCenter.EditComponentType = (t.TaskCenter.EditComponentType ?? "").Trim(); + if (string.IsNullOrWhiteSpace(t.TaskCenter.EditComponentType)) t.TaskCenter.EditComponentType = null; return t; } diff --git a/src/TelegramPanel.Web/Services/DataSyncService.cs b/src/TelegramPanel.Web/Services/DataSyncService.cs index a1e8b08..c1eb95f 100644 --- a/src/TelegramPanel.Web/Services/DataSyncService.cs +++ b/src/TelegramPanel.Web/Services/DataSyncService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Interfaces; @@ -23,6 +24,7 @@ public class DataSyncService private readonly IGroupService _groupService; private readonly AccountTelegramToolsService _telegramTools; private readonly BatchTaskManagementService _taskManagement; + private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _configuration; private readonly ILogger<DataSyncService> _logger; @@ -34,6 +36,7 @@ public class DataSyncService IGroupService groupService, AccountTelegramToolsService telegramTools, BatchTaskManagementService taskManagement, + IServiceScopeFactory scopeFactory, IConfiguration configuration, ILogger<DataSyncService> logger) { @@ -44,28 +47,78 @@ public class DataSyncService _groupService = groupService; _telegramTools = telegramTools; _taskManagement = taskManagement; + _scopeFactory = scopeFactory; _configuration = configuration; _logger = logger; } public async Task<TrackedSyncResult> RunAllActiveAccountsTrackedAsync(string trigger, CancellationToken cancellationToken) { - if (!await SyncGate.WaitAsync(0, cancellationToken)) - throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度"); + var task = await CreateTrackedTaskAsync(trigger, cancellationToken); + return await ExecuteTrackedSyncAsync(task.Id, trigger, cancellationToken); + } + + public async Task<int> StartAllActiveAccountsTrackedInBackgroundAsync(string trigger, CancellationToken cancellationToken = default) + { + var task = await CreateTrackedTaskAsync(trigger, cancellationToken); + var taskId = task.Id; + var scopeFactory = _scopeFactory; + var logger = _logger; + + _ = Task.Run(async () => + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dataSync = scope.ServiceProvider.GetRequiredService<DataSyncService>(); + await dataSync.ExecuteTrackedSyncAsync(taskId, trigger, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Manual account sync background execution failed unexpectedly for task {TaskId}", taskId); + } + }, CancellationToken.None); + + return taskId; + } + + private async Task<BatchTask> CreateTrackedTaskAsync(string trigger, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var accounts = await GetDistinctActiveAccountsAsync(); + + return await _taskManagement.CreateTaskAsync(new BatchTask + { + TaskType = BatchTaskTypes.AccountAutoSync, + Total = accounts.Count, + Config = BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: accounts.Count, + processedAccounts: 0, + failedAccounts: 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty<SyncFailureItem>(), + error: null) + }); + } + + private async Task<TrackedSyncResult> ExecuteTrackedSyncAsync(int taskId, string trigger, CancellationToken cancellationToken) + { + var gateEntered = false; - BatchTask? task = null; try { - var accounts = (await _accountManagement.GetActiveAccountsAsync()) - .GroupBy(x => x.Id) - .Select(x => x.First()) - .ToList(); + gateEntered = await SyncGate.WaitAsync(0, cancellationToken); + if (!gateEntered) + throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度"); - task = await _taskManagement.CreateTaskAsync(new BatchTask - { - TaskType = BatchTaskTypes.AccountAutoSync, - Total = accounts.Count, - Config = BuildSyncTaskConfig( + var accounts = await GetDistinctActiveAccountsAsync(); + await _taskManagement.UpdateTaskDraftAsync( + taskId, + accounts.Count, + BuildSyncTaskConfig( trigger: trigger, totalAccounts: accounts.Count, processedAccounts: 0, @@ -73,19 +126,18 @@ public class DataSyncService totalChannelsSynced: 0, totalGroupsSynced: 0, failures: Array.Empty<SyncFailureItem>(), - error: null) - }); + error: null)); - await _taskManagement.StartTaskAsync(task.Id); + await _taskManagement.StartTaskAsync(taskId); var summary = await SyncAccountsAsync( accounts, cancellationToken, - progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(task.Id, progress.ProcessedAccounts, progress.FailedAccounts)); + progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(taskId, progress.ProcessedAccounts, progress.FailedAccounts)); - await _taskManagement.UpdateTaskProgressAsync(task.Id, summary.ProcessedAccounts, summary.FailedAccountsCount); + await _taskManagement.UpdateTaskProgressAsync(taskId, summary.ProcessedAccounts, summary.FailedAccountsCount); await _taskManagement.UpdateTaskConfigAsync( - task.Id, + taskId, BuildSyncTaskConfig( trigger: trigger, totalAccounts: summary.TotalAccounts, @@ -95,58 +147,59 @@ public class DataSyncService totalGroupsSynced: summary.TotalGroupsSynced, failures: summary.AccountFailures.Select(ToFailureItem).ToList(), error: null)); - await _taskManagement.CompleteTaskAsync(task.Id, success: true); + await _taskManagement.CompleteTaskAsync(taskId, success: true); - return new TrackedSyncResult(task.Id, summary); + return new TrackedSyncResult(taskId, summary); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - if (task != null) - { - var snapshot = await _taskManagement.GetTaskAsync(task.Id); - await _taskManagement.UpdateTaskConfigAsync( - task.Id, - BuildSyncTaskConfig( - trigger: trigger, - totalAccounts: snapshot?.Total ?? 0, - processedAccounts: snapshot?.Completed ?? 0, - failedAccounts: snapshot?.Failed ?? 0, - totalChannelsSynced: 0, - totalGroupsSynced: 0, - failures: Array.Empty<SyncFailureItem>(), - error: "已取消")); - await _taskManagement.CompleteTaskAsync(task.Id, success: false); - } - + var snapshot = await _taskManagement.GetTaskAsync(taskId); + await _taskManagement.UpdateTaskConfigAsync( + taskId, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: snapshot?.Total ?? 0, + processedAccounts: snapshot?.Completed ?? 0, + failedAccounts: snapshot?.Failed ?? 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty<SyncFailureItem>(), + error: "已取消")); + await _taskManagement.CompleteTaskAsync(taskId, success: false); throw; } catch (Exception ex) { - if (task != null) - { - var snapshot = await _taskManagement.GetTaskAsync(task.Id); - await _taskManagement.UpdateTaskConfigAsync( - task.Id, - BuildSyncTaskConfig( - trigger: trigger, - totalAccounts: snapshot?.Total ?? 0, - processedAccounts: snapshot?.Completed ?? 0, - failedAccounts: snapshot?.Failed ?? 0, - totalChannelsSynced: 0, - totalGroupsSynced: 0, - failures: Array.Empty<SyncFailureItem>(), - error: ex.Message)); - await _taskManagement.CompleteTaskAsync(task.Id, success: false); - } - + var snapshot = await _taskManagement.GetTaskAsync(taskId); + await _taskManagement.UpdateTaskConfigAsync( + taskId, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: snapshot?.Total ?? 0, + processedAccounts: snapshot?.Completed ?? 0, + failedAccounts: snapshot?.Failed ?? 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty<SyncFailureItem>(), + error: ex.Message)); + await _taskManagement.CompleteTaskAsync(taskId, success: false); throw; } finally { - SyncGate.Release(); + if (gateEntered) + SyncGate.Release(); } } + private async Task<List<Account>> GetDistinctActiveAccountsAsync() + { + return (await _accountManagement.GetActiveAccountsAsync()) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + } + public async Task<SyncSummary> SyncAllActiveAccountsAsync( CancellationToken cancellationToken, Func<SyncProgress, Task>? progressCallback = null) @@ -191,10 +244,17 @@ public class DataSyncService var accountFailed = false; cancellationToken.ThrowIfCancellationRequested(); + _logger.LogInformation( + "Syncing account {Index}/{Total}: {AccountId} {Phone}", + index + 1, + accountList.Count, + account.Id, + account.Phone); + try { // 同步频道:拉取账号当前可见的全部频道,并记录该账号在频道中的角色关系。 - var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id); + var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id, cancellationToken); var keepChannelIds = new List<int>(capacity: channelInfos.Count); foreach (var channelInfo in channelInfos) @@ -230,7 +290,7 @@ public class DataSyncService await _channelManagement.DeleteStaleAccountChannelsAsync(account.Id, keepChannelIds); // 同步群组:拉取账号当前可见的全部群组,并记录该账号在群组中的角色关系。 - var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id); + var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id, cancellationToken); var keepGroupIds = new List<int>(capacity: groupInfos.Count); foreach (var groupInfo in groupInfos) diff --git a/src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs b/src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs new file mode 100644 index 0000000..26d5870 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs @@ -0,0 +1,42 @@ +using TelegramPanel.Modules; +using TelegramPanel.Web.Modules; + +namespace TelegramPanel.Web.Services; + +public static class ModuleTaskEditorComponentResolver +{ + public static Type? ResolveCreateEditor(RegisteredTaskDefinition taskDefinition) + { + return Resolve(taskDefinition, taskDefinition.Definition.EditorComponentType); + } + + public static Type? ResolveEditEditor(RegisteredTaskDefinition taskDefinition) + { + var typeName = taskDefinition.Definition.TaskCenter.EditComponentType; + if (string.IsNullOrWhiteSpace(typeName)) + typeName = taskDefinition.Definition.EditorComponentType; + + return Resolve(taskDefinition, typeName); + } + + private static Type? Resolve(RegisteredTaskDefinition taskDefinition, string? typeName) + { + typeName = (typeName ?? string.Empty).Trim(); + if (typeName.Length == 0) + return null; + + var moduleTypeName = NormalizeTypeName(typeName); + return Type.GetType(typeName, throwOnError: false, ignoreCase: false) + ?? taskDefinition.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); + } + + public static string NormalizeTypeName(string? typeName) + { + typeName = (typeName ?? string.Empty).Trim(); + if (typeName.Length == 0) + return typeName; + + var comma = typeName.IndexOf(',', StringComparison.Ordinal); + return comma > 0 ? typeName[..comma].Trim() : typeName; + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs new file mode 100644 index 0000000..ade6479 --- /dev/null +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class UserChatActiveTaskRerunBuilder : IModuleTaskRerunBuilder +{ + public string TaskType => BatchTaskTypes.UserChatActive; + + public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task) + { + var rerunConfig = BuildRerunConfig(task.Config); + var total = rerunConfig.MaxMessages > 0 ? rerunConfig.MaxMessages : 0; + var configJson = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true }); + + return new ModuleTaskCreateRequest + { + TaskType = BatchTaskTypes.UserChatActive, + Total = total, + Config = configJson + }; + } + + private static UserChatActiveTaskConfig BuildRerunConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务配置为空,无法重新运行"); + + UserChatActiveTaskConfig cfg; + try + { + cfg = JsonSerializer.Deserialize<UserChatActiveTaskConfig>(raw) + ?? throw new InvalidOperationException("任务配置解析结果为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); + } + + cfg.CategoryIds = NormalizeCategoryIdsForRerun(cfg.CategoryIds, cfg.CategoryId); + if (cfg.CategoryIds.Count == 0) + throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); + + cfg.CategoryId = cfg.CategoryIds[0]; + cfg.CategoryNames = NormalizeCategoryNamesForRerun(cfg.CategoryNames, cfg.CategoryName); + cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; + + cfg.Targets = (cfg.Targets ?? new List<string>()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + cfg.Dictionary = (cfg.Dictionary ?? new List<string>()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + + if (cfg.Targets.Count == 0) + throw new InvalidOperationException("任务缺少目标群组/频道,无法重新运行"); + + if (cfg.Dictionary.Count == 0) + throw new InvalidOperationException("任务缺少词典消息,无法重新运行"); + + if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0; + if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0; + if (cfg.DelayMinMs > 600000) cfg.DelayMinMs = 600000; + if (cfg.DelayMaxMs > 600000) cfg.DelayMaxMs = 600000; + if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs; + if (cfg.MaxMessages < 0) cfg.MaxMessages = 0; + + cfg.AccountMode = NormalizeModeValue(cfg.AccountMode); + cfg.TargetMode = NormalizeModeValue(cfg.TargetMode); + cfg.MessageMode = NormalizeModeValue(cfg.MessageMode); + + cfg.Canceled = false; + cfg.Error = null; + cfg.RecentFailures = new List<UserChatActiveTaskRuntimeFailure>(); + + return cfg; + } + + private static List<int> NormalizeCategoryIdsForRerun(IEnumerable<int>? values, int fallback) + { + var ids = (values ?? Array.Empty<int>()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + + private static List<string> NormalizeCategoryNamesForRerun(IEnumerable<string>? values, string? fallback) + { + var names = (values ?? Array.Empty<string>()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var fallbackName = (fallback ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + + return names; + } + + private static string NormalizeModeValue(string? mode) + { + return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase) + ? UserChatActiveTaskModes.Queue + : UserChatActiveTaskModes.Random; + } +} From efebe9e66ba0337b568f95e3f6a6466b19a11b7c Mon Sep 17 00:00:00 2001 From: meoacgx <a1478882500@gmail.com> Date: Mon, 9 Mar 2026 12:41:00 +0800 Subject: [PATCH 5/6] docs: add chinese summary for new dev features --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bfe6487..175be6a 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,22 @@ - 🔐 2FA 管理:单个/批量修改二级密码;绑定/换绑找回邮箱(支持对接 Cloud Mail 自动收码确认) - 🧩 模块化扩展:任务 / API / UI 可安装扩展(见 `docs/developer/modules.md`) +## 近期新增功能(dev) + +- 🧠 任务中心增强:持续任务支持暂停、编辑、重新运行;任务列表区分执行中与历史任务;支持“仅清理历史任务”与“历史任务保留上限”自动清理 +- 💬 持续活跃任务升级:新增账号持续活跃(群组/频道)能力,支持多分类账号、随机文案、秒级发送间隔、持续运行配置 +- 🔄 同步体验优化:手动“立即同步”改为后台任务执行,可在任务中心跟踪进度;同步链路补充超时保护与状态持久化 +- 👤 账号列表增强:新增注册时间展示,并可直接从账号操作中查看该账号已加入的频道和群组 +- 📺 频道管理升级:频道列表改为面向“已加入频道”视角,不再只显示系统创建频道;支持按账号、角色、分类筛选,支持查看频道内的本系统账号 +- 👥 群组管理补齐:新增群组创建页、群组分类、批量操作与列表能力补齐;群组列表同样支持按账号、角色、分类筛选,并支持查看群组内的本系统账号 +- 🔗 多账号关系可视化:同一频道/群组可绑定多个系统账号,频道/群组列表与详情页都可查看当前关联的系统账号、角色与状态 +- 🚪 真实退出/解散能力:频道与群组支持单个/批量退出;支持解散频道/群组,且解散前需后台密码确认 +- 🧹 数据准确性修正:修复频道列表混入群组的问题,并优化账号、频道、群组关系同步后的本地展示逻辑 +- ⚡ 数据层优化:补充频道/群组查询与关系索引,提升几百账号、上千频道/群组场景下的筛选与加载性能 + ## TODO(规划) -- [ ] 一键退群/退订、订阅(频道/群组) +- [x] 一键退群/退订、订阅(频道/群组) - [ ] 一键清空联系人 - [ ] 批量手机号验证码重新登录(用于刷新会话 session) - [ ] 手机号注册:未注册号支持完整注册流程(姓名/可选邮箱/邮箱验证码等) @@ -40,7 +53,7 @@ - [ ] 多 API:支持账号分类绑定 ApiId/ApiHash - [ ] 定时创建频道、定时公开频道 - [ ] 定时刷粉丝:对接刷粉 API(通用适配结构),通过适配模块对接多家刷粉平台 -- [ ] 群聊定时发言养号 +- [x] 群聊定时发言养号 ## 快速开始 From 3cdc458f27c325245ac0e056aa46bffe0caebed7 Mon Sep 17 00:00:00 2001 From: meoacgx <a1478882500@gmail.com> Date: Mon, 9 Mar 2026 13:24:19 +0800 Subject: [PATCH 6/6] fix: cleanup stale sync data and estimate account registration --- .../Services/ChannelManagementService.cs | 43 +- .../Services/GroupManagementService.cs | 43 +- .../Telegram/AccountTelegramToolsService.cs | 122 +++ src/TelegramPanel.Data/Entities/Account.cs | 10 + ...untEstimatedRegistrationFields.Designer.cs | 745 ++++++++++++++++++ ...2_AddAccountEstimatedRegistrationFields.cs | 39 + .../Migrations/AppDbContextModelSnapshot.cs | 6 + .../Repositories/ChannelRepository.cs | 14 + .../Repositories/GroupRepository.cs | 14 + .../Repositories/IChannelRepository.cs | 1 + .../Repositories/IGroupRepository.cs | 1 + .../Dialogs/AccountDetailsDialog.razor | 8 +- .../Components/Pages/Accounts.razor | 4 +- .../Services/DataSyncService.cs | 2 + 14 files changed, 1047 insertions(+), 5 deletions(-) create mode 100644 src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 15e901c..0d9b149 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -184,7 +184,30 @@ public class ChannelManagementService public async Task DeleteStaleAccountChannelsAsync(int accountId, IReadOnlyCollection<int> keepChannelIds) { - await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds); + var keepSet = keepChannelIds.Count == 0 + ? new HashSet<int>() + : keepChannelIds.ToHashSet(); + + var staleChannelIds = (await _accountChannelRepository.GetByAccountAsync(accountId)) + .Where(x => !keepSet.Contains(x.ChannelId)) + .Select(x => x.ChannelId) + .Distinct() + .ToList(); + + foreach (var channelId in staleChannelIds) + await RemoveAccountChannelAsync(channelId, accountId); + + var staleChannelIdSet = staleChannelIds.ToHashSet(); + var creatorOnlyChannelIds = (await _channelRepository.FindAsync(x => x.CreatorAccountId == accountId)) + .Select(x => x.Id) + .Where(id => !keepSet.Contains(id) && !staleChannelIdSet.Contains(id)) + .Distinct() + .ToList(); + + foreach (var channelId in creatorOnlyChannelIds) + await DetachCreatorFromChannelAsync(channelId, accountId); + + await _channelRepository.DeleteOrphanedAsync(); } public async Task<IReadOnlyList<AccountChannel>> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default) @@ -224,6 +247,24 @@ public class ChannelManagementService await _channelRepository.UpdateAsync(channel); } + private async Task DetachCreatorFromChannelAsync(int channelId, int accountId) + { + var channel = await _channelRepository.GetByIdAsync(channelId); + if (channel == null || channel.CreatorAccountId != accountId) + return; + + channel.CreatorAccountId = null; + + if (channel.AccountChannels.Count == 0) + { + await _channelRepository.DeleteAsync(channel); + return; + } + + channel.SyncedAt = DateTime.UtcNow; + await _channelRepository.UpdateAsync(channel); + } + /// <summary> /// 解析频道操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 4a2f7f6..423fbda 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -127,7 +127,30 @@ public class GroupManagementService public async Task DeleteStaleAccountGroupsAsync(int accountId, IReadOnlyCollection<int> keepGroupIds) { - await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds); + var keepSet = keepGroupIds.Count == 0 + ? new HashSet<int>() + : keepGroupIds.ToHashSet(); + + var staleGroupIds = (await _accountGroupRepository.GetByAccountAsync(accountId)) + .Where(x => !keepSet.Contains(x.GroupId)) + .Select(x => x.GroupId) + .Distinct() + .ToList(); + + foreach (var groupId in staleGroupIds) + await RemoveAccountGroupAsync(groupId, accountId); + + var staleGroupIdSet = staleGroupIds.ToHashSet(); + var creatorOnlyGroupIds = (await _groupRepository.FindAsync(x => x.CreatorAccountId == accountId)) + .Select(x => x.Id) + .Where(id => !keepSet.Contains(id) && !staleGroupIdSet.Contains(id)) + .Distinct() + .ToList(); + + foreach (var groupId in creatorOnlyGroupIds) + await DetachCreatorFromGroupAsync(groupId, accountId); + + await _groupRepository.DeleteOrphanedAsync(); } public async Task<IReadOnlyList<AccountGroup>> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default) @@ -167,6 +190,24 @@ public class GroupManagementService await _groupRepository.UpdateAsync(group); } + private async Task DetachCreatorFromGroupAsync(int groupId, int accountId) + { + var group = await _groupRepository.GetByIdAsync(groupId); + if (group == null || group.CreatorAccountId != accountId) + return; + + group.CreatorAccountId = null; + + if (group.AccountGroups.Count == 0) + { + await _groupRepository.DeleteAsync(group); + return; + } + + group.SyncedAt = DateTime.UtcNow; + await _groupRepository.UpdateAsync(group); + } + /// <summary> /// 解析群组操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 52ad472..8d2e55e 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -88,6 +88,7 @@ public class AccountTelegramToolsService if (account != null) { profile.ApplyTo(account); + await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken); } var summary = "正常"; @@ -246,6 +247,127 @@ public class AccountTelegramToolsService .ToList(); } + public async Task EnsureEstimatedRegistrationAsync(int accountId, CancellationToken cancellationToken = default) + { + try + { + var account = await _accountManagement.GetAccountAsync(accountId); + if (account == null) + return; + + if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue) + return; + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken); + + if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue) + await _accountManagement.UpdateAccountAsync(account); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "EnsureEstimatedRegistrationAsync skipped for account {AccountId}", accountId); + } + } + + private async Task TryPopulateEstimatedRegistrationAsync( + Account account, + Client client, + int accountId, + CancellationToken cancellationToken) + { + if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue) + return; + + var (checkedSuccessfully, estimatedAtUtc) = await TryGetEstimatedRegistrationFromSystemMessagesAsync(client, accountId, cancellationToken); + if (!checkedSuccessfully) + return; + + if (estimatedAtUtc.HasValue) + account.EstimatedRegistrationAt = estimatedAtUtc.Value; + + account.EstimatedRegistrationCheckedAtUtc = DateTime.UtcNow; + } + + private async Task<(bool CheckedSuccessfully, DateTime? EstimatedAtUtc)> TryGetEstimatedRegistrationFromSystemMessagesAsync( + Client client, + int accountId, + CancellationToken cancellationToken) + { + try + { + var peer = await TryResolveSystemPeerAsync(client); + if (peer == null) + return (true, null); + + const int pageSize = 100; + const int maxPages = 200; + var offsetId = 0; + DateTime? earliest = null; + + for (var page = 0; page < maxPages; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var history = await ExecuteTelegramRequestAsync( + accountId, + "读取 777000 系统通知历史", + () => client.Messages_GetHistory(peer, offset_id: offsetId, limit: pageSize), + cancellationToken, + resetClientOnTimeout: true); + + if (history.Messages == null || history.Messages.Length == 0) + break; + + foreach (var msgBase in history.Messages) + { + if (msgBase is not Message message) + continue; + + if (string.IsNullOrWhiteSpace(message.message)) + continue; + + var messageUtc = message.Date.ToUniversalTime(); + if (!earliest.HasValue || messageUtc < earliest.Value) + earliest = messageUtc; + } + + var nextOffsetId = history.Messages + .Select(GetTelegramMessageId) + .Where(id => id > 0) + .DefaultIfEmpty(0) + .Min(); + + if (nextOffsetId <= 0 || nextOffsetId == offsetId || history.Messages.Length < pageSize) + break; + + offsetId = nextOffsetId; + } + + return (true, earliest); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to estimate registration time from 777000 for account {AccountId}", accountId); + return (false, null); + } + } + + private static int GetTelegramMessageId(MessageBase msgBase) => msgBase switch + { + Message message => message.id, + MessageService service => service.id, + _ => 0 + }; + public async Task<IReadOnlyList<TelegramAuthorizationInfo>> GetAuthorizationsAsync(int accountId) { var client = await GetOrCreateConnectedClientAsync(accountId); diff --git a/src/TelegramPanel.Data/Entities/Account.cs b/src/TelegramPanel.Data/Entities/Account.cs index 7decde3..982de3b 100644 --- a/src/TelegramPanel.Data/Entities/Account.cs +++ b/src/TelegramPanel.Data/Entities/Account.cs @@ -38,6 +38,16 @@ public class Account public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime LastSyncAt { get; set; } = DateTime.UtcNow; + /// <summary> + /// 通过 777000 系统通知最早消息时间估算的注册时间(非百分百准确) + /// </summary> + public DateTime? EstimatedRegistrationAt { get; set; } + + /// <summary> + /// 最近一次尝试估算注册时间的时间(UTC) + /// </summary> + public DateTime? EstimatedRegistrationCheckedAtUtc { get; set; } + /// <summary> /// 最后一次登录 Telegram 的时间(UTC),用于风控检查 /// </summary> diff --git a/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs new file mode 100644 index 0000000..625d15c --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs @@ -0,0 +1,745 @@ +// <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("20260309051752_AddAccountEstimatedRegistrationFields")] + partial class AddAccountEstimatedRegistrationFields + { + /// <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<DateTime?>("EstimatedRegistrationAt") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc") + .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.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + + 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.HasIndex("GroupId", "IsCreator", "IsAdmin"); + + 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("SyncedAt"); + + 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("SyncedAt"); + + 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 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs new file mode 100644 index 0000000..ed07fa1 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// <inheritdoc /> + public partial class AddAccountEstimatedRegistrationFields : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<DateTime>( + name: "EstimatedRegistrationAt", + table: "Accounts", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "EstimatedRegistrationCheckedAtUtc", + table: "Accounts", + type: "TEXT", + nullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EstimatedRegistrationAt", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "EstimatedRegistrationCheckedAtUtc", + table: "Accounts"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 9ce8981..826e1c4 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -37,6 +37,12 @@ namespace TelegramPanel.Data.Migrations b.Property<DateTime>("CreatedAt") .HasColumnType("TEXT"); + b.Property<DateTime?>("EstimatedRegistrationAt") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc") + .HasColumnType("TEXT"); + b.Property<bool>("IsActive") .HasColumnType("INTEGER"); diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index 97dd136..6c5cd0f 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -183,6 +183,20 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository .ToListAsync(); } + public async Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default) + { + var orphanedChannels = await _dbSet + .Where(c => c.CreatorAccountId == null && !c.AccountChannels.Any()) + .ToListAsync(cancellationToken); + + if (orphanedChannels.Count == 0) + return 0; + + _dbSet.RemoveRange(orphanedChannels); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + return orphanedChannels.Count; + } + public async Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync( int accountId, int? groupId, diff --git a/src/TelegramPanel.Data/Repositories/GroupRepository.cs b/src/TelegramPanel.Data/Repositories/GroupRepository.cs index 9149b75..edc269c 100644 --- a/src/TelegramPanel.Data/Repositories/GroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/GroupRepository.cs @@ -131,6 +131,20 @@ public class GroupRepository : Repository<Group>, IGroupRepository .ToListAsync(); } + public async Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default) + { + var orphanedGroups = await _dbSet + .Where(g => g.CreatorAccountId == null && !g.AccountGroups.Any()) + .ToListAsync(cancellationToken); + + if (orphanedGroups.Count == 0) + return 0; + + _dbSet.RemoveRange(orphanedGroups); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + return orphanedGroups.Count; + } + public async Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync( int accountId, int? categoryId, diff --git a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs index 5b79c73..773c1ed 100644 --- a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs @@ -13,6 +13,7 @@ public interface IChannelRepository : IRepository<Channel> Task<IEnumerable<Channel>> GetForAccountAsync(int accountId, bool includeNonCreator); Task<IEnumerable<Channel>> GetByGroupAsync(int groupId); Task<IEnumerable<Channel>> GetBroadcastChannelsAsync(); + Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default); Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync( int accountId, diff --git a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs index e5f60f0..5471cca 100644 --- a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs @@ -9,6 +9,7 @@ public interface IGroupRepository : IRepository<Group> { Task<Group?> GetByTelegramIdAsync(long telegramId); Task<IEnumerable<Group>> GetByCreatorAccountAsync(int accountId); + Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default); Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync( int accountId, int? categoryId, diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor index 0ca3e70..2f4eea8 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor @@ -17,7 +17,8 @@ else { <MudStack Spacing="3"> - <MudTextField Value="@FormatCreatedAt()" Label="创建时间" ReadOnly="true" Variant="Variant.Outlined" /> + <MudTextField Value="@FormatEstimatedRegistrationAt()" Label="注册时间(估算,非百分百正确)" ReadOnly="true" Variant="Variant.Outlined" /> + <MudTextField Value="@FormatCreatedAt()" Label="导入时间" ReadOnly="true" Variant="Variant.Outlined" /> <MudTextField Value="@account.SessionPath" Label="Session 路径" ReadOnly="true" Variant="Variant.Outlined" /> <MudDivider Class="my-2" /> @@ -86,6 +87,11 @@ return account == null ? "-" : TimeZone.Format(account.CreatedAt, "yyyy-MM-dd HH:mm"); } + private string FormatEstimatedRegistrationAt() + { + return account == null ? "-" : TimeZone.Format(account.EstimatedRegistrationAt, "yyyy-MM-dd HH:mm", "-"); + } + private string DisplayPassword() { if (account == null) diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 82baa4c..4470f7c 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -115,7 +115,7 @@ <MudTh>频道/群组</MudTh> <MudTh>状态</MudTh> <MudTh>Telegram 状态</MudTh> - <MudTh>注册时间</MudTh> + <MudTh>注册时间(估算,非百分百正确)</MudTh> <MudTh>最后数据同步</MudTh> <MudTh Style="min-width: 180px;">操作</MudTh> </HeaderContent> @@ -156,7 +156,7 @@ <MudChip T="string" Size="Size.Small" Color="Color.Default">未检测</MudChip> } </MudTd> - <MudTd DataLabel="注册时间"><PanelTime Value="context.CreatedAt" /></MudTd> + <MudTd DataLabel="注册时间(估算,非百分百正确)"><PanelTime Value="context.EstimatedRegistrationAt" EmptyText="-" /></MudTd> <MudTd DataLabel="最后数据同步"><PanelTime Value="context.LastSyncAt" /></MudTd> <MudTd DataLabel="操作"> <MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;"> diff --git a/src/TelegramPanel.Web/Services/DataSyncService.cs b/src/TelegramPanel.Web/Services/DataSyncService.cs index c1eb95f..9c9dcb2 100644 --- a/src/TelegramPanel.Web/Services/DataSyncService.cs +++ b/src/TelegramPanel.Web/Services/DataSyncService.cs @@ -324,6 +324,8 @@ public class DataSyncService await _groupManagement.DeleteStaleAccountGroupsAsync(account.Id, keepGroupIds); + await _telegramTools.EnsureEstimatedRegistrationAsync(account.Id, cancellationToken); + await _accountManagement.UpdateLastSyncTimeAsync(account.Id); } catch (Exception ex)