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
///
/// 获取账号当前可见的全部频道(创建者/管理员/普通成员)
///
- Task> GetVisibleChannelsAsync(int accountId);
+ Task> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default);
///
/// 创建新频道
@@ -57,6 +57,16 @@ public interface IChannelService
///
Task SetForwardingAllowedAsync(int accountId, long channelId, bool allowed);
+ ///
+ /// 退出频道或取消订阅。
+ ///
+ Task LeaveChannelAsync(int accountId, long channelId);
+
+ ///
+ /// 解散频道(需要频道创建者权限)。
+ ///
+ Task DisbandChannelAsync(int accountId, long channelId);
+
///
/// 导出频道加入链接:公开频道返回 t.me 链接;私密频道导出邀请链接(需要权限)。
///
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
///
Task> GetOwnedGroupsAsync(int accountId);
+ ///
+ /// 创建群组(超级群组)
+ ///
+ Task CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null);
+
///
/// 获取账号当前可见的全部群组(创建者/管理员/普通成员)
///
- Task> GetVisibleGroupsAsync(int accountId);
+ Task> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default);
///
/// 获取群组详情
///
Task GetGroupInfoAsync(int accountId, long groupId);
+ ///
+ /// 退出群组。
+ ///
+ Task LeaveGroupAsync(int accountId, long groupId);
+
+ ///
+ /// 解散群组(超级群需要创建者权限;基础群暂不支持)。
+ ///
+ Task DisbandGroupAsync(int accountId, long groupId);
+
///
/// 导出加入链接:公开群组返回 t.me 链接;否则导出邀请链接。
///
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 _logger;
- public BatchTaskManagementService(IBatchTaskRepository batchTaskRepository)
+ public BatchTaskManagementService(
+ IBatchTaskRepository batchTaskRepository,
+ IConfiguration configuration,
+ ILogger logger)
{
_batchTaskRepository = batchTaskRepository;
+ _configuration = configuration;
+ _logger = logger;
}
public async Task GetTaskAsync(int id)
@@ -40,6 +49,11 @@ public class BatchTaskManagementService
return await _batchTaskRepository.GetRecentTasksAsync(count);
}
+ public async Task TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default)
+ {
+ return await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken);
+ }
+
public async Task 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> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ return await _accountChannelRepository.GetByAccountAsync(accountId, cancellationToken);
+ }
+
+ public async Task> 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);
+ }
+
///
/// 解析频道操作的执行账号:
/// 优先使用 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> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ return await _accountGroupRepository.GetByAccountAsync(accountId, cancellationToken);
+ }
+
+ public async Task> 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);
+ }
+
///
/// 解析群组操作的执行账号:
/// 优先使用 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().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 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 ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func> 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> GetVisibleChannelsAsync(int accountId)
+ public async Task> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default)
{
- var client = await GetOrCreateConnectedClientAsync(accountId);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
var channels = new List();
- 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 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 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 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 GetOrCreateConnectedClientAsync(int accountId)
+ private async Task 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 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 ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func> 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> GetVisibleGroupsAsync(int accountId)
+ public async Task 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().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> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+
var groups = new List();
- 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 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 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 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 GetOrCreateConnectedClientAsync(int accountId)
+ private async Task 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 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 ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func> 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 @@
+//
+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
+ {
+ ///
+ 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.HasIndex("ChannelId", "IsCreator", "IsAdmin");
+
+ 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.HasIndex("GroupId", "IsCreator", "IsAdmin");
+
+ 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("SyncedAt");
+
+ b.HasIndex("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ChannelGroups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("About")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("AccessHash")
+ .HasColumnType("INTEGER");
+
+ b.Property("CategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatorAccountId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MemberCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("CreatorAccountId");
+
+ b.HasIndex("SyncedAt");
+
+ b.HasIndex("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("GroupCategories");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
+ .WithMany("Accounts")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
+ .WithMany("AccountChannels")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
+ .WithMany("AccountChannels")
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
+ .WithMany("AccountGroups")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
+ .WithMany("AccountGroups")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+
+ b.Navigation("Group");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
+ .WithMany("Channels")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
+ .WithMany("Members")
+ .HasForeignKey("BotChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
+ .WithMany("ChannelMembers")
+ .HasForeignKey("BotId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Bot");
+
+ b.Navigation("BotChannel");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
+ .WithMany("Channels")
+ .HasForeignKey("CreatorAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
+ .WithMany("Channels")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("CreatorAccount");
+
+ b.Navigation("Group");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
+ .WithMany("Groups")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
+ .WithMany("Groups")
+ .HasForeignKey("CreatorAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatorAccount");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
+ {
+ b.Navigation("AccountChannels");
+
+ b.Navigation("AccountGroups");
+
+ b.Navigation("Channels");
+
+ b.Navigation("Groups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
+ {
+ b.Navigation("Accounts");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
+ {
+ b.Navigation("ChannelMembers");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
+ {
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
+ {
+ b.Navigation("AccountChannels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
+ {
+ b.Navigation("AccountGroups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
+ {
+ b.Navigation("Groups");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/TelegramPanel.Data/Migrations/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
+{
+ ///
+ public partial class OptimizeChatListIndexes : Migration
+ {
+ ///
+ 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" });
+ }
+
+ ///
+ 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, IAccountChan
return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.ChannelId == channelId);
}
+ public async Task> 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> 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, 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 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, IAccountGroupRep
return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.GroupId == groupId);
}
+ public async Task> 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> 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, 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 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, IAccountRepository
var linkedChannelPairs = await _context.Set()
.AsNoTracking()
+ .Join(
+ _context.Set().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()
.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, IBatchTaskRepository
.Take(count)
.ToListAsync();
}
+
+ public async Task 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, IChannelRepository
{
}
- private IQueryable BuildForViewQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search)
+ private IQueryable 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, IChannelRepository
|| (c.Group != null && EF.Functions.Like(c.Group.Name, like)));
}
- return query.OrderByDescending(c => c.SyncedAt);
+ return query;
+ }
+
+ private IQueryable BuildPagedDetailQuery(IReadOnlyCollection ids, int accountId)
+ {
+ IQueryable 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 GetByIdAsync(int id)
@@ -90,6 +105,7 @@ public class ChannelRepository : Repository, 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, 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, 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, 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, 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, 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(), 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(), 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, IGroupRepository
{
}
- private IQueryable BuildForViewQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search)
+ private IQueryable 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, IGroupRepository
|| (g.Category != null && EF.Functions.Like(g.Category.Name, like)));
}
- return query.OrderByDescending(g => g.SyncedAt);
+ return query;
+ }
+
+ private IQueryable BuildPagedDetailQuery(IReadOnlyCollection ids, int accountId)
+ {
+ IQueryable 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 GetByIdAsync(int id)
@@ -131,13 +145,34 @@ public class GroupRepository : Repository, 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(), 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(), 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
{
Task GetAsync(int accountId, int channelId);
+ Task> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default);
+ Task> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default);
Task UpsertAsync(AccountChannel link);
+ Task DeleteAsync(int accountId, int channelId);
Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepChannelIds);
Task 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
{
Task GetAsync(int accountId, int groupId);
+ Task> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default);
+ Task> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default);
Task UpsertAsync(AccountGroup link);
+ Task DeleteAsync(int accountId, int groupId);
Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepGroupIds);
Task 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
Task> GetByStatusAsync(string status);
Task> GetRunningTasksAsync();
Task> GetRecentTasksAsync(int count = 20);
+ Task 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 : IRepository 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 : IRepository 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; }
///
- /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback<ModuleTaskDraft>)\n ///
+ /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。
+ /// 该组件需要支持参数:
+ /// - Draft (ModuleTaskDraft)
+ /// - DraftChanged (EventCallback<ModuleTaskDraft>)
+ /// 编辑场景下还应支持可选参数:
+ /// - InitialConfigJson (string?)
+ ///
[JsonPropertyName("editorComponentType")]
public string? EditorComponentType { get; set; }
+ ///
+ /// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。
+ ///
+ [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; }
+
+ ///
+ /// 编辑时使用的组件类型;为空时回退到 EditorComponentType。
+ ///
+ [JsonPropertyName("editComponentType")]
+ public string? EditComponentType { get; set; }
+
+ ///
+ /// 若任务仍在运行,打开编辑器前是否自动先暂停。
+ ///
+ [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 GetApis(ModuleHostContext context);
@@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost
Task 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
+
+
+
+ @if (loading)
+ {
+
+ }
+ else
+ {
+
+ 共 @memberships.Count 个频道
+
+ @if (memberships.Count == 0)
+ {
+ 该账号当前没有已同步的频道记录
+ }
+ else
+ {
+
+
+ 频道名称
+ 用户名
+ 角色
+ 频道分类
+ 成员数
+ 最后同步
+
+
+ @context.Channel.Title
+ @FormatUsername(context.Channel.Username)
+
+ @GetRoleLabel(context)
+
+ @(context.Channel.Group?.Name ?? "未分类")
+ @context.Channel.MemberCount
+
+
+
+ }
+
+ }
+
+
+ 关闭
+
+
+
+@code {
+ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
+ [Parameter] public int AccountId { get; set; }
+
+ private bool loading = true;
+ private List 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
+
+
+
+ @if (loading)
+ {
+
+ }
+ else
+ {
+
+ 共 @memberships.Count 个群组
+
+ @if (memberships.Count == 0)
+ {
+ 该账号当前没有已同步的群组记录
+ }
+ else
+ {
+
+
+ 群组名称
+ 用户名
+ 角色
+ 群组分类
+ 成员数
+ 最后同步
+
+
+ @context.Group.Title
+ @FormatUsername(context.Group.Username)
+
+ @GetRoleLabel(context)
+
+ @(context.Group.Category?.Name ?? "未分类")
+ @context.Group.MemberCount
+
+
+
+ }
+
+ }
+
+
+ 关闭
+
+
+
+@code {
+ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
+ [Parameter] public int AccountId { get; set; }
+
+ private bool loading = true;
+ private List 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
+
+
+
+
+
+ @Prompt
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(errorMessage))
+ {
+ @errorMessage
+ }
+
+
+
+ 取消
+ @ConfirmText
+
+
+
+@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
@@ -85,6 +86,52 @@
+
+ 本系统账号
+
+
+ 查看完整列表
+
+
+ 刷新
+
+
+
+ @if (systemAccountsLoading)
+ {
+
+ }
+ else if (systemAccounts.Count == 0)
+ {
+ 暂无本系统账号关联
+ }
+ else
+ {
+
+
+ 手机号
+ 昵称
+ 用户名
+ 状态
+ 角色
+
+
+ @FormatPhone(context.Account?.Phone)
+ @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)
+ @FormatUsername(context.Account?.Username)
+
+ @GetAccountStatusLabel(context.Account)
+
+
+ @GetMembershipLabel(context)
+
+
+
+ }
+
+
+
踢出成员
输入 @@用户名 执行踢出;“非永久”会使用短时间封禁达到踢出效果,随后自动解除,方便对方重新加入。
@@ -130,6 +177,8 @@
private bool adminsLoading;
private List admins = new();
+ private bool systemAccountsLoading;
+ private List 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("本系统账号", 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
+
+
+
+ @if (loading)
+ {
+
+ }
+ else
+ {
+
+ 共 @memberships.Count 个本系统账号
+
+ @if (memberships.Count == 0)
+ {
+ 该频道当前没有已同步的本系统账号关联
+ }
+ else
+ {
+
+
+ 手机号
+ 昵称
+ 用户名
+ 账号分类
+ 状态
+ 角色
+ 最后同步
+
+
+ @FormatPhone(context.Account?.Phone)
+ @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)
+ @FormatUsername(context.Account?.Username)
+ @(context.Account?.Category?.Name ?? "未分类")
+
+ @GetAccountStatusLabel(context.Account)
+
+
+ @GetRoleLabel(context)
+
+
+
+
+ }
+
+ }
+
+
+ 关闭
+
+
+
+@code {
+ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
+ [Parameter] public int ChannelDbId { get; set; }
+
+ private bool loading = true;
+ private List 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
+
+
+
+ @if (loading)
+ {
+
+
+ 正在加载任务配置...
+
+ }
+ else if (!string.IsNullOrWhiteSpace(loadError))
+ {
+
+ @loadError
+
+ }
+ else if (editorComponentType == null)
+ {
+
+ 该任务未提供可编辑组件。
+
+ }
+ else
+ {
+
+ 编辑后会更新当前任务配置。若该任务已完成或失败,可继续使用“重新运行”创建新任务执行。
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(draft.ValidationError))
+ {
+ @draft.ValidationError
+ }
+ }
+
+
+ 关闭
+
+ @if (saving)
+ {
+
+ 保存中...
+ }
+ else
+ {
+ 保存配置
+ }
+
+
+
+
+@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 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 BuildEditorParameters(Type componentType, BatchTask currentTask)
+ {
+ var parameters = new Dictionary
+ {
+ ["Draft"] = draft,
+ ["DraftChanged"] = EventCallback.Factory.Create(this, OnDraftChanged)
+ };
+
+ AddOptionalParameter(parameters, componentType, "InitialConfigJson", currentTask.Config);
+ AddOptionalParameter(parameters, componentType, "TaskId", currentTask.Id);
+ return parameters;
+ }
+
+ private static void AddOptionalParameter(Dictionary 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 @@
}
+
+
+
+
+ 本系统账号
+
+
+ 查看完整列表
+
+
+ 刷新
+
+
+
+ @if (systemAccountsLoading)
+ {
+
+ }
+ else if (systemAccounts.Count == 0)
+ {
+ 暂无本系统账号关联
+ }
+ else
+ {
+
+
+ 手机号
+ 昵称
+ 用户名
+ 状态
+ 角色
+
+
+ @FormatPhone(context.Account?.Phone)
+ @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)
+ @FormatUsername(context.Account?.Username)
+
+ @GetAccountStatusLabel(context.Account)
+
+
+ @GetMembershipLabel(context)
+
+
+
+ }
}
@@ -107,11 +155,14 @@
private bool adminsLoading;
private List admins = new();
+ private bool systemAccountsLoading;
+ private List 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("本系统账号", 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
+
+
+
+ @if (loading)
+ {
+
+ }
+ else
+ {
+
+ 共 @memberships.Count 个本系统账号
+
+ @if (memberships.Count == 0)
+ {
+ 该群组当前没有已同步的本系统账号关联
+ }
+ else
+ {
+
+
+ 手机号
+ 昵称
+ 用户名
+ 账号分类
+ 状态
+ 角色
+ 最后同步
+
+
+ @FormatPhone(context.Account?.Phone)
+ @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)
+ @FormatUsername(context.Account?.Username)
+ @(context.Account?.Category?.Name ?? "未分类")
+
+ @GetAccountStatusLabel(context.Account)
+
+
+ @GetRoleLabel(context)
+
+
+
+
+ }
+
+ }
+
+
+ 关闭
+
+
+
+@code {
+ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
+ [Parameter] public int GroupDbId { get; set; }
+
+ private bool loading = true;
+ private List 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 @@
群组列表
+ 创建群组
群组分类
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 @@
频道/群组
状态
Telegram 状态
+ 注册时间
最后数据同步
操作
@@ -155,6 +156,7 @@
未检测
}
+
@@ -168,6 +170,8 @@
加群/订阅
退群/退订
+ 查看加入的频道
+ 查看加入的群组
系统通知(验证码)
在线设备
@@ -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("加入的频道", 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("加入的群组", 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 @@
批量导出邀请链接(已选)
批量设置管理员(已选)
批量修改分类(已选)
+
+ 批量退出频道(已选)
+ 批量解散频道(已选)
@if (selectedChannels.Count > 0)
@@ -174,8 +177,11 @@
邀请成员
设置管理员
踢人
+ 本系统账号
复制链接
+ 退出频道
+ 解散频道
删除频道
@@ -195,6 +201,9 @@
@code {
+ [SupplyParameterFromQuery(Name = "accountId")]
+ public int? QueryAccountId { get; set; }
+
private MudTable? _table;
private List channels = new();
private List accounts = new();
@@ -207,6 +216,7 @@
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet 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("本系统账号", 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 ResolveChannelExecuteAccountIdAsync(Channel channel)
+ {
+ return await ChannelManagement.ResolveExecuteAccountIdAsync(
+ channel,
+ preferredAccountId: filterAccount > 0 ? filterAccount : null);
+ }
+
+ private async Task 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 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("后台密码确认", parameters, options);
+ var result = await dialog.Result;
+ return result is { Canceled: false };
+ }
+
+ private void ShowBatchFailureWarnings(IReadOnlyList 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();
+ 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();
+ 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
+
+创建群组 - Telegram Panel
+
+创建群组
+
+
+
+
+
+
+ -- 请选择账号 --
+ @foreach (var account in accounts)
+ {
+ @FormatAccountLabel(account)
+ }
+
+
+
+ 未分类
+ @foreach (var category in categories)
+ {
+ @category.Name
+ }
+
+
+
+
+
+
+
+
+ @if (isPublic)
+ {
+
+ }
+
+
+ 当前创建的是 Telegram 超级群组。创建成功后会自动写入本地数据库,并绑定到当前执行账号。
+
+
+
+ 取消
+
+
+ @if (creating)
+ {
+
+ 创建中...
+ }
+ else
+ {
+ 创建群组
+ }
+
+
+
+
+
+
+@code {
+ private List accounts = new();
+ private List 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("风控警告", 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
+
+ 创建群组
+
+
+ 分类管理
+
+
同步当前
@@ -75,6 +84,8 @@
批量导出加入链接(已选)
批量修改分类(已选)
+ 批量退出群组(已选)
+ 批量解散群组(已选)
批量删除(已选)
@@ -169,8 +180,11 @@
+ 本系统账号
复制链接
+ 退出群组
+ 解散群组
删除群组
@@ -190,6 +204,9 @@
@code {
+ [SupplyParameterFromQuery(Name = "accountId")]
+ public int? QueryAccountId { get; set; }
+
private MudTable? _table;
private List groups = new();
private List accounts = new();
@@ -202,6 +219,7 @@
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet 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("本系统账号", 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 ResolveGroupExecuteAccountIdAsync(Group group)
+ {
+ return await GroupManagement.ResolveExecuteAccountIdAsync(
+ group,
+ preferredAccountId: filterAccount > 0 ? filterAccount : null);
+ }
+
+ private async Task 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 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("后台密码确认", parameters, options);
+ var result = await dialog.Result;
+ return result is { Canceled: false };
+ }
+
+ private void ShowBatchFailureWarnings(IReadOnlyList 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();
+ 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();
+ 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" />
+
@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();
}
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 _logger;
@@ -34,6 +36,7 @@ public class DataSyncService
IGroupService groupService,
AccountTelegramToolsService telegramTools,
BatchTaskManagementService taskManagement,
+ IServiceScopeFactory scopeFactory,
IConfiguration configuration,
ILogger logger)
{
@@ -44,28 +47,78 @@ public class DataSyncService
_groupService = groupService;
_telegramTools = telegramTools;
_taskManagement = taskManagement;
+ _scopeFactory = scopeFactory;
_configuration = configuration;
_logger = logger;
}
public async Task 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 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();
+ 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 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(),
+ error: null)
+ });
+ }
+
+ private async Task 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(),
- 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(),
- 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(),
+ 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(),
- 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(),
+ error: ex.Message));
+ await _taskManagement.CompleteTaskAsync(taskId, success: false);
throw;
}
finally
{
- SyncGate.Release();
+ if (gateEntered)
+ SyncGate.Release();
}
}
+ private async Task> GetDistinctActiveAccountsAsync()
+ {
+ return (await _accountManagement.GetActiveAccountsAsync())
+ .GroupBy(x => x.Id)
+ .Select(x => x.First())
+ .ToList();
+ }
+
public async Task SyncAllActiveAccountsAsync(
CancellationToken cancellationToken,
Func? 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(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(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(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())
+ .Select(x => (x ?? string.Empty).Trim())
+ .Where(x => x.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ cfg.Dictionary = (cfg.Dictionary ?? new List())
+ .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();
+
+ 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)
+ ? UserChatActiveTaskModes.Queue
+ : UserChatActiveTaskModes.Random;
+ }
+}