feat: enhance task center and chat account visibility
This commit is contained in:
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+739
@@ -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<ModuleTaskDraft>)\n /// </summary>
|
||||
/// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。
|
||||
/// 该组件需要支持参数:
|
||||
/// - Draft (ModuleTaskDraft)
|
||||
/// - DraftChanged (EventCallback<ModuleTaskDraft>)
|
||||
/// 编辑场景下还应支持可选参数:
|
||||
/// - InitialConfigJson (string?)
|
||||
/// </summary>
|
||||
[JsonPropertyName("editorComponentType")]
|
||||
public string? EditorComponentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskCenter")]
|
||||
public ModuleTaskCenterCapabilities TaskCenter { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; } = 0;
|
||||
}
|
||||
|
||||
public sealed class ModuleTaskCenterCapabilities
|
||||
{
|
||||
[JsonPropertyName("canPause")]
|
||||
public bool CanPause { get; set; }
|
||||
|
||||
[JsonPropertyName("canResume")]
|
||||
public bool CanResume { get; set; }
|
||||
|
||||
[JsonPropertyName("canEdit")]
|
||||
public bool CanEdit { get; set; }
|
||||
|
||||
[JsonPropertyName("canRerun")]
|
||||
public bool CanRerun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编辑时使用的组件类型;为空时回退到 EditorComponentType。
|
||||
/// </summary>
|
||||
[JsonPropertyName("editComponentType")]
|
||||
public string? EditComponentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 若任务仍在运行,打开编辑器前是否自动先暂停。
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoPauseBeforeEdit")]
|
||||
public bool AutoPauseBeforeEdit { get; set; }
|
||||
}
|
||||
|
||||
public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError);
|
||||
|
||||
public sealed class ModuleTaskSnapshot
|
||||
{
|
||||
[JsonPropertyName("taskId")]
|
||||
public int TaskId { get; set; }
|
||||
|
||||
[JsonPropertyName("taskType")]
|
||||
public string TaskType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
public int Completed { get; set; }
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
public int Failed { get; set; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ModuleTaskCreateRequest
|
||||
{
|
||||
[JsonPropertyName("taskType")]
|
||||
public string TaskType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; set; }
|
||||
}
|
||||
|
||||
public interface IModuleTaskRerunBuilder
|
||||
{
|
||||
string TaskType { get; }
|
||||
ModuleTaskCreateRequest Build(ModuleTaskSnapshot task);
|
||||
}
|
||||
|
||||
public interface IModuleApiProvider
|
||||
{
|
||||
IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context);
|
||||
@@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost
|
||||
Task<bool> IsStillRunningAsync(CancellationToken cancellationToken);
|
||||
Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -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="@" 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user