feat: enhance task center and chat account visibility

This commit is contained in:
meoacgx committed 2026-03-09 12:36:51 +08:00
1 parent 7dc1eea3c0
commit 3c32cfb073
43 files changed
+3811 -155

No files matched your search

@@ -20,7 +20,7 @@ public interface IChannelService
/// <summary>
/// 获取账号当前可见的全部频道(创建者/管理员/普通成员)
/// </summary>
Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId);
Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default);
/// <summary>
/// 创建新频道
@@ -57,6 +57,16 @@ public interface IChannelService
/// </summary>
Task<bool> SetForwardingAllowedAsync(int accountId, long channelId, bool allowed);
/// <summary>
/// 退出频道或取消订阅。
/// </summary>
Task<bool> LeaveChannelAsync(int accountId, long channelId);
/// <summary>
/// 解散频道(需要频道创建者权限)。
/// </summary>
Task<bool> DisbandChannelAsync(int accountId, long channelId);
/// <summary>
/// 导出频道加入链接:公开频道返回 t.me 链接;私密频道导出邀请链接(需要权限)。
/// </summary>
@@ -12,16 +12,31 @@ public interface IGroupService
/// </summary>
Task<List<GroupInfo>> GetOwnedGroupsAsync(int accountId);
/// <summary>
/// 创建群组(超级群组)
/// </summary>
Task<GroupInfo> CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null);
/// <summary>
/// 获取账号当前可见的全部群组(创建者/管理员/普通成员)
/// </summary>
Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId);
Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取群组详情
/// </summary>
Task<GroupInfo?> GetGroupInfoAsync(int accountId, long groupId);
/// <summary>
/// 退出群组。
/// </summary>
Task<bool> LeaveGroupAsync(int accountId, long groupId);
/// <summary>
/// 解散群组(超级群需要创建者权限;基础群暂不支持)。
/// </summary>
Task<bool> DisbandGroupAsync(int accountId, long groupId);
/// <summary>
/// 导出加入链接:公开群组返回 t.me 链接;否则导出邀请链接。
/// </summary>
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
@@ -9,10 +11,17 @@ namespace TelegramPanel.Core.Services;
public class BatchTaskManagementService
{
private readonly IBatchTaskRepository _batchTaskRepository;
private readonly IConfiguration _configuration;
private readonly ILogger<BatchTaskManagementService> _logger;
public BatchTaskManagementService(IBatchTaskRepository batchTaskRepository)
public BatchTaskManagementService(
IBatchTaskRepository batchTaskRepository,
IConfiguration configuration,
ILogger<BatchTaskManagementService> logger)
{
_batchTaskRepository = batchTaskRepository;
_configuration = configuration;
_logger = logger;
}
public async Task<BatchTask?> GetTaskAsync(int id)
@@ -40,6 +49,11 @@ public class BatchTaskManagementService
return await _batchTaskRepository.GetRecentTasksAsync(count);
}
public async Task<int> TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default)
{
return await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken);
}
public async Task<BatchTask> CreateTaskAsync(BatchTask task)
{
task.CreatedAt = DateTime.UtcNow;
@@ -123,12 +137,13 @@ public class BatchTaskManagementService
public async Task CompleteTaskAsync(int taskId, bool success = true)
{
var task = await _batchTaskRepository.GetByIdAsync(taskId);
if (task != null)
{
task.Status = success ? "completed" : "failed";
task.CompletedAt = DateTime.UtcNow;
await _batchTaskRepository.UpdateAsync(task);
}
if (task == null)
return;
task.Status = success ? "completed" : "failed";
task.CompletedAt = DateTime.UtcNow;
await _batchTaskRepository.UpdateAsync(task);
await TrimHistoryTasksIfNeededAsync();
}
public async Task DeleteTaskAsync(int id)
@@ -139,4 +154,29 @@ public class BatchTaskManagementService
await _batchTaskRepository.DeleteAsync(task);
}
}
private async Task TrimHistoryTasksIfNeededAsync(CancellationToken cancellationToken = default)
{
var keepCount = GetHistoryRetentionLimit();
if (keepCount <= 0)
return;
var deletedCount = await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken);
if (deletedCount > 0)
{
_logger.LogInformation(
"Trimmed {DeletedCount} historical batch tasks, keepCount={KeepCount}",
deletedCount,
keepCount);
}
}
private int GetHistoryRetentionLimit()
{
var rawValue = _configuration["BatchTasks:HistoryRetentionLimit"];
if (!int.TryParse(rawValue, out var keepCount) || keepCount < 0)
return 0;
return Math.Min(keepCount, 5000);
}
}
@@ -187,6 +187,43 @@ public class ChannelManagementService
await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds);
}
public async Task<IReadOnlyList<AccountChannel>> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
{
return await _accountChannelRepository.GetByAccountAsync(accountId, cancellationToken);
}
public async Task<IReadOnlyList<AccountChannel>> GetChannelAccountMembershipsAsync(int channelId, CancellationToken cancellationToken = default)
{
return await _accountChannelRepository.GetByChannelAsync(channelId, cancellationToken);
}
public async Task RemoveAccountChannelAsync(int channelId, int accountId)
{
var channel = await _channelRepository.GetByIdAsync(channelId);
if (channel == null)
return;
var hasRemainingRelations = channel.AccountChannels.Any(x => x.AccountId != accountId);
var creatorRemoved = channel.CreatorAccountId == accountId;
await _accountChannelRepository.DeleteAsync(accountId, channelId);
if (creatorRemoved)
channel.CreatorAccountId = null;
if (!hasRemainingRelations && channel.CreatorAccountId == null)
{
await _channelRepository.DeleteAsync(channel);
return;
}
if (!creatorRemoved)
return;
channel.SyncedAt = DateTime.UtcNow;
await _channelRepository.UpdateAsync(channel);
}
/// <summary>
/// 解析频道操作的执行账号:
/// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。
@@ -130,6 +130,43 @@ public class GroupManagementService
await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds);
}
public async Task<IReadOnlyList<AccountGroup>> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
{
return await _accountGroupRepository.GetByAccountAsync(accountId, cancellationToken);
}
public async Task<IReadOnlyList<AccountGroup>> GetGroupAccountMembershipsAsync(int groupId, CancellationToken cancellationToken = default)
{
return await _accountGroupRepository.GetByGroupAsync(groupId, cancellationToken);
}
public async Task RemoveAccountGroupAsync(int groupId, int accountId)
{
var group = await _groupRepository.GetByIdAsync(groupId);
if (group == null)
return;
var hasRemainingRelations = group.AccountGroups.Any(x => x.AccountId != accountId);
var creatorRemoved = group.CreatorAccountId == accountId;
await _accountGroupRepository.DeleteAsync(accountId, groupId);
if (creatorRemoved)
group.CreatorAccountId = null;
if (!hasRemainingRelations && group.CreatorAccountId == null)
{
await _groupRepository.DeleteAsync(group);
return;
}
if (!creatorRemoved)
return;
group.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(group);
}
/// <summary>
/// 解析群组操作的执行账号:
/// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。
@@ -50,7 +50,12 @@ public class AccountTelegramToolsService
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var users = await client.Users_GetUsers(InputUser.Self);
var users = await ExecuteTelegramRequestAsync(
accountId,
"拉取账号资料",
() => client.Users_GetUsers(InputUser.Self),
cancellationToken,
resetClientOnTimeout: true);
cancellationToken.ThrowIfCancellationRequested();
var self = users.OfType<User>().FirstOrDefault();
@@ -1409,10 +1414,22 @@ public class AccountTelegramToolsService
try
{
await client.ConnectAsync();
await ExecuteTelegramRequestAsync(
accountId,
"连接 Telegram",
() => client.ConnectAsync(),
cancellationToken,
resetClientOnTimeout: true);
cancellationToken.ThrowIfCancellationRequested();
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
await client.LoginUserIfNeeded(reloginOnFailedResume: false);
{
await ExecuteTelegramRequestAsync(
accountId,
"恢复 Telegram 登录状态",
() => client.LoginUserIfNeeded(reloginOnFailedResume: false),
cancellationToken,
resetClientOnTimeout: true);
}
}
catch (Exception ex)
{
@@ -1433,6 +1450,70 @@ public class AccountTelegramToolsService
return client;
}
private TimeSpan GetTelegramRequestTimeout()
{
var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
? parsedSeconds
: 90;
return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
}
private async Task ExecuteTelegramRequestAsync(
int accountId,
string operation,
Func<Task> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
}
}
private async Task<T> ExecuteTelegramRequestAsync<T>(
int accountId,
string operation,
Func<Task<T>> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
return await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
}
}
private int ResolveApiId(Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
@@ -1492,7 +1573,12 @@ public class AccountTelegramToolsService
UpdatesBase updates;
try
{
updates = await client.Channels_CreateChannel(title: title, about: about, broadcast: true);
updates = await ExecuteTelegramRequestAsync(
accountId,
"创建测试频道探测账号状态",
() => client.Channels_CreateChannel(title: title, about: about, broadcast: true),
cancellationToken,
resetClientOnTimeout: true);
}
catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase))
{
@@ -1509,7 +1595,12 @@ public class AccountTelegramToolsService
{
// 立即删除,避免留下垃圾频道
var input = new InputChannel(channel.id, channel.access_hash);
await client.Channels_DeleteChannel(input);
await ExecuteTelegramRequestAsync(
accountId,
$"删除测试频道({channel.id})",
() => client.Channels_DeleteChannel(input),
cancellationToken,
resetClientOnTimeout: false);
}
catch (Exception ex)
{
@@ -1535,6 +1626,12 @@ public class AccountTelegramToolsService
{
var msg = ex.Message ?? string.Empty;
if (ex is TimeoutException
|| msg.Contains("请求超时", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("timed out", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("timeout", StringComparison.OrdinalIgnoreCase))
return ("请求超时", msg);
if (msg.Contains("EMAIL_HASH_EXPIRED", StringComparison.OrdinalIgnoreCase))
return (
"邮箱验证码已过期(EMAIL_HASH_EXPIRED)",
@@ -39,7 +39,7 @@ public class ChannelService : IChannelService
foreach (var (_, chat) in dialogs.chats)
{
if (chat is not Channel channel || !channel.IsActive)
if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
continue;
try
@@ -98,7 +98,7 @@ public class ChannelService : IChannelService
foreach (var (_, chat) in dialogs.chats)
{
if (chat is not Channel channel || !channel.IsActive)
if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
continue;
try
@@ -152,16 +152,25 @@ public class ChannelService : IChannelService
return channels;
}
public async Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId)
public async Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
cancellationToken.ThrowIfCancellationRequested();
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
var channels = new List<ChannelInfo>();
var dialogs = await client.Messages_GetAllDialogs();
var dialogs = await ExecuteTelegramRequestAsync(
accountId,
"拉取频道/群组对话列表",
() => client.Messages_GetAllDialogs(),
cancellationToken,
resetClientOnTimeout: true);
foreach (var (_, chat) in dialogs.chats)
{
if (chat is not Channel channel || !channel.IsActive)
cancellationToken.ThrowIfCancellationRequested();
if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
continue;
try
@@ -176,7 +185,12 @@ public class ChannelService : IChannelService
string? about = null;
try
{
var fullChannel = await client.Channels_GetFullChannel(channel);
var fullChannel = await ExecuteTelegramRequestAsync(
accountId,
$"拉取频道详情({channel.id})",
() => client.Channels_GetFullChannel(channel),
cancellationToken,
resetClientOnTimeout: false);
memberCount = fullChannel.full_chat.ParticipantsCount;
about = (fullChannel.full_chat as ChannelFull)?.about;
}
@@ -725,6 +739,46 @@ public class ChannelService : IChannelService
return true;
}
public async Task<bool> LeaveChannelAsync(int accountId, long channelId)
{
try
{
var client = await GetOrCreateConnectedClientAsync(accountId);
var channel = await GetChannelByIdAsync(client, channelId)
?? throw new InvalidOperationException($"频道 {channelId} not found");
await ExecuteTelegramRequestAsync(
accountId,
$"退出频道({channel.id})",
() => client.LeaveChat(channel.ToInputPeer()),
CancellationToken.None,
resetClientOnTimeout: false);
return true;
}
catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
public async Task<bool> DisbandChannelAsync(int accountId, long channelId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
var channel = await GetChannelByIdAsync(client, channelId)
?? throw new InvalidOperationException($"频道 {channelId} not found");
var input = new InputChannel(channel.id, channel.access_hash);
await ExecuteTelegramRequestAsync(
accountId,
$"解散频道({channel.id})",
() => client.Channels_DeleteChannel(input),
CancellationToken.None,
resetClientOnTimeout: false);
return true;
}
public async Task<string> ExportJoinLinkAsync(int accountId, long channelId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -891,8 +945,10 @@ public class ChannelService : IChannelService
.FirstOrDefault(c => candidateSet?.Contains(c.id) ?? c.id == channelId);
}
private async Task<Client> GetOrCreateConnectedClientAsync(int accountId)
private async Task<Client> GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var existing = _clientPool.GetClient(accountId);
if (existing?.User != null)
return existing;
@@ -932,9 +988,22 @@ public class ChannelService : IChannelService
try
{
await client.ConnectAsync();
await ExecuteTelegramRequestAsync(
accountId,
"连接 Telegram",
() => client.ConnectAsync(),
cancellationToken,
resetClientOnTimeout: true);
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
await client.LoginUserIfNeeded(reloginOnFailedResume: false);
{
await ExecuteTelegramRequestAsync(
accountId,
"恢复 Telegram 登录状态",
() => client.LoginUserIfNeeded(reloginOnFailedResume: false),
cancellationToken,
resetClientOnTimeout: true);
}
}
catch (Exception ex)
{
@@ -955,6 +1024,70 @@ public class ChannelService : IChannelService
return client;
}
private TimeSpan GetTelegramRequestTimeout()
{
var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
? parsedSeconds
: 90;
return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
}
private async Task ExecuteTelegramRequestAsync(
int accountId,
string operation,
Func<Task> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
}
}
private async Task<T> ExecuteTelegramRequestAsync<T>(
int accountId,
string operation,
Func<Task<T>> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
return await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
}
}
private int ResolveApiId(TelegramPanel.Data.Entities.Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
@@ -36,15 +36,91 @@ public class GroupService : IGroupService
return groups.Where(x => x.IsCreator).ToList();
}
public async Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId)
public async Task<GroupInfo> CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
_logger.LogInformation("Creating group '{Title}' for account {AccountId}", title, accountId);
if (isPublic)
{
username = username?.Trim().TrimStart('@');
if (string.IsNullOrWhiteSpace(username))
throw new InvalidOperationException("公开群组需要设置用户名");
}
UpdatesBase updates;
try
{
updates = await client.Channels_CreateChannel(
title: title,
about: about,
broadcast: false,
megagroup: true
);
}
catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Telegram 返回 FROZEN_METHOD_INVALID:当前 ApiId/ApiHash 或账号被 Telegram 限制调用创建群组接口。" +
"建议:在【系统设置】更换全局 ApiId/ApiHash(推荐使用你自己在 my.telegram.org 申请的)," +
"并重新导入/重新登录生成 session 后再试。",
ex);
}
var group = updates.Chats.Values.OfType<Channel>().FirstOrDefault(c => !c.IsChannel)
?? throw new InvalidOperationException("群组创建失败");
if (isPublic && !string.IsNullOrWhiteSpace(username))
{
var available = await client.Channels_CheckUsername(group, username);
if (!available)
throw new InvalidOperationException($"用户名 '{username}' 不可用");
try
{
await client.Channels_UpdateUsername(group, username);
}
catch (RpcException ex) when (ex.Message.Contains("USERNAME_NOT_MODIFIED", StringComparison.OrdinalIgnoreCase))
{
// 视为成功
}
}
return new GroupInfo
{
TelegramId = group.id,
AccessHash = group.access_hash,
Title = group.title,
Username = isPublic ? username : group.MainUsername,
MemberCount = 0,
About = about,
CreatorAccountId = accountId,
IsCreator = true,
IsAdmin = true,
CreatedAt = group.date,
SyncedAt = DateTime.UtcNow
};
}
public async Task<List<GroupInfo>> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
var groups = new List<GroupInfo>();
var dialogs = await client.Messages_GetAllDialogs();
var dialogs = await ExecuteTelegramRequestAsync(
accountId,
"拉取频道/群组对话列表",
() => client.Messages_GetAllDialogs(),
cancellationToken,
resetClientOnTimeout: true);
foreach (var (_, chat) in dialogs.chats)
{
cancellationToken.ThrowIfCancellationRequested();
// 基础群组(Chat 类型,非 Channel)
if (chat is Chat basicChat && basicChat.IsActive)
{
@@ -56,7 +132,12 @@ public class GroupService : IGroupService
try
{
var fullChat = await client.Messages_GetFullChat(basicChat.id);
var fullChat = await ExecuteTelegramRequestAsync(
accountId,
$"拉取基础群详情({basicChat.id})",
() => client.Messages_GetFullChat(basicChat.id),
cancellationToken,
resetClientOnTimeout: false);
if (fullChat.full_chat is ChatFull cf)
{
about = ReadString(cf, about, "about", "About");
@@ -117,7 +198,12 @@ public class GroupService : IGroupService
string? about = null;
try
{
var fullChannel = await client.Channels_GetFullChannel(channel);
var fullChannel = await ExecuteTelegramRequestAsync(
accountId,
$"拉取超级群详情({channel.id})",
() => client.Channels_GetFullChannel(channel),
cancellationToken,
resetClientOnTimeout: false);
memberCount = fullChannel.full_chat.ParticipantsCount;
about = (fullChannel.full_chat as ChannelFull)?.about;
}
@@ -330,6 +416,84 @@ public class GroupService : IGroupService
return groups.FirstOrDefault(g => g.TelegramId == groupId);
}
public async Task<bool> LeaveGroupAsync(int accountId, long groupId)
{
try
{
var client = await GetOrCreateConnectedClientAsync(accountId);
var dialogs = await ExecuteTelegramRequestAsync(
accountId,
"拉取频道/群组对话列表",
() => client.Messages_GetAllDialogs(),
CancellationToken.None,
resetClientOnTimeout: true);
var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId);
var chat = dialogs.chats.Values.FirstOrDefault(x =>
x switch
{
Chat basicChat => basicChat.id == normalizedId,
Channel channel => !channel.IsChannel && channel.id == normalizedId,
_ => false
});
if (chat == null)
throw new InvalidOperationException($"群组 {groupId} not found");
await ExecuteTelegramRequestAsync(
accountId,
$"退出群组({normalizedId})",
() => client.LeaveChat(chat.ToInputPeer()),
CancellationToken.None,
resetClientOnTimeout: false);
return true;
}
catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
public async Task<bool> DisbandGroupAsync(int accountId, long groupId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
var dialogs = await ExecuteTelegramRequestAsync(
accountId,
"拉取频道/群组对话列表",
() => client.Messages_GetAllDialogs(),
CancellationToken.None,
resetClientOnTimeout: true);
var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId);
var chat = dialogs.chats.Values.FirstOrDefault(x =>
x switch
{
Chat basicChat => basicChat.id == normalizedId,
Channel channel => !channel.IsChannel && channel.id == normalizedId,
_ => false
});
if (chat == null)
throw new InvalidOperationException($"群组 {groupId} not found");
if (chat is Chat)
throw new InvalidOperationException("基础群暂不支持一键解散,请在 Telegram 客户端手动操作或先升级为超级群");
if (chat is not Channel megaGroup)
throw new InvalidOperationException("仅支持解散超级群");
var input = new InputChannel(megaGroup.id, megaGroup.access_hash);
await ExecuteTelegramRequestAsync(
accountId,
$"解散群组({normalizedId})",
() => client.Channels_DeleteChannel(input),
CancellationToken.None,
resetClientOnTimeout: false);
return true;
}
public async Task<string> ExportJoinLinkAsync(int accountId, long groupId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -468,8 +632,10 @@ public class GroupService : IGroupService
throw new InvalidOperationException($"群组 {groupId} not found");
}
private async Task<WTelegram.Client> GetOrCreateConnectedClientAsync(int accountId)
private async Task<WTelegram.Client> GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var existing = _clientPool.GetClient(accountId);
if (existing?.User != null)
return existing;
@@ -509,9 +675,22 @@ public class GroupService : IGroupService
try
{
await client.ConnectAsync();
await ExecuteTelegramRequestAsync(
accountId,
"连接 Telegram",
() => client.ConnectAsync(),
cancellationToken,
resetClientOnTimeout: true);
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
await client.LoginUserIfNeeded(reloginOnFailedResume: false);
{
await ExecuteTelegramRequestAsync(
accountId,
"恢复 Telegram 登录状态",
() => client.LoginUserIfNeeded(reloginOnFailedResume: false),
cancellationToken,
resetClientOnTimeout: true);
}
}
catch (Exception ex)
{
@@ -532,6 +711,70 @@ public class GroupService : IGroupService
return client;
}
private TimeSpan GetTelegramRequestTimeout()
{
var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
? parsedSeconds
: 90;
return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
}
private async Task ExecuteTelegramRequestAsync(
int accountId,
string operation,
Func<Task> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
}
}
private async Task<T> ExecuteTelegramRequestAsync<T>(
int accountId,
string operation,
Func<Task<T>> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
return await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
}
}
private int ResolveApiId(TelegramPanel.Data.Entities.Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
+7
View File
@@ -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)
@@ -0,0 +1,739 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TelegramPanel.Data;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260308181608_OptimizeChatListIndexes")]
partial class OptimizeChatListIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("AccountCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("AccountId", "ChannelId")
.IsUnique();
b.HasIndex("ChannelId", "IsCreator", "IsAdmin");
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("AccountId", "GroupId")
.IsUnique();
b.HasIndex("GroupId", "IsCreator", "IsAdmin");
b.ToTable("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Bots");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("BotChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("BotChannelCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BotChannelId");
b.HasIndex("BotId");
b.HasIndex("BotId", "BotChannelId")
.IsUnique();
b.ToTable("BotChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("GroupId");
b.HasIndex("SyncedAt");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorAccountId");
b.HasIndex("SyncedAt");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
.WithMany("Accounts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountChannels")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
.WithMany("AccountChannels")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountGroups")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
.WithMany("AccountGroups")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
.WithMany("Channels")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
.WithMany("Members")
.HasForeignKey("BotChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
.WithMany("ChannelMembers")
.HasForeignKey("BotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bot");
b.Navigation("BotChannel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Channels")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
.WithMany("Channels")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
.WithMany("Groups")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Navigation("AccountChannels");
b.Navigation("AccountGroups");
b.Navigation("Channels");
b.Navigation("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Navigation("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Navigation("ChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Navigation("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Navigation("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Navigation("Groups");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class OptimizeChatListIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Groups_SyncedAt",
table: "Groups",
column: "SyncedAt");
migrationBuilder.CreateIndex(
name: "IX_Channels_SyncedAt",
table: "Channels",
column: "SyncedAt");
migrationBuilder.CreateIndex(
name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin",
table: "AccountGroups",
columns: new[] { "GroupId", "IsCreator", "IsAdmin" });
migrationBuilder.CreateIndex(
name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin",
table: "AccountChannels",
columns: new[] { "ChannelId", "IsCreator", "IsAdmin" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Groups_SyncedAt",
table: "Groups");
migrationBuilder.DropIndex(
name: "IX_Channels_SyncedAt",
table: "Channels");
migrationBuilder.DropIndex(
name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin",
table: "AccountGroups");
migrationBuilder.DropIndex(
name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin",
table: "AccountChannels");
}
}
}
@@ -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();
@@ -17,6 +17,32 @@ public class AccountChannelRepository : Repository<AccountChannel>, IAccountChan
return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.ChannelId == channelId);
}
public async Task<IReadOnlyList<AccountChannel>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default)
{
return await _dbSet
.AsNoTracking()
.Where(x => x.AccountId == accountId)
.Include(x => x.Channel)
.ThenInclude(x => x.Group)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenByDescending(x => x.SyncedAt)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<AccountChannel>> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default)
{
return await _dbSet
.AsNoTracking()
.Where(x => x.ChannelId == channelId)
.Include(x => x.Account)
.ThenInclude(x => x.Category)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenBy(x => x.AccountId)
.ToListAsync(cancellationToken);
}
public async Task UpsertAsync(AccountChannel link)
{
var existing = await GetAsync(link.AccountId, link.ChannelId);
@@ -35,6 +61,16 @@ public class AccountChannelRepository : Repository<AccountChannel>, IAccountChan
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int accountId, int channelId)
{
var existing = await GetAsync(accountId, channelId);
if (existing == null)
return;
_dbSet.Remove(existing);
await _context.SaveChangesAsync();
}
public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepChannelIds)
{
var keep = keepChannelIds.ToHashSet();
@@ -17,6 +17,32 @@ public class AccountGroupRepository : Repository<AccountGroup>, IAccountGroupRep
return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.GroupId == groupId);
}
public async Task<IReadOnlyList<AccountGroup>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default)
{
return await _dbSet
.AsNoTracking()
.Where(x => x.AccountId == accountId)
.Include(x => x.Group)
.ThenInclude(x => x.Category)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenByDescending(x => x.SyncedAt)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<AccountGroup>> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default)
{
return await _dbSet
.AsNoTracking()
.Where(x => x.GroupId == groupId)
.Include(x => x.Account)
.ThenInclude(x => x.Category)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenBy(x => x.AccountId)
.ToListAsync(cancellationToken);
}
public async Task UpsertAsync(AccountGroup link)
{
var existing = await GetAsync(link.AccountId, link.GroupId);
@@ -35,6 +61,16 @@ public class AccountGroupRepository : Repository<AccountGroup>, IAccountGroupRep
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int accountId, int groupId)
{
var existing = await GetAsync(accountId, groupId);
if (existing == null)
return;
_dbSet.Remove(existing);
await _context.SaveChangesAsync();
}
public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepGroupIds)
{
var keep = keepGroupIds.ToHashSet();
@@ -205,13 +205,17 @@ public class AccountRepository : Repository<Account>, IAccountRepository
var linkedChannelPairs = await _context.Set<AccountChannel>()
.AsNoTracking()
.Join(
_context.Set<Channel>().AsNoTracking().Where(x => x.IsBroadcast),
link => link.ChannelId,
channel => channel.Id,
(link, channel) => new { link.AccountId, link.ChannelId })
.Where(x => accountIds.Contains(x.AccountId))
.Select(x => new { x.AccountId, x.ChannelId })
.ToListAsync(cancellationToken);
var createdChannelPairs = await _context.Set<Channel>()
.AsNoTracking()
.Where(x => x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value))
.Where(x => x.IsBroadcast && x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value))
.Select(x => new { AccountId = x.CreatorAccountId!.Value, ChannelId = x.Id })
.ToListAsync(cancellationToken);
@@ -35,4 +35,24 @@ public class BatchTaskRepository : Repository<BatchTask>, IBatchTaskRepository
.Take(count)
.ToListAsync();
}
public async Task<int> TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default)
{
if (keepCount <= 0)
return 0;
var staleTasks = await _dbSet
.Where(t => t.Status == "completed" || t.Status == "failed")
.OrderByDescending(t => t.CreatedAt)
.ThenByDescending(t => t.Id)
.Skip(keepCount)
.ToListAsync(cancellationToken);
if (staleTasks.Count == 0)
return 0;
_dbSet.RemoveRange(staleTasks);
await SaveChangesWithSqliteLockRetryAsync(cancellationToken);
return staleTasks.Count;
}
}
@@ -12,16 +12,13 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
{
}
private IQueryable<Channel> BuildForViewQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search)
private IQueryable<Channel> BuildFilterQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search)
{
var query = _dbSet
.AsNoTracking()
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.AsQueryable();
query = query.Where(c => c.IsBroadcast);
query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any());
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
@@ -70,7 +67,25 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
|| (c.Group != null && EF.Functions.Like(c.Group.Name, like)));
}
return query.OrderByDescending(c => c.SyncedAt);
return query;
}
private IQueryable<Channel> BuildPagedDetailQuery(IReadOnlyCollection<int> ids, int accountId)
{
IQueryable<Channel> query = _dbSet
.AsNoTracking()
.Where(c => ids.Contains(c.Id))
.Include(c => c.CreatorAccount)
.Include(c => c.Group);
if (accountId > 0)
{
query = query
.Include(c => c.AccountChannels.Where(x => x.AccountId == accountId))
.AsSplitQuery();
}
return query;
}
public override async Task<Channel?> GetByIdAsync(int id)
@@ -90,6 +105,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -111,7 +127,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.CreatorAccountId != null)
.Where(c => c.IsBroadcast && c.CreatorAccountId != null)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -123,7 +139,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.CreatorAccountId == accountId)
.Where(c => c.IsBroadcast && c.CreatorAccountId == accountId)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -138,7 +154,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => links.Any(x => x.ChannelId == c.Id))
.Where(c => c.IsBroadcast && links.Any(x => x.ChannelId == c.Id))
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -150,7 +166,7 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.GroupId == groupId)
.Where(c => c.IsBroadcast && c.GroupId == groupId)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -181,13 +197,34 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var query = BuildForViewQuery(accountId, groupId, filterType, membershipRole, search);
var total = await query.CountAsync(cancellationToken);
var items = await query
var filteredQuery = BuildFilterQuery(accountId, groupId, filterType, membershipRole, search);
var total = await filteredQuery.CountAsync(cancellationToken);
if (total == 0)
return (Array.Empty<Channel>(), 0);
var pageIds = await filteredQuery
.OrderByDescending(c => c.SyncedAt)
.ThenByDescending(c => c.Id)
.Skip(pageIndex * pageSize)
.Take(pageSize)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
return (items, total);
if (pageIds.Count == 0)
return (Array.Empty<Channel>(), total);
var orderMap = pageIds
.Select((id, index) => new { id, index })
.ToDictionary(x => x.id, x => x.index);
var items = await BuildPagedDetailQuery(pageIds, accountId)
.ToListAsync(cancellationToken);
var orderedItems = items
.OrderBy(c => orderMap[c.Id])
.ToList();
return (orderedItems, total);
}
}
@@ -12,14 +12,10 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
}
private IQueryable<Group> BuildForViewQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search)
private IQueryable<Group> BuildFilterQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search)
{
var query = _dbSet
.AsNoTracking()
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.AsQueryable();
query = query.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any());
@@ -70,7 +66,25 @@ public class GroupRepository : Repository<Group>, IGroupRepository
|| (g.Category != null && EF.Functions.Like(g.Category.Name, like)));
}
return query.OrderByDescending(g => g.SyncedAt);
return query;
}
private IQueryable<Group> BuildPagedDetailQuery(IReadOnlyCollection<int> ids, int accountId)
{
IQueryable<Group> query = _dbSet
.AsNoTracking()
.Where(g => ids.Contains(g.Id))
.Include(g => g.CreatorAccount)
.Include(g => g.Category);
if (accountId > 0)
{
query = query
.Include(g => g.AccountGroups.Where(x => x.AccountId == accountId))
.AsSplitQuery();
}
return query;
}
public override async Task<Group?> GetByIdAsync(int id)
@@ -131,13 +145,34 @@ public class GroupRepository : Repository<Group>, IGroupRepository
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var query = BuildForViewQuery(accountId, categoryId, filterType, membershipRole, search);
var total = await query.CountAsync(cancellationToken);
var items = await query
var filteredQuery = BuildFilterQuery(accountId, categoryId, filterType, membershipRole, search);
var total = await filteredQuery.CountAsync(cancellationToken);
if (total == 0)
return (Array.Empty<Group>(), 0);
var pageIds = await filteredQuery
.OrderByDescending(g => g.SyncedAt)
.ThenByDescending(g => g.Id)
.Skip(pageIndex * pageSize)
.Take(pageSize)
.Select(g => g.Id)
.ToListAsync(cancellationToken);
return (items, total);
if (pageIds.Count == 0)
return (Array.Empty<Group>(), total);
var orderMap = pageIds
.Select((id, index) => new { id, index })
.ToDictionary(x => x.id, x => x.index);
var items = await BuildPagedDetailQuery(pageIds, accountId)
.ToListAsync(cancellationToken);
var orderedItems = items
.OrderBy(g => orderMap[g.Id])
.ToList();
return (orderedItems, total);
}
}
@@ -8,7 +8,10 @@ namespace TelegramPanel.Data.Repositories;
public interface IAccountChannelRepository : IRepository<AccountChannel>
{
Task<AccountChannel?> GetAsync(int accountId, int channelId);
Task<IReadOnlyList<AccountChannel>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AccountChannel>> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default);
Task UpsertAsync(AccountChannel link);
Task DeleteAsync(int accountId, int channelId);
Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepChannelIds);
Task<int?> GetPreferredAdminAccountIdAsync(int channelId);
}
@@ -8,7 +8,10 @@ namespace TelegramPanel.Data.Repositories;
public interface IAccountGroupRepository : IRepository<AccountGroup>
{
Task<AccountGroup?> GetAsync(int accountId, int groupId);
Task<IReadOnlyList<AccountGroup>> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AccountGroup>> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default);
Task UpsertAsync(AccountGroup link);
Task DeleteAsync(int accountId, int groupId);
Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection<int> keepGroupIds);
Task<int?> GetPreferredAdminAccountIdAsync(int groupId);
}
@@ -10,4 +10,5 @@ public interface IBatchTaskRepository : IRepository<BatchTask>
Task<IEnumerable<BatchTask>> GetByStatusAsync(string status);
Task<IEnumerable<BatchTask>> GetRunningTasksAsync();
Task<IEnumerable<BatchTask>> GetRecentTasksAsync(int count = 20);
Task<int> TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default);
}
@@ -59,7 +59,7 @@ public class Repository<T> : IRepository<T> where T : class
: await _dbSet.CountAsync(predicate);
}
private async Task SaveChangesWithSqliteLockRetryAsync()
protected async Task SaveChangesWithSqliteLockRetryAsync(CancellationToken cancellationToken = default)
{
// SQLite 在云端(容器/卷/后台任务)场景更容易遇到瞬时写锁;这里做有限重试来提升稳定性
const int maxAttempts = 5;
@@ -69,7 +69,7 @@ public class Repository<T> : IRepository<T> where T : class
{
try
{
await _context.SaveChangesAsync();
await _context.SaveChangesAsync(cancellationToken);
return;
}
catch (DbUpdateException ex) when (IsSqliteLock(ex))
@@ -85,16 +85,97 @@ public sealed class ModuleTaskDefinition
public string? CreateRoute { get; set; }
/// <summary>
/// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback&lt;ModuleTaskDraft&gt;)\n /// </summary>
/// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。
/// 该组件需要支持参数:
/// - Draft (ModuleTaskDraft)
/// - DraftChanged (EventCallback&lt;ModuleTaskDraft&gt;)
/// 编辑场景下还应支持可选参数:
/// - InitialConfigJson (string?)
/// </summary>
[JsonPropertyName("editorComponentType")]
public string? EditorComponentType { get; set; }
/// <summary>
/// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。
/// </summary>
[JsonPropertyName("taskCenter")]
public ModuleTaskCenterCapabilities TaskCenter { get; set; } = new();
[JsonPropertyName("order")]
public int Order { get; set; } = 0;
}
public sealed class ModuleTaskCenterCapabilities
{
[JsonPropertyName("canPause")]
public bool CanPause { get; set; }
[JsonPropertyName("canResume")]
public bool CanResume { get; set; }
[JsonPropertyName("canEdit")]
public bool CanEdit { get; set; }
[JsonPropertyName("canRerun")]
public bool CanRerun { get; set; }
/// <summary>
/// 编辑时使用的组件类型;为空时回退到 EditorComponentType。
/// </summary>
[JsonPropertyName("editComponentType")]
public string? EditComponentType { get; set; }
/// <summary>
/// 若任务仍在运行,打开编辑器前是否自动先暂停。
/// </summary>
[JsonPropertyName("autoPauseBeforeEdit")]
public bool AutoPauseBeforeEdit { get; set; }
}
public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError);
public sealed class ModuleTaskSnapshot
{
[JsonPropertyName("taskId")]
public int TaskId { get; set; }
[JsonPropertyName("taskType")]
public string TaskType { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("completed")]
public int Completed { get; set; }
[JsonPropertyName("failed")]
public int Failed { get; set; }
[JsonPropertyName("config")]
public string? Config { get; set; }
}
public sealed class ModuleTaskCreateRequest
{
[JsonPropertyName("taskType")]
public string TaskType { get; set; } = "";
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("config")]
public string? Config { get; set; }
}
public interface IModuleTaskRerunBuilder
{
string TaskType { get; }
ModuleTaskCreateRequest Build(ModuleTaskSnapshot task);
}
public interface IModuleApiProvider
{
IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context);
@@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost
Task<bool> IsStillRunningAsync(CancellationToken cancellationToken);
Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken);
}
@@ -0,0 +1,115 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject ChannelManagementService ChannelManagement
@inject ISnackbar Snackbar
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
@if (loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个频道</MudText>
@if (memberships.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该账号当前没有已同步的频道记录</MudAlert>
}
else
{
<MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>角色</MudTh>
<MudTh>频道分类</MudTh>
<MudTh>成员数</MudTh>
<MudTh>最后同步</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="频道名称">@context.Channel.Title</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Channel.Username)</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip>
</MudTd>
<MudTd DataLabel="频道分类">@(context.Channel.Group?.Name ?? "未分类")</MudTd>
<MudTd DataLabel="成员数">@context.Channel.MemberCount</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int AccountId { get; set; }
private bool loading = true;
private List<AccountChannel> memberships = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
memberships = (await ChannelManagement.GetAccountChannelMembershipsAsync(AccountId))
.Where(x => x.Channel != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenByDescending(x => x.SyncedAt)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号频道关系失败:{ex.Message}", Severity.Error);
memberships.Clear();
}
finally
{
loading = false;
}
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetRoleLabel(AccountChannel membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetRoleColor(AccountChannel membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private void Close()
{
MudDialog.Close();
}
}
@@ -0,0 +1,115 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject GroupManagementService GroupManagement
@inject ISnackbar Snackbar
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
@if (loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个群组</MudText>
@if (memberships.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该账号当前没有已同步的群组记录</MudAlert>
}
else
{
<MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>群组名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>角色</MudTh>
<MudTh>群组分类</MudTh>
<MudTh>成员数</MudTh>
<MudTh>最后同步</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="群组名称">@context.Group.Title</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Group.Username)</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip>
</MudTd>
<MudTd DataLabel="群组分类">@(context.Group.Category?.Name ?? "未分类")</MudTd>
<MudTd DataLabel="成员数">@context.Group.MemberCount</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int AccountId { get; set; }
private bool loading = true;
private List<AccountGroup> memberships = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
memberships = (await GroupManagement.GetAccountGroupMembershipsAsync(AccountId))
.Where(x => x.Group != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenByDescending(x => x.SyncedAt)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号群组关系失败:{ex.Message}", Severity.Error);
memberships.Clear();
}
finally
{
loading = false;
}
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetRoleLabel(AccountGroup membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetRoleColor(AccountGroup membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private void Close()
{
MudDialog.Close();
}
}
@@ -0,0 +1,89 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject TelegramPanel.Web.Services.AdminCredentialStore CredentialStore
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@Prompt
</MudAlert>
<MudTextField @bind-Value="password"
Label="后台密码"
Variant="Variant.Outlined"
InputType="@passwordInputType"
Adornment="Adornment.End"
AdornmentIcon="@passwordAdornmentIcon"
OnAdornmentClick="TogglePasswordVisibility"
Immediate="true"
Required="true"
AutoFocus="true" />
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" Disabled="@submitting" OnClick="Cancel">取消</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Error" Disabled="@submitting" OnClick="ConfirmAsync">@ConfirmText</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public string Prompt { get; set; } = "此操作需要输入后台密码确认";
[Parameter] public string ConfirmText { get; set; } = "确认";
private string password = string.Empty;
private string? errorMessage;
private bool submitting;
private bool showPassword;
private InputType passwordInputType => showPassword ? InputType.Text : InputType.Password;
private string passwordAdornmentIcon => showPassword ? Icons.Material.Filled.VisibilityOff : Icons.Material.Filled.Visibility;
private void TogglePasswordVisibility()
{
showPassword = !showPassword;
}
private void Cancel() => MudDialog.Cancel();
private async Task ConfirmAsync()
{
if (submitting)
return;
errorMessage = null;
var value = (password ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
{
errorMessage = "请输入后台密码";
return;
}
submitting = true;
try
{
var ok = await CredentialStore.ValidateAsync(CredentialStore.Username, value);
if (!ok)
{
errorMessage = "后台密码错误";
return;
}
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = $"校验失败:{ex.Message}";
}
finally
{
submitting = false;
}
}
}
@@ -4,6 +4,7 @@
@inject IChannelService ChannelService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Core.Utils
@using TelegramPanel.Data.Entities
<MudDialog>
@@ -85,6 +86,52 @@
<MudDivider />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.h6">本系统账号</MudText>
<MudSpacer />
<MudButton Variant="Variant.Text" Color="Color.Primary" Disabled="@systemAccountsLoading" OnClick="ShowSystemAccounts">
查看完整列表
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
Disabled="@systemAccountsLoading" OnClick="LoadSystemAccounts">
刷新
</MudButton>
</MudStack>
@if (systemAccountsLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (systemAccounts.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">暂无本系统账号关联</MudText>
}
else
{
<MudTable Items="@systemAccounts" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>昵称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>状态</MudTh>
<MudTh>角色</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd>
<MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip>
</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetMembershipColor(context)">@GetMembershipLabel(context)</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
}
<MudDivider />
<MudText Typo="Typo.h6">踢出成员</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
输入 @@用户名 执行踢出;“非永久”会使用短时间封禁达到踢出效果,随后自动解除,方便对方重新加入。
@@ -130,6 +177,8 @@
private bool adminsLoading;
private List<ChannelAdminInfo> admins = new();
private bool systemAccountsLoading;
private List<AccountChannel> systemAccounts = new();
private string kickUsername = "";
private bool kickPermanent;
@@ -139,6 +188,7 @@
{
await LoadChannel();
await LoadAdmins();
await LoadSystemAccounts();
}
private async Task LoadChannel()
@@ -202,6 +252,37 @@
}
}
private async Task LoadSystemAccounts()
{
systemAccountsLoading = true;
try
{
systemAccounts = (await ChannelManagement.GetChannelAccountMembershipsAsync(ChannelDbId))
.Where(x => x.Account != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenBy(x => x.AccountId)
.ToList();
}
catch (Exception ex)
{
systemAccounts.Clear();
Snackbar.Add($"加载本系统账号失败:{ex.Message}", Severity.Error);
}
finally
{
systemAccountsLoading = false;
}
}
private Task ShowSystemAccounts()
{
var parameters = new DialogParameters { ["ChannelDbId"] = ChannelDbId };
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<ChannelSystemAccountsDialog>("本系统账号", parameters, options);
return Task.CompletedTask;
}
private async Task KickUser()
{
if (channel == null)
@@ -243,9 +324,48 @@
{
await LoadChannel();
await LoadAdmins();
await LoadSystemAccounts();
}
}
private static string FormatPhone(string? phone)
{
return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone);
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetMembershipLabel(AccountChannel membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetMembershipColor(AccountChannel membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private static string GetAccountStatusLabel(Account? account)
{
return account?.IsActive == false ? "停用" : "启用";
}
private static Color GetAccountStatusColor(Account? account)
{
return account?.IsActive == false ? Color.Default : Color.Success;
}
private void Close()
{
MudDialog.Close();
@@ -0,0 +1,134 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject ChannelManagementService ChannelManagement
@inject ISnackbar Snackbar
@using TelegramPanel.Core.Utils
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
@if (loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个本系统账号</MudText>
@if (memberships.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该频道当前没有已同步的本系统账号关联</MudAlert>
}
else
{
<MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>昵称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>账号分类</MudTh>
<MudTh>状态</MudTh>
<MudTh>角色</MudTh>
<MudTh>最后同步</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd>
<MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd>
<MudTd DataLabel="账号分类">@(context.Account?.Category?.Name ?? "未分类")</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip>
</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip>
</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int ChannelDbId { get; set; }
private bool loading = true;
private List<AccountChannel> memberships = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
memberships = (await ChannelManagement.GetChannelAccountMembershipsAsync(ChannelDbId))
.Where(x => x.Account != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenBy(x => x.AccountId)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载频道关联账号失败:{ex.Message}", Severity.Error);
memberships.Clear();
}
finally
{
loading = false;
}
}
private static string FormatPhone(string? phone)
{
return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone);
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetRoleLabel(AccountChannel membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetRoleColor(AccountChannel membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private static string GetAccountStatusLabel(Account? account)
{
return account?.IsActive == false ? "停用" : "启用";
}
private static Color GetAccountStatusColor(Account? account)
{
return account?.IsActive == false ? Color.Default : Color.Success;
}
private void Close()
{
MudDialog.Close();
}
}
@@ -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)
@@ -0,0 +1,200 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject BatchTaskManagementService TaskManagement
@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.AspNetCore.Components
@using TelegramPanel.Data.Entities
@using TelegramPanel.Modules
@using TelegramPanel.Web.Modules
@using TelegramPanel.Web.Services
@using System.Reflection
<MudDialog>
<DialogContent>
@if (loading)
{
<MudStack Spacing="2">
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">正在加载任务配置...</MudText>
</MudStack>
}
else if (!string.IsNullOrWhiteSpace(loadError))
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Dense="true">
@loadError
</MudAlert>
}
else if (editorComponentType == null)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
该任务未提供可编辑组件。
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
编辑后会更新当前任务配置。若该任务已完成或失败,可继续使用“重新运行”创建新任务执行。
</MudAlert>
<DynamicComponent Type="editorComponentType" Parameters="editorParameters" />
@if (!string.IsNullOrWhiteSpace(draft.ValidationError))
{
<MudText Typo="Typo.caption" Color="Color.Error">@draft.ValidationError</MudText>
}
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@saving">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Save" Disabled="@(saving || !CanSave)">
@if (saving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>保存中...</span>
}
else
{
<span>保存配置</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int TaskId { get; set; }
private BatchTask? task;
private RegisteredTaskDefinition? taskDefinition;
private Type? editorComponentType;
private Dictionary<string, object?> editorParameters = new();
private ModuleTaskDraft draft = new(0, null, false, null);
private bool loading = true;
private bool saving;
private string? loadError;
private bool CanSave =>
task != null
&& taskDefinition != null
&& taskDefinition.Definition.TaskCenter.CanEdit
&& editorComponentType != null
&& !string.Equals(GetDisplayStatus(task), "running", StringComparison.OrdinalIgnoreCase)
&& draft.CanSubmit
&& !string.IsNullOrWhiteSpace(draft.Config);
protected override async Task OnInitializedAsync()
{
try
{
if (TaskId <= 0)
{
loadError = "任务不存在或参数无效。";
return;
}
task = await TaskManagement.GetTaskAsync(TaskId);
if (task == null)
{
loadError = "任务不存在或已被删除。";
return;
}
if (!Contributions.TaskTypeToDefinition.TryGetValue((task.TaskType ?? string.Empty).Trim(), out var reg))
{
loadError = "未找到该任务类型的定义,无法编辑。";
return;
}
taskDefinition = reg;
if (!reg.Definition.TaskCenter.CanEdit)
{
loadError = "该任务当前不支持编辑。";
return;
}
if (string.Equals(GetDisplayStatus(task), "running", StringComparison.OrdinalIgnoreCase))
{
loadError = "该任务正在执行中,请先暂停后再编辑。";
return;
}
editorComponentType = ModuleTaskEditorComponentResolver.ResolveEditEditor(reg);
if (editorComponentType == null)
return;
editorParameters = BuildEditorParameters(editorComponentType, task);
}
finally
{
loading = false;
}
}
private Dictionary<string, object?> BuildEditorParameters(Type componentType, BatchTask currentTask)
{
var parameters = new Dictionary<string, object?>
{
["Draft"] = draft,
["DraftChanged"] = EventCallback.Factory.Create<ModuleTaskDraft>(this, OnDraftChanged)
};
AddOptionalParameter(parameters, componentType, "InitialConfigJson", currentTask.Config);
AddOptionalParameter(parameters, componentType, "TaskId", currentTask.Id);
return parameters;
}
private static void AddOptionalParameter(Dictionary<string, object?> parameters, Type componentType, string parameterName, object? value)
{
var property = componentType.GetProperty(parameterName, BindingFlags.Public | BindingFlags.Instance);
if (property == null)
return;
parameters[parameterName] = value;
}
private static string GetDisplayStatus(BatchTask task)
{
return task.CompletedAt.HasValue
? "completed"
: (task.Status ?? string.Empty).Trim().ToLowerInvariant();
}
private void OnDraftChanged(ModuleTaskDraft value)
{
draft = value;
}
private async Task Save()
{
if (!CanSave || task == null || saving)
return;
bool? confirm = await DialogService.ShowMessageBox(
"确认保存",
$"将更新任务 #{task.Id} 的配置,是否继续?",
yesText: "保存", cancelText: "取消");
if (confirm != true)
return;
saving = true;
try
{
var config = draft.Config?.Trim();
await TaskManagement.UpdateTaskDraftAsync(task.Id, draft.Total < 0 ? 0 : draft.Total, config);
Snackbar.Add($"任务 #{task.Id} 配置已更新", Severity.Success);
MudDialog.Close(DialogResult.Ok(task.Id));
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
finally
{
saving = false;
}
}
private void Cancel() => MudDialog.Close();
}
@@ -3,7 +3,9 @@
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Core.Interfaces.IGroupService GroupService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JS
@using TelegramPanel.Core.Utils
@using TelegramPanel.Data.Entities
@using TelegramPanel.Core.Models
@@ -83,6 +85,52 @@
</RowTemplate>
</MudTable>
}
<MudDivider />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.h6">本系统账号</MudText>
<MudSpacer />
<MudButton Variant="Variant.Text" Color="Color.Primary" Disabled="@systemAccountsLoading" OnClick="ShowSystemAccounts">
查看完整列表
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
Disabled="@systemAccountsLoading" OnClick="LoadSystemAccounts">
刷新
</MudButton>
</MudStack>
@if (systemAccountsLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (systemAccounts.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">暂无本系统账号关联</MudText>
}
else
{
<MudTable Items="@systemAccounts" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>昵称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>状态</MudTh>
<MudTh>角色</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd>
<MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip>
</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetMembershipColor(context)">@GetMembershipLabel(context)</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
}
</DialogContent>
@@ -107,11 +155,14 @@
private bool adminsLoading;
private List<ChannelAdminInfo> admins = new();
private bool systemAccountsLoading;
private List<AccountGroup> systemAccounts = new();
protected override async Task OnInitializedAsync()
{
await LoadGroup();
await LoadAdmins();
await LoadSystemAccounts();
}
private async Task LoadGroup()
@@ -175,6 +226,37 @@
}
}
private async Task LoadSystemAccounts()
{
systemAccountsLoading = true;
try
{
systemAccounts = (await GroupManagement.GetGroupAccountMembershipsAsync(GroupDbId))
.Where(x => x.Account != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenBy(x => x.AccountId)
.ToList();
}
catch (Exception ex)
{
systemAccounts.Clear();
Snackbar.Add($"加载本系统账号失败:{ex.Message}", Severity.Error);
}
finally
{
systemAccountsLoading = false;
}
}
private Task ShowSystemAccounts()
{
var parameters = new DialogParameters { ["GroupDbId"] = GroupDbId };
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<GroupSystemAccountsDialog>("本系统账号", parameters, options);
return Task.CompletedTask;
}
private async Task CopyLink()
{
if (group == null)
@@ -198,6 +280,44 @@
}
}
private static string FormatPhone(string? phone)
{
return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone);
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetMembershipLabel(AccountGroup membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetMembershipColor(AccountGroup membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private static string GetAccountStatusLabel(Account? account)
{
return account?.IsActive == false ? "停用" : "启用";
}
private static Color GetAccountStatusColor(Account? account)
{
return account?.IsActive == false ? Color.Default : Color.Success;
}
private void Close()
{
MudDialog.Close();
@@ -0,0 +1,134 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject GroupManagementService GroupManagement
@inject ISnackbar Snackbar
@using TelegramPanel.Core.Utils
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
@if (loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个本系统账号</MudText>
@if (memberships.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该群组当前没有已同步的本系统账号关联</MudAlert>
}
else
{
<MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>昵称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>账号分类</MudTh>
<MudTh>状态</MudTh>
<MudTh>角色</MudTh>
<MudTh>最后同步</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@FormatPhone(context.Account?.Phone)</MudTd>
<MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname)</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Account?.Username)</MudTd>
<MudTd DataLabel="账号分类">@(context.Account?.Category?.Name ?? "未分类")</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@GetAccountStatusColor(context.Account)">@GetAccountStatusLabel(context.Account)</MudChip>
</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip>
</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int GroupDbId { get; set; }
private bool loading = true;
private List<AccountGroup> memberships = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
memberships = (await GroupManagement.GetGroupAccountMembershipsAsync(GroupDbId))
.Where(x => x.Account != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenBy(x => x.AccountId)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载群组关联账号失败:{ex.Message}", Severity.Error);
memberships.Clear();
}
finally
{
loading = false;
}
}
private static string FormatPhone(string? phone)
{
return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone);
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetRoleLabel(AccountGroup membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetRoleColor(AccountGroup membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private static string GetAccountStatusLabel(Account? account)
{
return account?.IsActive == false ? "停用" : "启用";
}
private static Color GetAccountStatusColor(Account? account)
{
return account?.IsActive == false ? Color.Default : Color.Success;
}
private void Close()
{
MudDialog.Close();
}
}
@@ -20,6 +20,7 @@
<MudNavGroup Title="群组管理" Icon="@Icons.Material.Filled.Group">
<MudNavLink Href="/groups" Icon="@Icons.Material.Filled.List">群组列表</MudNavLink>
<MudNavLink Href="/groups/create" Icon="@Icons.Material.Filled.Add">创建群组</MudNavLink>
<MudNavLink Href="/groups/categories" Icon="@Icons.Material.Filled.Folder">群组分类</MudNavLink>
</MudNavGroup>
@@ -115,6 +115,7 @@
<MudTh>频道/群组</MudTh>
<MudTh>状态</MudTh>
<MudTh>Telegram 状态</MudTh>
<MudTh>注册时间</MudTh>
<MudTh>最后数据同步</MudTh>
<MudTh Style="min-width: 180px;">操作</MudTh>
</HeaderContent>
@@ -155,6 +156,7 @@
<MudChip T="string" Size="Size.Small" Color="Color.Default">未检测</MudChip>
}
</MudTd>
<MudTd DataLabel="注册时间"><PanelTime Value="context.CreatedAt" /></MudTd>
<MudTd DataLabel="最后数据同步"><PanelTime Value="context.LastSyncAt" /></MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
@@ -168,6 +170,8 @@
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.GroupAdd" Disabled="@loading" OnClick="@(() => OpenJoinSubscribe(context))">加群/订阅</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" Disabled="@loading" OnClick="@(() => OpenLeaveUnsubscribe(context))">退群/退订</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Campaign" Disabled="@loading" OnClick="@(() => OpenAccountChannels(context))">查看加入的频道</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Forum" Disabled="@loading" OnClick="@(() => OpenAccountGroups(context))">查看加入的群组</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.MarkEmailUnread" Disabled="@loading" OnClick="@(() => OpenSystemInbox(context))">系统通知(验证码)</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Devices" Disabled="@loading" OnClick="@(() => OpenDevices(context))">在线设备</MudMenuItem>
<MudDivider />
@@ -322,6 +326,28 @@
await ReloadTable();
}
private Task OpenAccountChannels(Account account)
{
var parameters = new DialogParameters
{
["AccountId"] = account.Id
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.AccountChannelMembershipsDialog>("加入的频道", parameters, options);
return Task.CompletedTask;
}
private Task OpenAccountGroups(Account account)
{
var parameters = new DialogParameters
{
["AccountId"] = account.Id
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.AccountGroupMembershipsDialog>("加入的群组", parameters, options);
return Task.CompletedTask;
}
private async Task OpenChangeTwoFactorPassword(Account account)
{
if (loading)
@@ -76,6 +76,9 @@
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="BatchExportInvitesSelected">批量导出邀请链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetAdminsSelected">批量设置管理员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分类(已选)</MudMenuItem>
<MudDivider />
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="LeaveSelectedChannels">批量退出频道(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="DisbandSelectedChannels">批量解散频道(已选)</MudMenuItem>
</MudMenu>
@if (selectedChannels.Count > 0)
@@ -174,8 +177,11 @@
<MudMenuItem Icon="@Icons.Material.Filled.PersonAdd" OnClick="@(async () => await OpenInvite(context))">邀请成员</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.AdminPanelSettings" OnClick="@(async () => await OpenSetAdmins(context))">设置管理员</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.PersonRemove" OnClick="@(async () => await KickUser(context))">踢人</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.People" OnClick="@(async () => await ShowSystemAccounts(context))">本系统账号</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyLinkAsync(context))">复制链接</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" OnClick="@(async () => await LeaveChannel(context))">退出频道</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="@(async () => await DisbandChannel(context))">解散频道</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(() => DeleteChannel(context.Id))">删除频道</MudMenuItem>
</MudMenu>
</MudStack>
@@ -195,6 +201,9 @@
</MudCard>
@code {
[SupplyParameterFromQuery(Name = "accountId")]
public int? QueryAccountId { get; set; }
private MudTable<Channel>? _table;
private List<Channel> channels = new();
private List<Account> accounts = new();
@@ -207,6 +216,7 @@
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet<Channel> selectedChannels = new();
private int? _lastAppliedQueryAccountId;
private CancellationTokenSource? _searchDebounceCts;
private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
@@ -232,6 +242,19 @@
await LoadGroups();
}
protected override async Task OnParametersSetAsync()
{
var queryAccountId = QueryAccountId.GetValueOrDefault();
if (_lastAppliedQueryAccountId == queryAccountId)
return;
_lastAppliedQueryAccountId = queryAccountId;
filterAccount = queryAccountId > 0 ? queryAccountId : 0;
if (_table != null)
await ReloadTable();
}
private async Task LoadAccounts()
{
try
@@ -679,6 +702,14 @@
await ReloadTable();
}
private async Task ShowSystemAccounts(Channel channel)
{
var parameters = new DialogParameters { ["ChannelDbId"] = channel.Id };
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<ChannelSystemAccountsDialog>("本系统账号", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchSetGroupSelected()
{
var targets = selectedChannels.ToList();
@@ -735,9 +766,7 @@
{
try
{
var executeAccountId = await ChannelManagement.ResolveExecuteAccountIdAsync(
channel,
preferredAccountId: filterAccount > 0 ? filterAccount : null);
var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel);
if (executeAccountId is not > 0)
{
Snackbar.Add("无法导出链接:该频道暂无可用执行账号,请先同步对应账号的频道数据", Severity.Warning);
@@ -755,6 +784,208 @@
}
}
private async Task<int?> ResolveChannelExecuteAccountIdAsync(Channel channel)
{
return await ChannelManagement.ResolveExecuteAccountIdAsync(
channel,
preferredAccountId: filterAccount > 0 ? filterAccount : null);
}
private async Task<int?> ResolveChannelDisbandAccountIdAsync(Channel channel)
{
if (filterAccount > 0 && channel.CreatorAccountId == filterAccount)
return filterAccount;
if (channel.CreatorAccountId is > 0)
return channel.CreatorAccountId.Value;
return await ResolveChannelExecuteAccountIdAsync(channel);
}
private async Task<bool> ConfirmAdminPasswordAsync(string prompt, string confirmText)
{
var parameters = new DialogParameters
{
["Prompt"] = prompt,
["ConfirmText"] = confirmText
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<AdminPasswordConfirmDialog>("后台密码确认", parameters, options);
var result = await dialog.Result;
return result is { Canceled: false };
}
private void ShowBatchFailureWarnings(IReadOnlyList<string> failures, string actionName)
{
foreach (var failure in failures.Take(3))
Snackbar.Add($"{actionName}失败:{failure}", Severity.Warning);
if (failures.Count > 3)
Snackbar.Add($"另有 {failures.Count - 3} 条{actionName}失败,请重试或逐个处理", Severity.Warning);
}
private async Task LeaveChannel(Channel channel)
{
bool? result = await DialogService.ShowMessageBox(
"确认退出",
$"确定要让执行账号退出频道 {channel.Title} 吗?若系统内其他账号仍在该频道中,频道记录可能继续显示。",
yesText: "退出", cancelText: "取消");
if (result != true)
return;
try
{
var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel);
if (executeAccountId is not > 0)
{
Snackbar.Add("退出失败:该频道暂无可用执行账号", Severity.Warning);
return;
}
await ChannelService.LeaveChannelAsync(executeAccountId.Value, channel.TelegramId);
await ChannelManagement.RemoveAccountChannelAsync(channel.Id, executeAccountId.Value);
await ReloadTable();
Snackbar.Add($"已退出频道:{channel.Title}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"退出失败:{ex.Message}", Severity.Error);
}
}
private async Task LeaveSelectedChannels()
{
var targets = selectedChannels.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何频道", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认批量退出",
$"确定要让执行账号退出选中的 {targets.Count} 个频道吗?若系统内其他账号仍在这些频道中,频道记录可能继续显示。",
yesText: "退出", cancelText: "取消");
if (result != true)
return;
loading = true;
var successCount = 0;
var failures = new List<string>();
try
{
foreach (var channel in targets)
{
try
{
var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel);
if (executeAccountId is not > 0)
throw new InvalidOperationException("暂无可用执行账号");
await ChannelService.LeaveChannelAsync(executeAccountId.Value, channel.TelegramId);
await ChannelManagement.RemoveAccountChannelAsync(channel.Id, executeAccountId.Value);
successCount++;
}
catch (Exception ex)
{
failures.Add($"{channel.Title}:{ex.Message}");
}
}
selectedChannels.Clear();
await ReloadTable();
Snackbar.Add(
$"批量退出完成:成功 {successCount} 个,失败 {failures.Count} 个",
failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error);
ShowBatchFailureWarnings(failures, "退出频道");
}
finally
{
loading = false;
}
}
private async Task DisbandChannel(Channel channel)
{
var confirmed = await ConfirmAdminPasswordAsync(
$"确定要解散频道 {channel.Title} 吗?此操作会在 Telegram 侧删除频道,并从本地面板移除记录。",
"确认解散");
if (!confirmed)
return;
try
{
var executeAccountId = await ResolveChannelDisbandAccountIdAsync(channel);
if (executeAccountId is not > 0)
{
Snackbar.Add("解散失败:该频道暂无可用执行账号", Severity.Warning);
return;
}
await ChannelService.DisbandChannelAsync(executeAccountId.Value, channel.TelegramId);
await ChannelManagement.DeleteChannelAsync(channel.Id);
await ReloadTable();
Snackbar.Add($"已解散频道:{channel.Title}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"解散失败:{ex.Message}", Severity.Error);
}
}
private async Task DisbandSelectedChannels()
{
var targets = selectedChannels.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何频道", Severity.Info);
return;
}
var confirmed = await ConfirmAdminPasswordAsync(
$"确定要解散选中的 {targets.Count} 个频道吗?此操作会在 Telegram 侧删除频道,并从本地面板移除记录。",
"确认批量解散");
if (!confirmed)
return;
loading = true;
var successCount = 0;
var failures = new List<string>();
try
{
foreach (var channel in targets)
{
try
{
var executeAccountId = await ResolveChannelDisbandAccountIdAsync(channel);
if (executeAccountId is not > 0)
throw new InvalidOperationException("暂无可用执行账号");
await ChannelService.DisbandChannelAsync(executeAccountId.Value, channel.TelegramId);
await ChannelManagement.DeleteChannelAsync(channel.Id);
successCount++;
}
catch (Exception ex)
{
failures.Add($"{channel.Title}:{ex.Message}");
}
}
selectedChannels.Clear();
await ReloadTable();
Snackbar.Add(
$"批量解散完成:成功 {successCount} 个,失败 {failures.Count} 个",
failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error);
ShowBatchFailureWarnings(failures, "解散频道");
}
finally
{
loading = false;
}
}
private async Task DeleteChannel(int id)
{
var channel = channels.FirstOrDefault(c => c.Id == id);
@@ -0,0 +1,224 @@
@page "/groups/create"
@inject IGroupService GroupService
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject GroupManagementService GroupManagement
@inject GroupCategoryManagementService GroupCategoryManagement
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
@using TelegramPanel.Web.Components.Dialogs
<PageTitle>创建群组 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">创建群组</MudText>
<MudGrid>
<MudItem xs="12" md="8" Class="mx-auto">
<MudCard>
<MudCardContent>
<MudSelect @bind-Value="selectedAccountId" Label="选择账号" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">-- 请选择账号 --</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="selectedCategoryId" Label="群组分类" Variant="Variant.Outlined" Class="mt-4">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="groupTitle" Label="群组名称" Variant="Variant.Outlined" Required="true" Class="mt-4"
HelperText="最多255个字符" MaxLength="255" />
<MudTextField @bind-Value="groupAbout" Label="群组简介" Variant="Variant.Outlined" Lines="3" Class="mt-4"
HelperText="最多255个字符" MaxLength="255" />
<MudSwitch @bind-Value="isPublic" Label="设为公开群组" Color="Color.Primary" Class="mt-4" />
@if (isPublic)
{
<MudTextField @bind-Value="username" Label="群组用户名" Variant="Variant.Outlined" Class="mt-3"
Adornment="Adornment.Start" AdornmentText="&#64;" Required="true"
HelperText="5-32个字符,只能包含 a-z、0-9 和下划线" />
}
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mt-4">
当前创建的是 Telegram 超级群组。创建成功后会自动写入本地数据库,并绑定到当前执行账号。
</MudAlert>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Text" Color="Color.Default"
OnClick="@(async (MouseEventArgs _) => await Cancel())">取消</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(selectedAccountId == 0 || string.IsNullOrWhiteSpace(groupTitle) || creating)"
OnClick="@(async (MouseEventArgs _) => await CreateGroup())">
@if (creating)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>创建中...</span>
}
else
{
<span>创建群组</span>
}
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
</MudGrid>
@code {
private List<Account> accounts = new();
private List<GroupCategory> categories = new();
private int selectedAccountId;
private int selectedCategoryId;
private string groupTitle = string.Empty;
private string groupAbout = string.Empty;
private bool isPublic;
private string username = string.Empty;
private bool creating;
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadCategories();
}
private async Task LoadAccounts()
{
try
{
var accountList = await AccountManagement.GetActiveAccountsAsync();
accounts = accountList
.Where(a => a.Category?.ExcludeFromOperations != true)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号列表失败:{ex.Message}", Severity.Error);
}
}
private async Task LoadCategories()
{
try
{
var list = await GroupCategoryManagement.GetAllCategoriesAsync();
categories = list.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载群组分类失败:{ex.Message}", Severity.Error);
}
}
private async Task CreateGroup()
{
if (selectedAccountId == 0)
{
Snackbar.Add("请选择账号", Severity.Error);
return;
}
var title = groupTitle.Trim();
if (string.IsNullOrWhiteSpace(title))
{
Snackbar.Add("请输入群组名称", Severity.Error);
return;
}
var about = string.IsNullOrWhiteSpace(groupAbout) ? string.Empty : groupAbout.Trim();
var normalizedUsername = NormalizeUsername(username);
if (isPublic && string.IsNullOrWhiteSpace(normalizedUsername))
{
Snackbar.Add("公开群组需要设置用户名", Severity.Error);
return;
}
var account = await AccountManagement.GetAccountAsync(selectedAccountId);
if (account != null)
{
var riskCheck = RiskService.CheckLoginDuration(account);
if (riskCheck.IsRisky)
{
var parameters = new DialogParameters
{
["Message"] = riskCheck.Message,
["DetailedMessage"] = riskCheck.DetailedMessage,
["ShowRecommendations"] = true
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium };
var dialog = DialogService.Show<RiskWarningDialog>("风控警告", parameters, options);
var result = await dialog.Result;
if (result.Canceled)
return;
}
}
creating = true;
try
{
var groupInfo = await GroupService.CreateGroupAsync(selectedAccountId, title, about, isPublic, normalizedUsername);
var now = DateTime.UtcNow;
var group = new Group
{
TelegramId = groupInfo.TelegramId,
AccessHash = groupInfo.AccessHash,
Title = groupInfo.Title,
Username = groupInfo.Username,
MemberCount = Math.Max(groupInfo.MemberCount, 1),
About = groupInfo.About,
CreatorAccountId = selectedAccountId,
CategoryId = selectedCategoryId > 0 ? selectedCategoryId : null,
CreatedAt = groupInfo.CreatedAt ?? now,
SyncedAt = now
};
var savedGroup = await GroupManagement.CreateOrUpdateGroupAsync(group);
await GroupManagement.UpsertAccountGroupAsync(selectedAccountId, savedGroup.Id, isCreator: true, isAdmin: true, syncedAtUtc: now);
Snackbar.Add($"群组 \"{title}\" 创建成功", Severity.Success);
Navigation.NavigateTo("/groups");
}
catch (Exception ex)
{
Snackbar.Add($"创建失败:{ex.Message}", Severity.Error);
}
finally
{
creating = false;
}
}
private Task Cancel()
{
Navigation.NavigateTo("/groups");
return Task.CompletedTask;
}
private static string? NormalizeUsername(string? value)
{
var normalized = value?.Trim().TrimStart('@');
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}
@@ -60,6 +60,15 @@
@SelectionToggleText
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
Href="/groups/create" Size="Size.Small">
创建群组
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.Folder"
Href="/groups/categories" Size="Size.Small">
分类管理
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync"
Disabled="@(loading || filterAccount <= 0)" OnClick="SyncCurrentAccountGroups" Size="Size.Small">
同步当前
@@ -75,6 +84,8 @@
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="BatchExportLinksSelected">批量导出加入链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="OpenBatchSetCategorySelected">批量修改分类(已选)</MudMenuItem>
<MudDivider />
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="LeaveSelectedGroups">批量退出群组(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="DisbandSelectedGroups">批量解散群组(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="DeleteSelectedGroups">批量删除(已选)</MudMenuItem>
</MudMenu>
@@ -169,8 +180,11 @@
<MudIconButton Icon="@Icons.Material.Filled.Info" Size="Size.Small" Color="Color.Info"
OnClick="@(async () => await ShowDetails(context))" />
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.People" OnClick="@(async () => await ShowSystemAccounts(context))">本系统账号</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyLinkAsync(context))">复制链接</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" OnClick="@(async () => await LeaveGroup(context))">退出群组</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Warning" IconColor="Color.Error" OnClick="@(async () => await DisbandGroup(context))">解散群组</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(() => DeleteGroup(context.Id))">删除群组</MudMenuItem>
</MudMenu>
</MudStack>
@@ -190,6 +204,9 @@
</MudCard>
@code {
[SupplyParameterFromQuery(Name = "accountId")]
public int? QueryAccountId { get; set; }
private MudTable<Group>? _table;
private List<Group> groups = new();
private List<Account> accounts = new();
@@ -202,6 +219,7 @@
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet<Group> selectedGroups = new();
private int? _lastAppliedQueryAccountId;
private CancellationTokenSource? _searchDebounceCts;
private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
@@ -227,6 +245,19 @@
await LoadCategories();
}
protected override async Task OnParametersSetAsync()
{
var queryAccountId = QueryAccountId.GetValueOrDefault();
if (_lastAppliedQueryAccountId == queryAccountId)
return;
_lastAppliedQueryAccountId = queryAccountId;
filterAccount = queryAccountId > 0 ? queryAccountId : 0;
if (_table != null)
await ReloadTable();
}
private async Task LoadAccounts()
{
try
@@ -627,13 +658,19 @@
await Task.CompletedTask;
}
private async Task ShowSystemAccounts(Group group)
{
var parameters = new DialogParameters { ["GroupDbId"] = group.Id };
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<GroupSystemAccountsDialog>("本系统账号", parameters, options);
await Task.CompletedTask;
}
private async Task CopyLinkAsync(Group group)
{
try
{
var executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync(
group,
preferredAccountId: filterAccount > 0 ? filterAccount : null);
var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group);
if (executeAccountId is not > 0)
{
Snackbar.Add("无法导出链接:该群组暂无可用执行账号,请先同步对应账号的群组数据", Severity.Warning);
@@ -650,6 +687,208 @@
}
}
private async Task<int?> ResolveGroupExecuteAccountIdAsync(Group group)
{
return await GroupManagement.ResolveExecuteAccountIdAsync(
group,
preferredAccountId: filterAccount > 0 ? filterAccount : null);
}
private async Task<int?> ResolveGroupDisbandAccountIdAsync(Group group)
{
if (filterAccount > 0 && group.CreatorAccountId == filterAccount)
return filterAccount;
if (group.CreatorAccountId is > 0)
return group.CreatorAccountId.Value;
return await ResolveGroupExecuteAccountIdAsync(group);
}
private async Task<bool> ConfirmAdminPasswordAsync(string prompt, string confirmText)
{
var parameters = new DialogParameters
{
["Prompt"] = prompt,
["ConfirmText"] = confirmText
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<AdminPasswordConfirmDialog>("后台密码确认", parameters, options);
var result = await dialog.Result;
return result is { Canceled: false };
}
private void ShowBatchFailureWarnings(IReadOnlyList<string> failures, string actionName)
{
foreach (var failure in failures.Take(3))
Snackbar.Add($"{actionName}失败:{failure}", Severity.Warning);
if (failures.Count > 3)
Snackbar.Add($"另有 {failures.Count - 3} 条{actionName}失败,请重试或逐个处理", Severity.Warning);
}
private async Task LeaveGroup(Group group)
{
bool? result = await DialogService.ShowMessageBox(
"确认退出",
$"确定要让执行账号退出群组 {group.Title} 吗?若系统内其他账号仍在该群组中,群组记录可能继续显示。",
yesText: "退出", cancelText: "取消");
if (result != true)
return;
try
{
var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group);
if (executeAccountId is not > 0)
{
Snackbar.Add("退出失败:该群组暂无可用执行账号", Severity.Warning);
return;
}
await GroupService.LeaveGroupAsync(executeAccountId.Value, group.TelegramId);
await GroupManagement.RemoveAccountGroupAsync(group.Id, executeAccountId.Value);
await ReloadTable();
Snackbar.Add($"已退出群组:{group.Title}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"退出失败:{ex.Message}", Severity.Error);
}
}
private async Task LeaveSelectedGroups()
{
var targets = selectedGroups.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认批量退出",
$"确定要让执行账号退出选中的 {targets.Count} 个群组吗?若系统内其他账号仍在这些群组中,群组记录可能继续显示。",
yesText: "退出", cancelText: "取消");
if (result != true)
return;
loading = true;
var successCount = 0;
var failures = new List<string>();
try
{
foreach (var group in targets)
{
try
{
var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group);
if (executeAccountId is not > 0)
throw new InvalidOperationException("暂无可用执行账号");
await GroupService.LeaveGroupAsync(executeAccountId.Value, group.TelegramId);
await GroupManagement.RemoveAccountGroupAsync(group.Id, executeAccountId.Value);
successCount++;
}
catch (Exception ex)
{
failures.Add($"{group.Title}:{ex.Message}");
}
}
selectedGroups.Clear();
await ReloadTable();
Snackbar.Add(
$"批量退出完成:成功 {successCount} 个,失败 {failures.Count} 个",
failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error);
ShowBatchFailureWarnings(failures, "退出群组");
}
finally
{
loading = false;
}
}
private async Task DisbandGroup(Group group)
{
var confirmed = await ConfirmAdminPasswordAsync(
$"确定要解散群组 {group.Title} 吗?此操作会在 Telegram 侧删除群组,并从本地面板移除记录。",
"确认解散");
if (!confirmed)
return;
try
{
var executeAccountId = await ResolveGroupDisbandAccountIdAsync(group);
if (executeAccountId is not > 0)
{
Snackbar.Add("解散失败:该群组暂无可用执行账号", Severity.Warning);
return;
}
await GroupService.DisbandGroupAsync(executeAccountId.Value, group.TelegramId);
await GroupManagement.DeleteGroupAsync(group.Id);
await ReloadTable();
Snackbar.Add($"已解散群组:{group.Title}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"解散失败:{ex.Message}", Severity.Error);
}
}
private async Task DisbandSelectedGroups()
{
var targets = selectedGroups.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
var confirmed = await ConfirmAdminPasswordAsync(
$"确定要解散选中的 {targets.Count} 个群组吗?此操作会在 Telegram 侧删除群组,并从本地面板移除记录。",
"确认批量解散");
if (!confirmed)
return;
loading = true;
var successCount = 0;
var failures = new List<string>();
try
{
foreach (var group in targets)
{
try
{
var executeAccountId = await ResolveGroupDisbandAccountIdAsync(group);
if (executeAccountId is not > 0)
throw new InvalidOperationException("暂无可用执行账号");
await GroupService.DisbandGroupAsync(executeAccountId.Value, group.TelegramId);
await GroupManagement.DeleteGroupAsync(group.Id);
successCount++;
}
catch (Exception ex)
{
failures.Add($"{group.Title}:{ex.Message}");
}
}
selectedGroups.Clear();
await ReloadTable();
Snackbar.Add(
$"批量解散完成:成功 {successCount} 个,失败 {failures.Count} 个",
failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error);
ShowBatchFailureWarnings(failures, "解散群组");
}
finally
{
loading = false;
}
}
private async Task DeleteSelectedGroups()
{
var targets = selectedGroups.ToList();
@@ -7,6 +7,7 @@
@inject IGroupService GroupService
@inject ITelegramClientPool TelegramClientPool
@inject TelegramPanel.Web.Services.DataSyncService DataSync
@inject BatchTaskManagementService TaskManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IWebHostEnvironment Environment
@@ -90,6 +91,8 @@
Min="1000" Max="10000" HelperText="建议2000-5000ms" />
<MudNumericField @bind-Value="maxConcurrent" Label="最大并发任务数" Variant="Variant.Outlined" Class="mt-3"
Min="1" Max="10" HelperText="同时执行的任务数量" />
<MudNumericField @bind-Value="historyRetentionLimit" Label="历史任务保留上限" Variant="Variant.Outlined" Class="mt-3"
Min="0" Max="5000" HelperText="仅保留最新的已完成/失败任务,0 表示不限制" />
<MudSwitch @bind-Value="autoRetry" Label="失败自动重试" Color="Color.Primary" Class="mt-3" />
@if (autoRetry)
{
@@ -265,6 +268,7 @@
private string apiHash = "";
private int defaultDelay = 2000;
private int maxConcurrent = 1;
private int historyRetentionLimit = 0;
private bool autoRetry = false;
private int maxRetries = 3;
private bool autoSync = true;
@@ -314,6 +318,7 @@
apiHash = Configuration["Telegram:ApiHash"] ?? "";
defaultDelay = Configuration.GetValue("Telegram:DefaultDelayMs", 2000);
maxConcurrent = Configuration.GetValue("BatchTasks:MaxConcurrent", 1);
historyRetentionLimit = Configuration.GetValue("BatchTasks:HistoryRetentionLimit", 0);
maxRetries = Configuration.GetValue("Telegram:MaxRetries", 0);
autoRetry = maxRetries > 0;
if (maxRetries <= 0) maxRetries = 3;
@@ -567,6 +572,12 @@
return;
}
if (historyRetentionLimit < 0 || historyRetentionLimit > 5000)
{
Snackbar.Add("历史任务保留上限范围应为 0-5000", Severity.Error);
return;
}
try
{
var root = await LoadOrCreateLocalConfigAsync();
@@ -577,9 +588,19 @@
var batchTasks = EnsureObject(root, "BatchTasks");
batchTasks["MaxConcurrent"] = maxConcurrent;
batchTasks["HistoryRetentionLimit"] = historyRetentionLimit;
await SaveLocalConfigAsync(root);
Snackbar.Add("批量操作配置已保存(配置热重载后生效)", Severity.Success);
var deletedCount = historyRetentionLimit > 0
? await TaskManagement.TrimHistoryTasksAsync(historyRetentionLimit)
: 0;
var message = deletedCount > 0
? $"批量操作配置已保存,并已清理 {deletedCount} 条超出上限的历史任务"
: "批量操作配置已保存(配置热重载后生效)";
Snackbar.Add(message, Severity.Success);
StateHasChanged();
}
catch (Exception ex)
@@ -639,21 +660,16 @@
}
syncing = true;
Snackbar.Add("开始同步频道和群组信息...", Severity.Info);
Snackbar.Add("正在后台创建同步任务...", Severity.Info);
try
{
var run = await DataSync.RunAllActiveAccountsTrackedAsync("manual", CancellationToken.None);
var summary = run.Summary;
foreach (var failure in summary.AccountFailures)
Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning);
Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组。任务 #{run.TaskId} 已记录到任务中心", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning);
var taskId = await DataSync.StartAllActiveAccountsTrackedInBackgroundAsync("manual", CancellationToken.None);
Snackbar.Add($"已启动后台同步任务 #{taskId},请到任务中心查看进度", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"同步失败:{ex.Message}", Severity.Error);
Snackbar.Add($"同步启动失败:{ex.Message}", Severity.Error);
}
finally
{
@@ -1,6 +1,7 @@
using MudBlazor;
using TelegramPanel.Core.BatchTasks;
using TelegramPanel.Modules;
using MudBlazor;
using TelegramPanel.Web.Services;
namespace TelegramPanel.Web.Modules.BuiltIn;
@@ -23,6 +24,7 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
{
// 内置任务的执行由宿主 BatchTaskBackgroundService 负责;这里只提供“元数据”用于 UI 展示与创建。
services.AddSingleton<IModuleTaskRerunBuilder, UserChatActiveTaskRerunBuilder>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
@@ -40,6 +42,15 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide
Description = "按账号分类持续向指定目标发送词典内容,支持间隔抖动、随机/队列循环。",
Icon = Icons.Material.Filled.Chat,
EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "",
TaskCenter = new ModuleTaskCenterCapabilities
{
CanPause = true,
CanResume = true,
CanEdit = true,
CanRerun = true,
EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "",
AutoPauseBeforeEdit = true
},
Order = 120
};
@@ -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;
}
+117 -57
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.BatchTasks;
using TelegramPanel.Core.Interfaces;
@@ -23,6 +24,7 @@ public class DataSyncService
private readonly IGroupService _groupService;
private readonly AccountTelegramToolsService _telegramTools;
private readonly BatchTaskManagementService _taskManagement;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<DataSyncService> _logger;
@@ -34,6 +36,7 @@ public class DataSyncService
IGroupService groupService,
AccountTelegramToolsService telegramTools,
BatchTaskManagementService taskManagement,
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
ILogger<DataSyncService> logger)
{
@@ -44,28 +47,78 @@ public class DataSyncService
_groupService = groupService;
_telegramTools = telegramTools;
_taskManagement = taskManagement;
_scopeFactory = scopeFactory;
_configuration = configuration;
_logger = logger;
}
public async Task<TrackedSyncResult> RunAllActiveAccountsTrackedAsync(string trigger, CancellationToken cancellationToken)
{
if (!await SyncGate.WaitAsync(0, cancellationToken))
throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度");
var task = await CreateTrackedTaskAsync(trigger, cancellationToken);
return await ExecuteTrackedSyncAsync(task.Id, trigger, cancellationToken);
}
public async Task<int> StartAllActiveAccountsTrackedInBackgroundAsync(string trigger, CancellationToken cancellationToken = default)
{
var task = await CreateTrackedTaskAsync(trigger, cancellationToken);
var taskId = task.Id;
var scopeFactory = _scopeFactory;
var logger = _logger;
_ = Task.Run(async () =>
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var dataSync = scope.ServiceProvider.GetRequiredService<DataSyncService>();
await dataSync.ExecuteTrackedSyncAsync(taskId, trigger, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Manual account sync background execution failed unexpectedly for task {TaskId}", taskId);
}
}, CancellationToken.None);
return taskId;
}
private async Task<BatchTask> CreateTrackedTaskAsync(string trigger, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var accounts = await GetDistinctActiveAccountsAsync();
return await _taskManagement.CreateTaskAsync(new BatchTask
{
TaskType = BatchTaskTypes.AccountAutoSync,
Total = accounts.Count,
Config = BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: accounts.Count,
processedAccounts: 0,
failedAccounts: 0,
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: null)
});
}
private async Task<TrackedSyncResult> ExecuteTrackedSyncAsync(int taskId, string trigger, CancellationToken cancellationToken)
{
var gateEntered = false;
BatchTask? task = null;
try
{
var accounts = (await _accountManagement.GetActiveAccountsAsync())
.GroupBy(x => x.Id)
.Select(x => x.First())
.ToList();
gateEntered = await SyncGate.WaitAsync(0, cancellationToken);
if (!gateEntered)
throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度");
task = await _taskManagement.CreateTaskAsync(new BatchTask
{
TaskType = BatchTaskTypes.AccountAutoSync,
Total = accounts.Count,
Config = BuildSyncTaskConfig(
var accounts = await GetDistinctActiveAccountsAsync();
await _taskManagement.UpdateTaskDraftAsync(
taskId,
accounts.Count,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: accounts.Count,
processedAccounts: 0,
@@ -73,19 +126,18 @@ public class DataSyncService
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: null)
});
error: null));
await _taskManagement.StartTaskAsync(task.Id);
await _taskManagement.StartTaskAsync(taskId);
var summary = await SyncAccountsAsync(
accounts,
cancellationToken,
progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(task.Id, progress.ProcessedAccounts, progress.FailedAccounts));
progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(taskId, progress.ProcessedAccounts, progress.FailedAccounts));
await _taskManagement.UpdateTaskProgressAsync(task.Id, summary.ProcessedAccounts, summary.FailedAccountsCount);
await _taskManagement.UpdateTaskProgressAsync(taskId, summary.ProcessedAccounts, summary.FailedAccountsCount);
await _taskManagement.UpdateTaskConfigAsync(
task.Id,
taskId,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: summary.TotalAccounts,
@@ -95,58 +147,59 @@ public class DataSyncService
totalGroupsSynced: summary.TotalGroupsSynced,
failures: summary.AccountFailures.Select(ToFailureItem).ToList(),
error: null));
await _taskManagement.CompleteTaskAsync(task.Id, success: true);
await _taskManagement.CompleteTaskAsync(taskId, success: true);
return new TrackedSyncResult(task.Id, summary);
return new TrackedSyncResult(taskId, summary);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (task != null)
{
var snapshot = await _taskManagement.GetTaskAsync(task.Id);
await _taskManagement.UpdateTaskConfigAsync(
task.Id,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: snapshot?.Total ?? 0,
processedAccounts: snapshot?.Completed ?? 0,
failedAccounts: snapshot?.Failed ?? 0,
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: "已取消"));
await _taskManagement.CompleteTaskAsync(task.Id, success: false);
}
var snapshot = await _taskManagement.GetTaskAsync(taskId);
await _taskManagement.UpdateTaskConfigAsync(
taskId,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: snapshot?.Total ?? 0,
processedAccounts: snapshot?.Completed ?? 0,
failedAccounts: snapshot?.Failed ?? 0,
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: "已取消"));
await _taskManagement.CompleteTaskAsync(taskId, success: false);
throw;
}
catch (Exception ex)
{
if (task != null)
{
var snapshot = await _taskManagement.GetTaskAsync(task.Id);
await _taskManagement.UpdateTaskConfigAsync(
task.Id,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: snapshot?.Total ?? 0,
processedAccounts: snapshot?.Completed ?? 0,
failedAccounts: snapshot?.Failed ?? 0,
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: ex.Message));
await _taskManagement.CompleteTaskAsync(task.Id, success: false);
}
var snapshot = await _taskManagement.GetTaskAsync(taskId);
await _taskManagement.UpdateTaskConfigAsync(
taskId,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: snapshot?.Total ?? 0,
processedAccounts: snapshot?.Completed ?? 0,
failedAccounts: snapshot?.Failed ?? 0,
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: ex.Message));
await _taskManagement.CompleteTaskAsync(taskId, success: false);
throw;
}
finally
{
SyncGate.Release();
if (gateEntered)
SyncGate.Release();
}
}
private async Task<List<Account>> GetDistinctActiveAccountsAsync()
{
return (await _accountManagement.GetActiveAccountsAsync())
.GroupBy(x => x.Id)
.Select(x => x.First())
.ToList();
}
public async Task<SyncSummary> SyncAllActiveAccountsAsync(
CancellationToken cancellationToken,
Func<SyncProgress, Task>? progressCallback = null)
@@ -191,10 +244,17 @@ public class DataSyncService
var accountFailed = false;
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation(
"Syncing account {Index}/{Total}: {AccountId} {Phone}",
index + 1,
accountList.Count,
account.Id,
account.Phone);
try
{
// 同步频道:拉取账号当前可见的全部频道,并记录该账号在频道中的角色关系。
var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id);
var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id, cancellationToken);
var keepChannelIds = new List<int>(capacity: channelInfos.Count);
foreach (var channelInfo in channelInfos)
@@ -230,7 +290,7 @@ public class DataSyncService
await _channelManagement.DeleteStaleAccountChannelsAsync(account.Id, keepChannelIds);
// 同步群组:拉取账号当前可见的全部群组,并记录该账号在群组中的角色关系。
var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id);
var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id, cancellationToken);
var keepGroupIds = new List<int>(capacity: groupInfos.Count);
foreach (var groupInfo in groupInfos)
@@ -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;
}
}
@@ -0,0 +1,120 @@
using System.Text.Json;
using TelegramPanel.Core.BatchTasks;
using TelegramPanel.Data.Entities;
using TelegramPanel.Modules;
namespace TelegramPanel.Web.Services;
public sealed class UserChatActiveTaskRerunBuilder : IModuleTaskRerunBuilder
{
public string TaskType => BatchTaskTypes.UserChatActive;
public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task)
{
var rerunConfig = BuildRerunConfig(task.Config);
var total = rerunConfig.MaxMessages > 0 ? rerunConfig.MaxMessages : 0;
var configJson = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true });
return new ModuleTaskCreateRequest
{
TaskType = BatchTaskTypes.UserChatActive,
Total = total,
Config = configJson
};
}
private static UserChatActiveTaskConfig BuildRerunConfig(string? rawConfig)
{
var raw = (rawConfig ?? string.Empty).Trim();
if (raw.Length == 0)
throw new InvalidOperationException("任务配置为空,无法重新运行");
UserChatActiveTaskConfig cfg;
try
{
cfg = JsonSerializer.Deserialize<UserChatActiveTaskConfig>(raw)
?? throw new InvalidOperationException("任务配置解析结果为空");
}
catch (Exception ex)
{
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
}
cfg.CategoryIds = NormalizeCategoryIdsForRerun(cfg.CategoryIds, cfg.CategoryId);
if (cfg.CategoryIds.Count == 0)
throw new InvalidOperationException("任务缺少账号分类,无法重新运行");
cfg.CategoryId = cfg.CategoryIds[0];
cfg.CategoryNames = NormalizeCategoryNamesForRerun(cfg.CategoryNames, cfg.CategoryName);
cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName;
cfg.Targets = (cfg.Targets ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
cfg.Dictionary = (cfg.Dictionary ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.ToList();
if (cfg.Targets.Count == 0)
throw new InvalidOperationException("任务缺少目标群组/频道,无法重新运行");
if (cfg.Dictionary.Count == 0)
throw new InvalidOperationException("任务缺少词典消息,无法重新运行");
if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0;
if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0;
if (cfg.DelayMinMs > 600000) cfg.DelayMinMs = 600000;
if (cfg.DelayMaxMs > 600000) cfg.DelayMaxMs = 600000;
if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs;
if (cfg.MaxMessages < 0) cfg.MaxMessages = 0;
cfg.AccountMode = NormalizeModeValue(cfg.AccountMode);
cfg.TargetMode = NormalizeModeValue(cfg.TargetMode);
cfg.MessageMode = NormalizeModeValue(cfg.MessageMode);
cfg.Canceled = false;
cfg.Error = null;
cfg.RecentFailures = new List<UserChatActiveTaskRuntimeFailure>();
return cfg;
}
private static List<int> NormalizeCategoryIdsForRerun(IEnumerable<int>? values, int fallback)
{
var ids = (values ?? Array.Empty<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0 && fallback > 0)
ids.Add(fallback);
return ids;
}
private static List<string> NormalizeCategoryNamesForRerun(IEnumerable<string>? values, string? fallback)
{
var names = (values ?? Array.Empty<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var fallbackName = (fallback ?? string.Empty).Trim();
if (names.Count == 0 && fallbackName.Length > 0)
names.Add(fallbackName);
return names;
}
private static string NormalizeModeValue(string? mode)
{
return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase)
? UserChatActiveTaskModes.Queue
: UserChatActiveTaskModes.Random;
}
}