merge(dev): 合并 v1.31.5 开发分支功能

This commit is contained in:
meoacgx committed 2026-03-09 13:56:33 +08:00
commit 9a78210403
75 files changed
+9133 -343

No files matched your search

+4 -4
View File
@@ -5,10 +5,10 @@
<!-- 主版本: 重大变更/不兼容更新 -->
<!-- 次版本: 新功能/兼容性更新 -->
<!-- 修订号: Bug修复/小改进 -->
<Version>1.31.4</Version>
<AssemblyVersion>1.31.4.0</AssemblyVersion>
<FileVersion>1.31.4.0</FileVersion>
<InformationalVersion>1.31.4</InformationalVersion>
<Version>1.31.5</Version>
<AssemblyVersion>1.31.5.0</AssemblyVersion>
<FileVersion>1.31.5.0</FileVersion>
<InformationalVersion>1.31.5</InformationalVersion>
<!-- 项目元信息 -->
<Authors>Telegram Panel Contributors</Authors>
+15 -2
View File
@@ -28,9 +28,22 @@
- 🔐 2FA 管理:单个/批量修改二级密码;绑定/换绑找回邮箱(支持对接 Cloud Mail 自动收码确认)
- 🧩 模块化扩展:任务 / API / UI 可安装扩展(见 `docs/developer/modules.md`)
## 近期新增功能(dev)
- 🧠 任务中心增强:持续任务支持暂停、编辑、重新运行;任务列表区分执行中与历史任务;支持“仅清理历史任务”与“历史任务保留上限”自动清理
- 💬 持续活跃任务升级:新增账号持续活跃(群组/频道)能力,支持多分类账号、随机文案、秒级发送间隔、持续运行配置
- 🔄 同步体验优化:手动“立即同步”改为后台任务执行,可在任务中心跟踪进度;同步链路补充超时保护与状态持久化
- 👤 账号列表增强:新增注册时间展示,并可直接从账号操作中查看该账号已加入的频道和群组
- 📺 频道管理升级:频道列表改为面向“已加入频道”视角,不再只显示系统创建频道;支持按账号、角色、分类筛选,支持查看频道内的本系统账号
- 👥 群组管理补齐:新增群组创建页、群组分类、批量操作与列表能力补齐;群组列表同样支持按账号、角色、分类筛选,并支持查看群组内的本系统账号
- 🔗 多账号关系可视化:同一频道/群组可绑定多个系统账号,频道/群组列表与详情页都可查看当前关联的系统账号、角色与状态
- 🚪 真实退出/解散能力:频道与群组支持单个/批量退出;支持解散频道/群组,且解散前需后台密码确认
- 🧹 数据准确性修正:修复频道列表混入群组的问题,并优化账号、频道、群组关系同步后的本地展示逻辑
- ⚡ 数据层优化:补充频道/群组查询与关系索引,提升几百账号、上千频道/群组场景下的筛选与加载性能
## TODO(规划)
- [ ] 一键退群/退订、订阅(频道/群组)
- [x] 一键退群/退订、订阅(频道/群组)
- [ ] 一键清空联系人
- [ ] 批量手机号验证码重新登录(用于刷新会话 session)
- [ ] 手机号注册:未注册号支持完整注册流程(姓名/可选邮箱/邮箱验证码等)
@@ -40,7 +53,7 @@
- [ ] 多 API:支持账号分类绑定 ApiId/ApiHash
- [ ] 定时创建频道、定时公开频道
- [ ] 定时刷粉丝:对接刷粉 API(通用适配结构),通过适配模块对接多家刷粉平台
- [ ] 群聊定时发言养号
- [x] 群聊定时发言养号
## 快速开始
@@ -15,6 +15,9 @@ public static class BatchTaskTypes
public const string UserJoinSubscribe = "user_join_subscribe";
public const string UserChatActive = "user_chat_active";
// System 任务(记录到任务中心)
public const string AccountAutoSync = "account_auto_sync";
// External API(记录到任务中心)
public const string ExternalApiKick = "external_api_kick";
}
@@ -17,6 +17,11 @@ public interface IChannelService
/// </summary>
Task<List<ChannelInfo>> GetAdminedChannelsAsync(int accountId);
/// <summary>
/// 获取账号当前可见的全部频道(创建者/管理员/普通成员)
/// </summary>
Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default);
/// <summary>
/// 创建新频道
/// </summary>
@@ -52,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,11 +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, 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>
+5 -1
View File
@@ -11,7 +11,11 @@ public record GroupInfo
public string Title { get; init; } = string.Empty;
public string? Username { get; init; }
public int MemberCount { get; init; }
public int CreatorAccountId { get; init; }
public string? About { get; init; }
public int? CreatorAccountId { get; init; }
public bool IsCreator { get; init; }
public bool IsAdmin { get; init; }
public DateTime? CreatedAt { get; init; }
public DateTime SyncedAt { get; init; }
public bool IsPublic => !string.IsNullOrEmpty(Username);
@@ -35,6 +35,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ChannelManagementService>();
services.AddScoped<ChannelGroupManagementService>();
services.AddScoped<GroupManagementService>();
services.AddScoped<GroupCategoryManagementService>();
services.AddScoped<BatchTaskManagementService>();
services.AddScoped<BotManagementService>();
@@ -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);
}
}
@@ -33,25 +33,19 @@ public class ChannelManagementService
}
/// <summary>
/// 用于列表展示:可选按账号筛选(仅展示“本系统创建/创建者为该账号”的频道)
/// 用于列表展示:支持按账号查看该账号可见的全部频道,并按角色筛选。
/// </summary>
public async Task<IEnumerable<Channel>> GetChannelsForViewAsync(int accountId, bool includeNonCreator)
{
if (accountId <= 0)
return await _channelRepository.GetCreatedAsync();
return await _channelRepository.GetByCreatorAccountAsync(accountId);
}
public async Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryChannelsForViewPagedAsync(
int creatorAccountId,
int accountId,
int? groupId,
string? filterType,
string? membershipRole,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
{
return await _channelRepository.QueryForViewPagedAsync(creatorAccountId, filterType, search, pageIndex, pageSize, cancellationToken);
return await _channelRepository.QueryForViewPagedAsync(accountId, groupId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
}
public async Task<IEnumerable<Channel>> GetChannelsByCreatorAsync(int accountId)
@@ -190,7 +184,85 @@ public class ChannelManagementService
public async Task DeleteStaleAccountChannelsAsync(int accountId, IReadOnlyCollection<int> keepChannelIds)
{
await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds);
var keepSet = keepChannelIds.Count == 0
? new HashSet<int>()
: keepChannelIds.ToHashSet();
var staleChannelIds = (await _accountChannelRepository.GetByAccountAsync(accountId))
.Where(x => !keepSet.Contains(x.ChannelId))
.Select(x => x.ChannelId)
.Distinct()
.ToList();
foreach (var channelId in staleChannelIds)
await RemoveAccountChannelAsync(channelId, accountId);
var staleChannelIdSet = staleChannelIds.ToHashSet();
var creatorOnlyChannelIds = (await _channelRepository.FindAsync(x => x.CreatorAccountId == accountId))
.Select(x => x.Id)
.Where(id => !keepSet.Contains(id) && !staleChannelIdSet.Contains(id))
.Distinct()
.ToList();
foreach (var channelId in creatorOnlyChannelIds)
await DetachCreatorFromChannelAsync(channelId, accountId);
await _channelRepository.DeleteOrphanedAsync();
}
public async Task<IReadOnlyList<AccountChannel>> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
{
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);
}
private async Task DetachCreatorFromChannelAsync(int channelId, int accountId)
{
var channel = await _channelRepository.GetByIdAsync(channelId);
if (channel == null || channel.CreatorAccountId != accountId)
return;
channel.CreatorAccountId = null;
if (channel.AccountChannels.Count == 0)
{
await _channelRepository.DeleteAsync(channel);
return;
}
channel.SyncedAt = DateTime.UtcNow;
await _channelRepository.UpdateAsync(channel);
}
/// <summary>
@@ -0,0 +1,49 @@
using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
namespace TelegramPanel.Core.Services;
/// <summary>
/// 群组分类管理服务
/// </summary>
public class GroupCategoryManagementService
{
private readonly IGroupCategoryRepository _categoryRepository;
public GroupCategoryManagementService(IGroupCategoryRepository categoryRepository)
{
_categoryRepository = categoryRepository;
}
public async Task<IEnumerable<GroupCategory>> GetAllCategoriesAsync()
{
return await _categoryRepository.GetAllAsync();
}
public async Task<GroupCategory?> GetCategoryAsync(int id)
{
return await _categoryRepository.GetByIdAsync(id);
}
public async Task<GroupCategory?> GetCategoryByNameAsync(string name)
{
return await _categoryRepository.GetByNameAsync(name);
}
public async Task<GroupCategory> CreateCategoryAsync(GroupCategory category)
{
return await _categoryRepository.AddAsync(category);
}
public async Task UpdateCategoryAsync(GroupCategory category)
{
await _categoryRepository.UpdateAsync(category);
}
public async Task DeleteCategoryAsync(int id)
{
var category = await _categoryRepository.GetByIdAsync(id);
if (category != null)
await _categoryRepository.DeleteAsync(category);
}
}
@@ -9,10 +9,12 @@ namespace TelegramPanel.Core.Services;
public class GroupManagementService
{
private readonly IGroupRepository _groupRepository;
private readonly IAccountGroupRepository _accountGroupRepository;
public GroupManagementService(IGroupRepository groupRepository)
public GroupManagementService(IGroupRepository groupRepository, IAccountGroupRepository accountGroupRepository)
{
_groupRepository = groupRepository;
_accountGroupRepository = accountGroupRepository;
}
public async Task<Group?> GetGroupAsync(int id)
@@ -30,6 +32,19 @@ public class GroupManagementService
return await _groupRepository.GetAllAsync();
}
public async Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryGroupsForViewPagedAsync(
int accountId,
int? categoryId,
string? filterType,
string? membershipRole,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
{
return await _groupRepository.QueryForViewPagedAsync(accountId, categoryId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
}
public async Task<IEnumerable<Group>> GetGroupsByCreatorAsync(int accountId)
{
return await _groupRepository.GetByCreatorAccountAsync(accountId);
@@ -37,34 +52,54 @@ public class GroupManagementService
public async Task<Group> CreateOrUpdateGroupAsync(Group group)
{
if (group.TelegramId <= 0)
throw new ArgumentException("TelegramId 必须为正数", nameof(group));
var existing = await _groupRepository.GetByTelegramIdAsync(group.TelegramId);
if (existing != null)
{
// 更新现有群组
existing.Title = group.Title;
existing.Username = group.Username;
existing.MemberCount = group.MemberCount;
existing.About = group.About;
existing.AccessHash = group.AccessHash;
if (group.CategoryId.HasValue)
existing.CategoryId = group.CategoryId;
if (existing.CreatorAccountId == null && group.CreatorAccountId != null)
existing.CreatorAccountId = group.CreatorAccountId;
if (group.CreatedAt.HasValue)
existing.CreatedAt = group.CreatedAt;
existing.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(existing);
return existing;
}
else
{
// 创建新群组
group.SyncedAt = DateTime.UtcNow;
return await _groupRepository.AddAsync(group);
}
group.SyncedAt = DateTime.UtcNow;
return await _groupRepository.AddAsync(group);
}
public async Task UpdateGroupAsync(Group group)
{
group.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(group);
}
public async Task DeleteGroupAsync(int id)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group != null)
{
await _groupRepository.DeleteAsync(group);
}
public async Task UpdateGroupCategoryAsync(int groupId, int? categoryId)
{
var group = await _groupRepository.GetByIdAsync(groupId);
if (group != null)
{
group.CategoryId = categoryId;
group.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(group);
}
}
@@ -77,4 +112,114 @@ public class GroupManagementService
{
return await _groupRepository.CountAsync(g => g.CreatorAccountId == accountId);
}
public async Task UpsertAccountGroupAsync(int accountId, int groupId, bool isCreator, bool isAdmin, DateTime syncedAtUtc)
{
await _accountGroupRepository.UpsertAsync(new AccountGroup
{
AccountId = accountId,
GroupId = groupId,
IsCreator = isCreator,
IsAdmin = isAdmin,
SyncedAt = syncedAtUtc
});
}
public async Task DeleteStaleAccountGroupsAsync(int accountId, IReadOnlyCollection<int> keepGroupIds)
{
var keepSet = keepGroupIds.Count == 0
? new HashSet<int>()
: keepGroupIds.ToHashSet();
var staleGroupIds = (await _accountGroupRepository.GetByAccountAsync(accountId))
.Where(x => !keepSet.Contains(x.GroupId))
.Select(x => x.GroupId)
.Distinct()
.ToList();
foreach (var groupId in staleGroupIds)
await RemoveAccountGroupAsync(groupId, accountId);
var staleGroupIdSet = staleGroupIds.ToHashSet();
var creatorOnlyGroupIds = (await _groupRepository.FindAsync(x => x.CreatorAccountId == accountId))
.Select(x => x.Id)
.Where(id => !keepSet.Contains(id) && !staleGroupIdSet.Contains(id))
.Distinct()
.ToList();
foreach (var groupId in creatorOnlyGroupIds)
await DetachCreatorFromGroupAsync(groupId, accountId);
await _groupRepository.DeleteOrphanedAsync();
}
public async Task<IReadOnlyList<AccountGroup>> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
{
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);
}
private async Task DetachCreatorFromGroupAsync(int groupId, int accountId)
{
var group = await _groupRepository.GetByIdAsync(groupId);
if (group == null || group.CreatorAccountId != accountId)
return;
group.CreatorAccountId = null;
if (group.AccountGroups.Count == 0)
{
await _groupRepository.DeleteAsync(group);
return;
}
group.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(group);
}
/// <summary>
/// 解析群组操作的执行账号:
/// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。
/// </summary>
public async Task<int?> ResolveExecuteAccountIdAsync(Group group, int? preferredAccountId = null)
{
if (preferredAccountId.HasValue && preferredAccountId.Value > 0)
return preferredAccountId.Value;
if (group.CreatorAccountId.HasValue)
return group.CreatorAccountId.Value;
return await _accountGroupRepository.GetPreferredAdminAccountIdAsync(group.Id);
}
}
@@ -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();
@@ -83,6 +88,7 @@ public class AccountTelegramToolsService
if (account != null)
{
profile.ApplyTo(account);
await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken);
}
var summary = "正常";
@@ -241,6 +247,127 @@ public class AccountTelegramToolsService
.ToList();
}
public async Task EnsureEstimatedRegistrationAsync(int accountId, CancellationToken cancellationToken = default)
{
try
{
var account = await _accountManagement.GetAccountAsync(accountId);
if (account == null)
return;
if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue)
return;
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken);
if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue)
await _accountManagement.UpdateAccountAsync(account);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "EnsureEstimatedRegistrationAsync skipped for account {AccountId}", accountId);
}
}
private async Task TryPopulateEstimatedRegistrationAsync(
Account account,
Client client,
int accountId,
CancellationToken cancellationToken)
{
if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue)
return;
var (checkedSuccessfully, estimatedAtUtc) = await TryGetEstimatedRegistrationFromSystemMessagesAsync(client, accountId, cancellationToken);
if (!checkedSuccessfully)
return;
if (estimatedAtUtc.HasValue)
account.EstimatedRegistrationAt = estimatedAtUtc.Value;
account.EstimatedRegistrationCheckedAtUtc = DateTime.UtcNow;
}
private async Task<(bool CheckedSuccessfully, DateTime? EstimatedAtUtc)> TryGetEstimatedRegistrationFromSystemMessagesAsync(
Client client,
int accountId,
CancellationToken cancellationToken)
{
try
{
var peer = await TryResolveSystemPeerAsync(client);
if (peer == null)
return (true, null);
const int pageSize = 100;
const int maxPages = 200;
var offsetId = 0;
DateTime? earliest = null;
for (var page = 0; page < maxPages; page++)
{
cancellationToken.ThrowIfCancellationRequested();
var history = await ExecuteTelegramRequestAsync(
accountId,
"读取 777000 系统通知历史",
() => client.Messages_GetHistory(peer, offset_id: offsetId, limit: pageSize),
cancellationToken,
resetClientOnTimeout: true);
if (history.Messages == null || history.Messages.Length == 0)
break;
foreach (var msgBase in history.Messages)
{
if (msgBase is not Message message)
continue;
if (string.IsNullOrWhiteSpace(message.message))
continue;
var messageUtc = message.Date.ToUniversalTime();
if (!earliest.HasValue || messageUtc < earliest.Value)
earliest = messageUtc;
}
var nextOffsetId = history.Messages
.Select(GetTelegramMessageId)
.Where(id => id > 0)
.DefaultIfEmpty(0)
.Min();
if (nextOffsetId <= 0 || nextOffsetId == offsetId || history.Messages.Length < pageSize)
break;
offsetId = nextOffsetId;
}
return (true, earliest);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to estimate registration time from 777000 for account {AccountId}", accountId);
return (false, null);
}
}
private static int GetTelegramMessageId(MessageBase msgBase) => msgBase switch
{
Message message => message.id,
MessageService service => service.id,
_ => 0
};
public async Task<IReadOnlyList<TelegramAuthorizationInfo>> GetAuthorizationsAsync(int accountId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -1409,10 +1536,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 +1572,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 +1695,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 +1717,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 +1748,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,6 +152,79 @@ public class ChannelService : IChannelService
return channels;
}
public async Task<List<ChannelInfo>> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
var channels = new List<ChannelInfo>();
var dialogs = await ExecuteTelegramRequestAsync(
accountId,
"拉取频道/群组对话列表",
() => client.Messages_GetAllDialogs(),
cancellationToken,
resetClientOnTimeout: true);
foreach (var (_, chat) in dialogs.chats)
{
cancellationToken.ThrowIfCancellationRequested();
if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
continue;
try
{
var isCreator =
ReadBool(channel, "creator", "Creator", "is_creator", "IsCreator")
|| ReadFlagsHas(channel, flagName: "creator", memberNames: new[] { "flags", "Flags" });
var adminRights = ReadObject(channel, "admin_rights", "AdminRights", "adminRights");
var isAdmin = isCreator || adminRights != null;
var memberCount = ReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount", "memberCount", "MemberCount");
string? about = null;
try
{
var 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;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get full visible channel info for {ChannelId}", channel.id);
}
channels.Add(new ChannelInfo
{
TelegramId = channel.id,
AccessHash = channel.access_hash,
Title = channel.title,
Username = channel.MainUsername,
IsBroadcast = channel.IsChannel,
MemberCount = memberCount,
About = about,
CreatorAccountId = isCreator ? accountId : null,
IsCreator = isCreator,
IsAdmin = isAdmin,
CreatedAt = channel.date,
SyncedAt = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get visible channel info for {ChannelId}", channel.id);
}
}
_logger.LogInformation("Found {Count} visible channels for account {AccountId}", channels.Count, accountId);
return channels;
}
private static bool ReadBool(object obj, params string[] names)
{
foreach (var name in names)
@@ -666,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);
@@ -832,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;
@@ -873,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)
{
@@ -896,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)
@@ -1,6 +1,6 @@
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Models;
using TelegramPanel.Core.Services;
@@ -31,37 +31,124 @@ public class GroupService : IGroupService
}
public async Task<List<GroupInfo>> GetOwnedGroupsAsync(int accountId)
{
var groups = await GetVisibleGroupsAsync(accountId);
return groups.Where(x => x.IsCreator).ToList();
}
public async Task<GroupInfo> CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
var ownedGroups = new List<GroupInfo>();
var dialogs = await client.Messages_GetAllDialogs();
_logger.LogInformation("Creating group '{Title}' for account {AccountId}", title, accountId);
foreach (var (id, chat) in dialogs.chats)
if (isPublic)
{
// 处理基础群组(Chat类型,非Channel)
// 注意:基础 Chat 类型无法直接判断创建者,需要通过 GetFullChat 获取
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 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)
{
var memberCount = (int)basicChat.participants_count;
var about = ReadString(basicChat, null, "about", "About");
var username = ReadString(basicChat, null, "username", "Username", "MainUsername");
var isCreator = false;
var isAdmin = false;
try
{
// 获取完整信息来判断是否为创建者
var fullChat = await client.Messages_GetFullChat(basicChat.id);
if (fullChat.full_chat is ChatFull cf && cf.participants is ChatParticipants cp)
var fullChat = await ExecuteTelegramRequestAsync(
accountId,
$"拉取基础群详情({basicChat.id})",
() => client.Messages_GetFullChat(basicChat.id),
cancellationToken,
resetClientOnTimeout: false);
if (fullChat.full_chat is ChatFull cf)
{
// 检查当前用户是否为创建者
var creator = cp.participants.OfType<ChatParticipantCreator>()
.FirstOrDefault(p => p.user_id == client.User!.id);
if (creator != null)
about = ReadString(cf, about, "about", "About");
memberCount = Math.Max(memberCount, ReadInt(cf, memberCount, "participants_count", "ParticipantsCount", "participantsCount"));
if (cf.participants is ChatParticipants cp)
{
ownedGroups.Add(new GroupInfo
{
TelegramId = basicChat.id,
Title = basicChat.title,
MemberCount = basicChat.participants_count,
CreatorAccountId = accountId,
SyncedAt = DateTime.UtcNow
});
memberCount = Math.Max(memberCount, cp.participants.Count());
var self = cp.participants.FirstOrDefault(p => GetChatParticipantUserId(p) == client.User!.id);
isCreator = self is ChatParticipantCreator;
isAdmin = isCreator || self is ChatParticipantAdmin;
}
}
}
@@ -78,38 +165,53 @@ public class GroupService : IGroupService
_logger.LogDebug(ex, "GetFullChat debug details (chatId={ChatId})", basicChat.id);
}
}
groups.Add(new GroupInfo
{
TelegramId = basicChat.id,
Title = basicChat.title,
Username = username,
MemberCount = memberCount,
About = about,
CreatorAccountId = isCreator ? accountId : null,
IsCreator = isCreator,
IsAdmin = isAdmin,
SyncedAt = DateTime.UtcNow
});
continue;
}
// 处理超级群组(Channel类型但megagroup=true, 即 !IsChannel)
else if (chat is Channel channel && channel.IsActive && !channel.IsChannel)
// 超级群组(Channel 类型但 megagroup=true,即 !IsChannel)
if (chat is not Channel channel || !channel.IsActive || channel.IsChannel)
continue;
try
{
var isCreator =
ReadBool(channel, "creator", "Creator", "is_creator", "IsCreator")
|| ReadFlagsHas(channel, flagName: "creator", memberNames: new[] { "flags", "Flags" });
var adminRights = ReadObject(channel, "admin_rights", "AdminRights", "adminRights");
var isAdmin = isCreator || adminRights != null;
var memberCount = ReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount", "memberCount", "MemberCount");
string? about = null;
try
{
// 通过获取管理员列表来检查当前用户是否为创建者
var participants = await client.Channels_GetParticipants(channel, new ChannelParticipantsAdmins());
var isCreator = participants.participants
.OfType<ChannelParticipantCreator>()
.Any(p => p.user_id == client.User!.id);
if (!isCreator) continue;
var fullChannel = await client.Channels_GetFullChannel(channel);
ownedGroups.Add(new GroupInfo
{
TelegramId = channel.id,
AccessHash = channel.access_hash,
Title = channel.title,
Username = channel.MainUsername,
MemberCount = fullChannel.full_chat.ParticipantsCount,
CreatorAccountId = accountId,
SyncedAt = DateTime.UtcNow
});
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;
}
catch (Exception ex)
{
if (ex.Message.Contains("CHANNEL_MONOFORUM_UNSUPPORTED", StringComparison.OrdinalIgnoreCase))
{
// 该错误通常不影响账号本身,仅表示 Telegram 不支持对这类“论坛/话题”群组调用某些接口
_logger.LogDebug("Skipping group {GroupId}: {Error}", channel.id, ex.Message);
_logger.LogDebug("Skipping full info for group {GroupId}: {Error}", channel.id, ex.Message);
}
else if (TryGetFloodWaitSeconds(ex.Message, out var seconds))
{
@@ -122,11 +224,30 @@ public class GroupService : IGroupService
_logger.LogDebug(ex, "GetFullGroup debug details (groupId={GroupId})", channel.id);
}
}
groups.Add(new GroupInfo
{
TelegramId = channel.id,
AccessHash = channel.access_hash,
Title = channel.title,
Username = channel.MainUsername,
MemberCount = memberCount,
About = about,
CreatorAccountId = isCreator ? accountId : null,
IsCreator = isCreator,
IsAdmin = isAdmin,
CreatedAt = channel.date,
SyncedAt = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get visible group info for {GroupId}", channel.id);
}
}
_logger.LogInformation("Found {Count} owned groups for account {AccountId}", ownedGroups.Count, accountId);
return ownedGroups;
_logger.LogInformation("Found {Count} visible groups for account {AccountId}", groups.Count, accountId);
return groups;
}
private static bool TryGetFloodWaitSeconds(string message, out int seconds)
@@ -135,7 +256,6 @@ public class GroupService : IGroupService
if (string.IsNullOrWhiteSpace(message))
return false;
// 典型:FLOOD_WAIT_13
var idx = message.IndexOf("FLOOD_WAIT_", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return false;
@@ -150,18 +270,235 @@ public class GroupService : IGroupService
return true;
}
private static bool ReadBool(object obj, params string[] names)
{
foreach (var name in names)
{
if (TryReadMember(obj, name, out var value) && value is bool b)
return b;
}
return false;
}
private static bool ReadFlagsHas(object obj, string flagName, string[] memberNames)
{
foreach (var memberName in memberNames)
{
if (!TryReadMember(obj, memberName, out var value) || value == null)
continue;
if (value is Enum enumValue)
{
var flagEnum = TryGetEnumFlag(enumValue.GetType(), flagName);
if (flagEnum != null)
return enumValue.HasFlag(flagEnum);
continue;
}
if (value is int intFlags)
return ReadNumericFlagsHas(obj, flagName, intFlags);
if (value is long longFlags)
return ReadNumericFlagsHas(obj, flagName, longFlags);
}
return false;
}
private static bool ReadNumericFlagsHas(object obj, string flagName, long flagsValue)
{
var flagsType = obj.GetType().GetNestedType("Flags");
if (flagsType == null || !flagsType.IsEnum)
return false;
var flagEnum = TryGetEnumFlag(flagsType, flagName);
if (flagEnum == null)
return false;
var flagValue = Convert.ToInt64(flagEnum);
return (flagsValue & flagValue) == flagValue;
}
private static Enum? TryGetEnumFlag(Type enumType, string flagName)
{
var names = Enum.GetNames(enumType);
var match = names.FirstOrDefault(n => string.Equals(n, flagName, StringComparison.OrdinalIgnoreCase));
if (match == null)
return null;
return (Enum)Enum.Parse(enumType, match);
}
private static int ReadInt(object obj, int fallback, params string[] names)
{
foreach (var name in names)
{
if (TryReadMember(obj, name, out var value))
{
if (value is int i) return i;
if (value is long l) return unchecked((int)l);
if (value is short s) return s;
if (value is byte by) return by;
}
}
return fallback;
}
private static long ReadLong(object obj, long fallback, params string[] names)
{
foreach (var name in names)
{
if (TryReadMember(obj, name, out var value))
{
if (value is long l) return l;
if (value is int i) return i;
if (value is short s) return s;
if (value is byte by) return by;
}
}
return fallback;
}
private static string? ReadString(object obj, string? fallback, params string[] names)
{
foreach (var name in names)
{
if (TryReadMember(obj, name, out var value) && value is string s)
return s;
}
return fallback;
}
private static object? ReadObject(object obj, params string[] names)
{
foreach (var name in names)
{
if (TryReadMember(obj, name, out var value) && value != null)
return value;
}
return null;
}
private static bool TryReadMember(object obj, string name, out object? value)
{
var type = obj.GetType();
var prop = type.GetProperty(name);
if (prop != null && prop.CanRead)
{
value = prop.GetValue(obj);
return true;
}
var field = type.GetField(name);
if (field != null)
{
value = field.GetValue(obj);
return true;
}
value = null;
return false;
}
private static long GetChatParticipantUserId(object participant)
{
return ReadLong(participant, 0, "user_id", "UserId", "userId");
}
public async Task<GroupInfo?> GetGroupInfoAsync(int accountId, long groupId)
{
var groups = await GetOwnedGroupsAsync(accountId);
var groups = await GetVisibleGroupsAsync(accountId);
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);
var dialogs = await client.Messages_GetAllDialogs();
// 基础群组(Chat)
var basic = dialogs.chats.Values.OfType<Chat>().FirstOrDefault(c => c.IsActive && c.id == groupId);
if (basic != null)
{
@@ -178,7 +515,6 @@ public class GroupService : IGroupService
return link;
}
// 超级群组(Channel 但 megagroup=true,即 !IsChannel)
var mega = dialogs.chats.Values.OfType<Channel>().FirstOrDefault(c => c.IsActive && !c.IsChannel && c.id == groupId);
if (mega != null)
{
@@ -206,7 +542,6 @@ public class GroupService : IGroupService
var client = await GetOrCreateConnectedClientAsync(accountId);
var dialogs = await client.Messages_GetAllDialogs();
// 基础群组(Chat)
var basic = dialogs.chats.Values.OfType<Chat>().FirstOrDefault(c => c.IsActive && c.id == groupId);
if (basic != null)
{
@@ -250,7 +585,6 @@ public class GroupService : IGroupService
.ToList();
}
// 超级群组(Channel but !IsChannel)
var mega = dialogs.chats.Values.OfType<Channel>().FirstOrDefault(c => c.IsActive && !c.IsChannel && c.id == groupId);
if (mega != null)
{
@@ -298,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;
@@ -339,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)
{
@@ -362,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)
+43 -7
View File
@@ -16,9 +16,11 @@ public class AppDbContext : DbContext
public DbSet<Account> Accounts => Set<Account>();
public DbSet<AccountCategory> AccountCategories => Set<AccountCategory>();
public DbSet<AccountChannel> AccountChannels => Set<AccountChannel>();
public DbSet<AccountGroup> AccountGroups => Set<AccountGroup>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ChannelGroup> ChannelGroups => Set<ChannelGroup>();
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupCategory> GroupCategories => Set<GroupCategory>();
public DbSet<BatchTask> BatchTasks => Set<BatchTask>();
public DbSet<Bot> Bots => Set<Bot>();
public DbSet<BotChannel> BotChannels => Set<BotChannel>();
@@ -43,11 +45,8 @@ public class AppDbContext : DbContext
entity.Property(e => e.TelegramStatusDetails).HasMaxLength(2000);
entity.HasIndex(e => e.Phone).IsUnique();
// 注意:UserId 在未登录/未验证的 session 导入场景可能为 0,SQLite 的 UNIQUE 约束会导致多账号导入失败。
// 这里保留索引但不做唯一约束,真正的去重以 Phone 为准。
entity.HasIndex(e => e.UserId);
// 与AccountCategory的关系
entity.HasOne(e => e.Category)
.WithMany(c => c.Accounts)
.HasForeignKey(e => e.CategoryId)
@@ -76,14 +75,15 @@ 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);
// 与Account的关系(创建者)
entity.HasOne(e => e.CreatorAccount)
.WithMany(a => a.Channels)
.HasForeignKey(e => e.CreatorAccountId)
.OnDelete(DeleteBehavior.SetNull);
// 与ChannelGroup的关系
entity.HasOne(e => e.Group)
.WithMany(g => g.Channels)
.HasForeignKey(e => e.GroupId)
@@ -94,9 +94,9 @@ public class AppDbContext : DbContext
modelBuilder.Entity<AccountChannel>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.AccountId, e.ChannelId }).IsUnique();
entity.HasIndex(e => e.ChannelId);
entity.HasIndex(e => new { e.ChannelId, e.IsCreator, e.IsAdmin });
entity.HasOne(e => e.Account)
.WithMany(a => a.AccountChannels)
@@ -129,12 +129,48 @@ 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);
// 与Account的关系(创建者)
entity.HasOne(e => e.CreatorAccount)
.WithMany(a => a.Groups)
.HasForeignKey(e => e.CreatorAccountId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Category)
.WithMany(c => c.Groups)
.HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
});
// AccountGroup配置
modelBuilder.Entity<AccountGroup>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.AccountId, e.GroupId }).IsUnique();
entity.HasIndex(e => e.GroupId);
entity.HasIndex(e => new { e.GroupId, e.IsCreator, e.IsAdmin });
entity.HasOne(e => e.Account)
.WithMany(a => a.AccountGroups)
.HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Group)
.WithMany(g => g.AccountGroups)
.HasForeignKey(e => e.GroupId)
.OnDelete(DeleteBehavior.Cascade);
});
// GroupCategory配置
modelBuilder.Entity<GroupCategory>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.Property(e => e.Description).HasMaxLength(500);
entity.HasIndex(e => e.Name).IsUnique();
});
// BatchTask配置
@@ -24,6 +24,7 @@ public class Account
}
public long UserId { get; set; }
/// <summary>
/// 账号昵称(Telegram 显示名称)
/// </summary>
@@ -37,6 +38,16 @@ public class Account
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastSyncAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 通过 777000 系统通知最早消息时间估算的注册时间(非百分百准确)
/// </summary>
public DateTime? EstimatedRegistrationAt { get; set; }
/// <summary>
/// 最近一次尝试估算注册时间的时间(UTC)
/// </summary>
public DateTime? EstimatedRegistrationCheckedAtUtc { get; set; }
/// <summary>
/// 最后一次登录 Telegram 的时间(UTC),用于风控检查
/// </summary>
@@ -67,9 +78,22 @@ public class Account
/// </summary>
public string? TwoFactorPassword { get; set; }
/// <summary>
/// 账号当前已同步的频道数量(用于列表展示,不持久化)
/// </summary>
[NotMapped]
public int ChannelCount { get; set; }
/// <summary>
/// 账号当前已同步的群组数量(用于列表展示,不持久化)
/// </summary>
[NotMapped]
public int GroupCount { get; set; }
// 导航属性
public AccountCategory? Category { get; set; }
public ICollection<Channel> Channels { get; set; } = new List<Channel>();
public ICollection<AccountChannel> AccountChannels { get; set; } = new List<AccountChannel>();
public ICollection<Group> Groups { get; set; } = new List<Group>();
public ICollection<AccountGroup> AccountGroups { get; set; } = new List<AccountGroup>();
}
@@ -0,0 +1,27 @@
namespace TelegramPanel.Data.Entities;
/// <summary>
/// 账号-群组关联
/// </summary>
public class AccountGroup
{
public int Id { get; set; }
public int AccountId { get; set; }
public int GroupId { get; set; }
/// <summary>
/// 是否为创建者(拥有者)
/// </summary>
public bool IsCreator { get; set; }
/// <summary>
/// 是否为管理员(包含创建者)
/// </summary>
public bool IsAdmin { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
public Account Account { get; set; } = null!;
public Group Group { get; set; } = null!;
}
+5 -2
View File
@@ -12,10 +12,13 @@ public class Group
public string? Username { get; set; }
public int MemberCount { get; set; }
public string? About { get; set; }
public int CreatorAccountId { get; set; }
public int? CreatorAccountId { get; set; }
public int? CategoryId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public Account CreatorAccount { get; set; } = null!;
public Account? CreatorAccount { get; set; }
public GroupCategory? Category { get; set; }
public ICollection<AccountGroup> AccountGroups { get; set; } = new List<AccountGroup>();
}
@@ -0,0 +1,15 @@
namespace TelegramPanel.Data.Entities;
/// <summary>
/// 群组分类实体
/// </summary>
public class GroupCategory
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// 导航属性
public ICollection<Group> Groups { get; set; } = new List<Group>();
}
@@ -0,0 +1,688 @@
// <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("20260308155832_AddAccountGroups")]
partial class AddAccountGroups
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("AccountCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("AccountId", "ChannelId")
.IsUnique();
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("AccountId", "GroupId")
.IsUnique();
b.ToTable("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Bots");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("BotChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("BotChannelCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BotChannelId");
b.HasIndex("BotId");
b.HasIndex("BotId", "BotChannelId")
.IsUnique();
b.ToTable("BotChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("GroupId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<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("CreatorAccountId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
.WithMany("Accounts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountChannels")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
.WithMany("AccountChannels")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountGroups")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
.WithMany("AccountGroups")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
.WithMany("Channels")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
.WithMany("Members")
.HasForeignKey("BotChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
.WithMany("ChannelMembers")
.HasForeignKey("BotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bot");
b.Navigation("BotChannel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Channels")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
.WithMany("Channels")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Navigation("AccountChannels");
b.Navigation("AccountGroups");
b.Navigation("Channels");
b.Navigation("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Navigation("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Navigation("ChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Navigation("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Navigation("AccountGroups");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,125 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddAccountGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Groups_Accounts_CreatorAccountId",
table: "Groups");
migrationBuilder.AlterColumn<int>(
name: "CreatorAccountId",
table: "Groups",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.CreateTable(
name: "AccountGroups",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
GroupId = table.Column<int>(type: "INTEGER", nullable: false),
IsCreator = table.Column<bool>(type: "INTEGER", nullable: false),
IsAdmin = table.Column<bool>(type: "INTEGER", nullable: false),
SyncedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AccountGroups", x => x.Id);
table.ForeignKey(
name: "FK_AccountGroups_Accounts_AccountId",
column: x => x.AccountId,
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AccountGroups_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AccountGroups_AccountId_GroupId",
table: "AccountGroups",
columns: new[] { "AccountId", "GroupId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AccountGroups_GroupId",
table: "AccountGroups",
column: "GroupId");
migrationBuilder.Sql(@"
INSERT OR IGNORE INTO AccountGroups (AccountId, GroupId, IsCreator, IsAdmin, SyncedAt)
SELECT CreatorAccountId, Id, 1, 1, SyncedAt
FROM Groups
WHERE CreatorAccountId IS NOT NULL;
");
migrationBuilder.AddForeignKey(
name: "FK_Groups_Accounts_CreatorAccountId",
table: "Groups",
column: "CreatorAccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Groups_Accounts_CreatorAccountId",
table: "Groups");
migrationBuilder.Sql(@"
UPDATE Groups
SET CreatorAccountId = (
SELECT AccountId
FROM AccountGroups
WHERE GroupId = Groups.Id AND IsCreator = 1
ORDER BY SyncedAt DESC
LIMIT 1
)
WHERE CreatorAccountId IS NULL;
");
migrationBuilder.Sql(@"DELETE FROM Groups WHERE CreatorAccountId IS NULL;");
migrationBuilder.DropTable(
name: "AccountGroups");
migrationBuilder.AlterColumn<int>(
name: "CreatorAccountId",
table: "Groups",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_Groups_Accounts_CreatorAccountId",
table: "Groups",
column: "CreatorAccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,731 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TelegramPanel.Data;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260308172014_AddGroupCategories")]
partial class AddGroupCategories
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("AccountCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("AccountId", "ChannelId")
.IsUnique();
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("AccountId", "GroupId")
.IsUnique();
b.ToTable("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Bots");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("BotChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("BotChannelCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BotChannelId");
b.HasIndex("BotId");
b.HasIndex("BotId", "BotChannelId")
.IsUnique();
b.ToTable("BotChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("GroupId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorAccountId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
.WithMany("Accounts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountChannels")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
.WithMany("AccountChannels")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountGroups")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
.WithMany("AccountGroups")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
.WithMany("Channels")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
.WithMany("Members")
.HasForeignKey("BotChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
.WithMany("ChannelMembers")
.HasForeignKey("BotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bot");
b.Navigation("BotChannel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Channels")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
.WithMany("Channels")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
.WithMany("Groups")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Navigation("AccountChannels");
b.Navigation("AccountGroups");
b.Navigation("Channels");
b.Navigation("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Navigation("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Navigation("ChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Navigation("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Navigation("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Navigation("Groups");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddGroupCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "Groups",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "GroupCategories",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GroupCategories", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Groups_CategoryId",
table: "Groups",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_GroupCategories_Name",
table: "GroupCategories",
column: "Name",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Groups_GroupCategories_CategoryId",
table: "Groups",
column: "CategoryId",
principalTable: "GroupCategories",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Groups_GroupCategories_CategoryId",
table: "Groups");
migrationBuilder.DropTable(
name: "GroupCategories");
migrationBuilder.DropIndex(
name: "IX_Groups_CategoryId",
table: "Groups");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "Groups");
}
}
}
@@ -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");
}
}
}
@@ -0,0 +1,745 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TelegramPanel.Data;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260309051752_AddAccountEstimatedRegistrationFields")]
partial class AddAccountEstimatedRegistrationFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("AccountCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("AccountId", "ChannelId")
.IsUnique();
b.HasIndex("ChannelId", "IsCreator", "IsAdmin");
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("AccountId", "GroupId")
.IsUnique();
b.HasIndex("GroupId", "IsCreator", "IsAdmin");
b.ToTable("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Bots");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("BotChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("BotChannelCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BotChannelId");
b.HasIndex("BotId");
b.HasIndex("BotId", "BotChannelId")
.IsUnique();
b.ToTable("BotChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("GroupId");
b.HasIndex("SyncedAt");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorAccountId");
b.HasIndex("SyncedAt");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
.WithMany("Accounts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountChannels")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
.WithMany("AccountChannels")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountGroups")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
.WithMany("AccountGroups")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
.WithMany("Channels")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
.WithMany("Members")
.HasForeignKey("BotChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
.WithMany("ChannelMembers")
.HasForeignKey("BotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bot");
b.Navigation("BotChannel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Channels")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
.WithMany("Channels")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
.WithMany("Groups")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Navigation("AccountChannels");
b.Navigation("AccountGroups");
b.Navigation("Channels");
b.Navigation("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Navigation("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Navigation("ChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Navigation("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Navigation("AccountGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Navigation("Groups");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddAccountEstimatedRegistrationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedRegistrationAt",
table: "Accounts",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedRegistrationCheckedAtUtc",
table: "Accounts",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EstimatedRegistrationAt",
table: "Accounts");
migrationBuilder.DropColumn(
name: "EstimatedRegistrationCheckedAtUtc",
table: "Accounts");
}
}
}
@@ -15,7 +15,7 @@ namespace TelegramPanel.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.22");
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
@@ -37,6 +37,12 @@ namespace TelegramPanel.Data.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
@@ -159,9 +165,44 @@ namespace TelegramPanel.Data.Migrations
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")
@@ -416,6 +457,8 @@ namespace TelegramPanel.Data.Migrations
b.HasIndex("GroupId");
b.HasIndex("SyncedAt");
b.HasIndex("TelegramId")
.IsUnique();
@@ -463,10 +506,13 @@ namespace TelegramPanel.Data.Migrations
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CreatorAccountId")
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
@@ -489,8 +535,12 @@ namespace TelegramPanel.Data.Migrations
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorAccountId");
b.HasIndex("SyncedAt");
b.HasIndex("TelegramId")
.IsUnique();
@@ -499,6 +549,32 @@ namespace TelegramPanel.Data.Migrations
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
@@ -528,6 +604,25 @@ namespace TelegramPanel.Data.Migrations
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountGroups")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
.WithMany("AccountGroups")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
@@ -576,11 +671,17 @@ namespace TelegramPanel.Data.Migrations
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
.WithMany("Groups")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("CreatorAccount");
});
@@ -589,6 +690,8 @@ namespace TelegramPanel.Data.Migrations
{
b.Navigation("AccountChannels");
b.Navigation("AccountGroups");
b.Navigation("Channels");
b.Navigation("Groups");
@@ -623,6 +726,16 @@ namespace TelegramPanel.Data.Migrations
{
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
}
}
@@ -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();
@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 账号-群组关联仓储实现
/// </summary>
public class AccountGroupRepository : Repository<AccountGroup>, IAccountGroupRepository
{
public AccountGroupRepository(AppDbContext context) : base(context)
{
}
public async Task<AccountGroup?> GetAsync(int accountId, int groupId)
{
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);
if (existing == null)
{
await _dbSet.AddAsync(link);
}
else
{
existing.IsCreator = link.IsCreator;
existing.IsAdmin = link.IsAdmin;
existing.SyncedAt = link.SyncedAt;
_dbSet.Update(existing);
}
await _context.SaveChangesAsync();
}
public async Task 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();
var toDelete = await _dbSet
.Where(x => x.AccountId == accountId && !keep.Contains(x.GroupId))
.ToListAsync();
if (toDelete.Count == 0)
return;
_dbSet.RemoveRange(toDelete);
await _context.SaveChangesAsync();
}
public async Task<int?> GetPreferredAdminAccountIdAsync(int groupId)
{
return await _dbSet
.Where(x => x.GroupId == groupId && x.IsAdmin)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.SyncedAt)
.Select(x => (int?)x.AccountId)
.FirstOrDefaultAsync();
}
}
@@ -174,6 +174,8 @@ public class AccountRepository : Repository<Account>, IAccountRepository
.Take(pageSize)
.ToListAsync(cancellationToken);
await PopulateStatisticsAsync(items, cancellationToken);
return (items, total);
}
@@ -186,4 +188,67 @@ public class AccountRepository : Repository<Account>, IAccountRepository
var query = BuildQuery(categoryId, search, onlyWaste);
return await query.ToListAsync(cancellationToken);
}
private async Task PopulateStatisticsAsync(IReadOnlyList<Account> items, CancellationToken cancellationToken)
{
if (items.Count == 0)
return;
var accountIds = items
.Select(x => x.Id)
.Where(x => x > 0)
.Distinct()
.ToArray();
if (accountIds.Length == 0)
return;
var linkedChannelPairs = await _context.Set<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))
.ToListAsync(cancellationToken);
var createdChannelPairs = await _context.Set<Channel>()
.AsNoTracking()
.Where(x => x.IsBroadcast && x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value))
.Select(x => new { AccountId = x.CreatorAccountId!.Value, ChannelId = x.Id })
.ToListAsync(cancellationToken);
var channelCountMap = linkedChannelPairs
.Concat(createdChannelPairs)
.GroupBy(x => x.AccountId)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.ChannelId).Distinct().Count());
var linkedGroupPairs = await _context.Set<AccountGroup>()
.AsNoTracking()
.Where(x => accountIds.Contains(x.AccountId))
.Select(x => new { x.AccountId, x.GroupId })
.ToListAsync(cancellationToken);
var createdGroupPairs = await _context.Set<Group>()
.AsNoTracking()
.Where(x => x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value))
.Select(x => new { AccountId = x.CreatorAccountId!.Value, GroupId = x.Id })
.ToListAsync(cancellationToken);
var groupCountMap = linkedGroupPairs
.Concat(createdGroupPairs)
.GroupBy(x => x.AccountId)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.GroupId).Distinct().Count());
foreach (var account in items)
{
account.ChannelCount = channelCountMap.TryGetValue(account.Id, out var channelCount) ? channelCount : 0;
account.GroupCount = groupCountMap.TryGetValue(account.Id, out var groupCount) ? groupCount : 0;
}
}
}
@@ -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,19 +12,44 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
{
}
private IQueryable<Channel> BuildForViewQuery(int creatorAccountId, string? filterType, 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)
.AsQueryable();
// 列表只展示“系统创建”的频道(有 CreatorAccountId)
if (creatorAccountId <= 0)
query = query.Where(c => c.CreatorAccountId != null);
else
query = query.Where(c => c.CreatorAccountId == creatorAccountId);
query = query.Where(c => c.IsBroadcast);
query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any());
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
if (accountId > 0)
{
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId));
if (membershipRole == "creator")
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId && x.IsCreator));
else if (membershipRole == "admin")
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId && x.IsAdmin && !x.IsCreator));
else if (membershipRole == "member")
query = query.Where(c => c.AccountChannels.Any(x => x.AccountId == accountId && !x.IsAdmin));
}
else if (membershipRole == "creator")
{
query = query.Where(c => c.AccountChannels.Any(x => x.IsCreator));
}
else if (membershipRole == "admin")
{
query = query.Where(c => c.AccountChannels.Any(x => x.IsAdmin && !x.IsCreator));
}
else if (membershipRole == "member")
{
query = query.Where(c => c.AccountChannels.Any(x => !x.IsAdmin));
}
if (groupId.HasValue && groupId.Value > 0)
query = query.Where(c => c.GroupId == groupId.Value);
else if (groupId.HasValue && groupId.Value == 0)
query = query.Where(c => c.GroupId == null);
filterType = (filterType ?? "all").Trim().ToLowerInvariant();
if (filterType == "public")
@@ -38,10 +63,29 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
var like = $"%{search}%";
query = query.Where(c =>
EF.Functions.Like(c.Title, like)
|| (c.Username != null && EF.Functions.Like(c.Username, like)));
|| (c.Username != null && EF.Functions.Like(c.Username, like))
|| (c.Group != null && EF.Functions.Like(c.Group.Name, like)));
}
return query.OrderByDescending(c => c.SyncedAt);
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)
@@ -49,6 +93,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == id);
}
@@ -57,6 +103,9 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -66,6 +115,8 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.TelegramId == telegramId);
}
@@ -74,7 +125,9 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Where(c => c.CreatorAccountId != null)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast && c.CreatorAccountId != null)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -84,7 +137,9 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Where(c => c.CreatorAccountId == accountId)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast && c.CreatorAccountId == accountId)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -97,7 +152,9 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Where(c => links.Any(x => x.ChannelId == c.Id))
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast && links.Any(x => x.ChannelId == c.Id))
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -107,7 +164,9 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Where(c => c.GroupId == groupId)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast && c.GroupId == groupId)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
@@ -117,14 +176,32 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
return await _dbSet
.Include(c => c.CreatorAccount)
.Include(c => c.Group)
.Include(c => c.AccountChannels)
.AsSplitQuery()
.Where(c => c.IsBroadcast)
.OrderByDescending(c => c.SyncedAt)
.ToListAsync();
}
public async Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default)
{
var orphanedChannels = await _dbSet
.Where(c => c.CreatorAccountId == null && !c.AccountChannels.Any())
.ToListAsync(cancellationToken);
if (orphanedChannels.Count == 0)
return 0;
_dbSet.RemoveRange(orphanedChannels);
await SaveChangesWithSqliteLockRetryAsync(cancellationToken);
return orphanedChannels.Count;
}
public async Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int creatorAccountId,
int accountId,
int? groupId,
string? filterType,
string? membershipRole,
string? search,
int pageIndex,
int pageSize,
@@ -134,13 +211,34 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var query = BuildForViewQuery(creatorAccountId, filterType, 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);
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 群组分类仓储实现
/// </summary>
public class GroupCategoryRepository : Repository<GroupCategory>, IGroupCategoryRepository
{
public GroupCategoryRepository(AppDbContext context) : base(context)
{
}
public override async Task<IEnumerable<GroupCategory>> GetAllAsync()
{
return await _dbSet
.Include(g => g.Groups)
.OrderBy(g => g.Name)
.ToListAsync();
}
public async Task<GroupCategory?> GetByNameAsync(string name)
{
return await _dbSet
.Include(g => g.Groups)
.FirstOrDefaultAsync(g => g.Name == name);
}
}
@@ -12,10 +12,88 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
}
private IQueryable<Group> BuildFilterQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search)
{
var query = _dbSet
.AsNoTracking()
.AsQueryable();
query = query.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any());
membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant();
if (accountId > 0)
{
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId));
if (membershipRole == "creator")
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId && x.IsCreator));
else if (membershipRole == "admin")
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId && x.IsAdmin && !x.IsCreator));
else if (membershipRole == "member")
query = query.Where(g => g.AccountGroups.Any(x => x.AccountId == accountId && !x.IsAdmin));
}
else if (membershipRole == "creator")
{
query = query.Where(g => g.AccountGroups.Any(x => x.IsCreator));
}
else if (membershipRole == "admin")
{
query = query.Where(g => g.AccountGroups.Any(x => x.IsAdmin && !x.IsCreator));
}
else if (membershipRole == "member")
{
query = query.Where(g => g.AccountGroups.Any(x => !x.IsAdmin));
}
if (categoryId.HasValue && categoryId.Value > 0)
query = query.Where(g => g.CategoryId == categoryId.Value);
else if (categoryId.HasValue && categoryId.Value == 0)
query = query.Where(g => g.CategoryId == null);
filterType = (filterType ?? "all").Trim().ToLowerInvariant();
if (filterType == "public")
query = query.Where(g => g.Username != null && g.Username != "");
else if (filterType == "private")
query = query.Where(g => g.Username == null || g.Username == "");
search = (search ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(search))
{
var like = $"%{search}%";
query = query.Where(g =>
EF.Functions.Like(g.Title, like)
|| (g.Username != null && EF.Functions.Like(g.Username, like))
|| (g.Category != null && EF.Functions.Like(g.Category.Name, like)));
}
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)
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.FirstOrDefaultAsync(g => g.Id == id);
}
@@ -23,6 +101,10 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any())
.OrderByDescending(g => g.SyncedAt)
.ToListAsync();
}
@@ -31,6 +113,9 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.FirstOrDefaultAsync(g => g.TelegramId == telegramId);
}
@@ -38,8 +123,70 @@ public class GroupRepository : Repository<Group>, IGroupRepository
{
return await _dbSet
.Include(g => g.CreatorAccount)
.Include(g => g.Category)
.Include(g => g.AccountGroups)
.AsSplitQuery()
.Where(g => g.CreatorAccountId == accountId)
.OrderByDescending(g => g.SyncedAt)
.ToListAsync();
}
public async Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default)
{
var orphanedGroups = await _dbSet
.Where(g => g.CreatorAccountId == null && !g.AccountGroups.Any())
.ToListAsync(cancellationToken);
if (orphanedGroups.Count == 0)
return 0;
_dbSet.RemoveRange(orphanedGroups);
await SaveChangesWithSqliteLockRetryAsync(cancellationToken);
return orphanedGroups.Count;
}
public async Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? categoryId,
string? filterType,
string? membershipRole,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
{
if (pageIndex < 0) pageIndex = 0;
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var 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);
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);
}
@@ -0,0 +1,17 @@
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 账号-群组关联仓储接口
/// </summary>
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);
}
@@ -13,10 +13,13 @@ public interface IChannelRepository : IRepository<Channel>
Task<IEnumerable<Channel>> GetForAccountAsync(int accountId, bool includeNonCreator);
Task<IEnumerable<Channel>> GetByGroupAsync(int groupId);
Task<IEnumerable<Channel>> GetBroadcastChannelsAsync();
Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int creatorAccountId,
int accountId,
int? groupId,
string? filterType,
string? membershipRole,
string? search,
int pageIndex,
int pageSize,
@@ -0,0 +1,11 @@
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 群组分类仓储接口
/// </summary>
public interface IGroupCategoryRepository : IRepository<GroupCategory>
{
Task<GroupCategory?> GetByNameAsync(string name);
}
@@ -9,4 +9,14 @@ public interface IGroupRepository : IRepository<Group>
{
Task<Group?> GetByTelegramIdAsync(long telegramId);
Task<IEnumerable<Group>> GetByCreatorAccountAsync(int accountId);
Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? categoryId,
string? filterType,
string? membershipRole,
string? search,
int pageIndex,
int pageSize,
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))
@@ -23,9 +23,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAccountRepository, AccountRepository>();
services.AddScoped<IAccountCategoryRepository, AccountCategoryRepository>();
services.AddScoped<IAccountChannelRepository, AccountChannelRepository>();
services.AddScoped<IAccountGroupRepository, AccountGroupRepository>();
services.AddScoped<IChannelRepository, ChannelRepository>();
services.AddScoped<IChannelGroupRepository, ChannelGroupRepository>();
services.AddScoped<IGroupRepository, GroupRepository>();
services.AddScoped<IGroupCategoryRepository, GroupCategoryRepository>();
services.AddScoped<IBatchTaskRepository, BatchTaskRepository>();
services.AddScoped<IBotRepository, BotRepository>();
services.AddScoped<IBotChannelRepository, BotChannelRepository>();
@@ -85,16 +85,97 @@ public sealed class ModuleTaskDefinition
public string? CreateRoute { get; set; }
/// <summary>
/// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback&lt;ModuleTaskDraft&gt;)\n /// </summary>
/// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。
/// 该组件需要支持参数:
/// - Draft (ModuleTaskDraft)
/// - DraftChanged (EventCallback&lt;ModuleTaskDraft&gt;)
/// 编辑场景下还应支持可选参数:
/// - InitialConfigJson (string?)
/// </summary>
[JsonPropertyName("editorComponentType")]
public string? EditorComponentType { get; set; }
/// <summary>
/// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。
/// </summary>
[JsonPropertyName("taskCenter")]
public ModuleTaskCenterCapabilities TaskCenter { get; set; } = new();
[JsonPropertyName("order")]
public int Order { get; set; } = 0;
}
public sealed class ModuleTaskCenterCapabilities
{
[JsonPropertyName("canPause")]
public bool CanPause { get; set; }
[JsonPropertyName("canResume")]
public bool CanResume { get; set; }
[JsonPropertyName("canEdit")]
public bool CanEdit { get; set; }
[JsonPropertyName("canRerun")]
public bool CanRerun { get; set; }
/// <summary>
/// 编辑时使用的组件类型;为空时回退到 EditorComponentType。
/// </summary>
[JsonPropertyName("editComponentType")]
public string? EditComponentType { get; set; }
/// <summary>
/// 若任务仍在运行,打开编辑器前是否自动先暂停。
/// </summary>
[JsonPropertyName("autoPauseBeforeEdit")]
public bool AutoPauseBeforeEdit { get; set; }
}
public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError);
public sealed class ModuleTaskSnapshot
{
[JsonPropertyName("taskId")]
public int TaskId { get; set; }
[JsonPropertyName("taskType")]
public string TaskType { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("completed")]
public int Completed { get; set; }
[JsonPropertyName("failed")]
public int Failed { get; set; }
[JsonPropertyName("config")]
public string? Config { get; set; }
}
public sealed class ModuleTaskCreateRequest
{
[JsonPropertyName("taskType")]
public string TaskType { get; set; } = "";
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("config")]
public string? Config { get; set; }
}
public interface IModuleTaskRerunBuilder
{
string TaskType { get; }
ModuleTaskCreateRequest Build(ModuleTaskSnapshot task);
}
public interface IModuleApiProvider
{
IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context);
@@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost
Task<bool> IsStillRunningAsync(CancellationToken cancellationToken);
Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken);
}
@@ -0,0 +1,115 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject ChannelManagementService ChannelManagement
@inject ISnackbar Snackbar
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
@if (loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个频道</MudText>
@if (memberships.Count == 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该账号当前没有已同步的频道记录</MudAlert>
}
else
{
<MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>角色</MudTh>
<MudTh>频道分类</MudTh>
<MudTh>成员数</MudTh>
<MudTh>最后同步</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="频道名称">@context.Channel.Title</MudTd>
<MudTd DataLabel="用户名">@FormatUsername(context.Channel.Username)</MudTd>
<MudTd DataLabel="角色">
<MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip>
</MudTd>
<MudTd DataLabel="频道分类">@(context.Channel.Group?.Name ?? "未分类")</MudTd>
<MudTd DataLabel="成员数">@context.Channel.MemberCount</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int AccountId { get; set; }
private bool loading = true;
private List<AccountChannel> memberships = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
try
{
memberships = (await ChannelManagement.GetAccountChannelMembershipsAsync(AccountId))
.Where(x => x.Channel != null)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.ThenByDescending(x => x.SyncedAt)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号频道关系失败:{ex.Message}", Severity.Error);
memberships.Clear();
}
finally
{
loading = false;
}
}
private static string FormatUsername(string? username)
{
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
}
private static string GetRoleLabel(AccountChannel membership)
{
if (membership.IsCreator)
return "创建者";
if (membership.IsAdmin)
return "管理员";
return "成员";
}
private static Color GetRoleColor(AccountChannel membership)
{
if (membership.IsCreator)
return Color.Success;
if (membership.IsAdmin)
return Color.Info;
return Color.Default;
}
private void Close()
{
MudDialog.Close();
}
}
@@ -17,7 +17,8 @@
else
{
<MudStack Spacing="3">
<MudTextField Value="@FormatCreatedAt()" Label="创建时间" ReadOnly="true" Variant="Variant.Outlined" />
<MudTextField Value="@FormatEstimatedRegistrationAt()" Label="注册时间(估算,非百分百正确)" ReadOnly="true" Variant="Variant.Outlined" />
<MudTextField Value="@FormatCreatedAt()" Label="导入时间" ReadOnly="true" Variant="Variant.Outlined" />
<MudTextField Value="@account.SessionPath" Label="Session 路径" ReadOnly="true" Variant="Variant.Outlined" />
<MudDivider Class="my-2" />
@@ -86,6 +87,11 @@
return account == null ? "-" : TimeZone.Format(account.CreatedAt, "yyyy-MM-dd HH:mm");
}
private string FormatEstimatedRegistrationAt()
{
return account == null ? "-" : TimeZone.Format(account.EstimatedRegistrationAt, "yyyy-MM-dd HH:mm", "-");
}
private string DisplayPassword()
{
if (account == null)
@@ -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;
}
}
}
@@ -5,11 +5,11 @@
<DialogContent>
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info">
将为 <strong>@SelectedCount</strong> 个频道设置分组
将为 <strong>@SelectedCount</strong> 个频道设置分类
</MudAlert>
<MudSelect @bind-Value="selectedGroupId" Label="选择目标分组" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">未分组</MudSelectItem>
<MudSelect @bind-Value="selectedGroupId" Label="选择目标分类" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var g in Groups)
{
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
@@ -0,0 +1,40 @@
@namespace TelegramPanel.Web.Components.Dialogs
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info">
将为 <strong>@SelectedCount</strong> 个群组设置分类
</MudAlert>
<MudSelect @bind-Value="selectedCategoryId" Label="选择目标分类" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var category in Categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel">取消</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Confirm">确定</MudButton>
</DialogActions>
</MudDialog>
@code
{
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public List<GroupCategory> Categories { get; set; } = new();
[Parameter] public int SelectedCount { get; set; }
private int selectedCategoryId;
private void Cancel() => MudDialog.Cancel();
private void Confirm()
{
MudDialog.Close(DialogResult.Ok(selectedCategoryId));
}
}
@@ -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>
@@ -103,14 +151,18 @@
private bool loading = true;
private Group? group;
private string creatorDisplay = "-";
private int? executeAccountId;
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()
@@ -122,10 +174,19 @@
if (group == null)
return;
var creator = await AccountManagement.GetAccountAsync(group.CreatorAccountId);
creatorDisplay = creator == null
? $"账号 {group.CreatorAccountId}"
: $"{creator.DisplayPhone}({creator.Nickname ?? creator.Username ?? creator.UserId.ToString()})";
if (group.CreatorAccountId.HasValue)
{
var creator = await AccountManagement.GetAccountAsync(group.CreatorAccountId.Value);
creatorDisplay = creator == null
? $"账号 {group.CreatorAccountId.Value}"
: $"{creator.DisplayPhone}({creator.Nickname ?? creator.Username ?? creator.UserId.ToString()})";
}
else
{
creatorDisplay = "(非系统创建)";
}
executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync(group);
}
catch (Exception ex)
{
@@ -142,10 +203,17 @@
if (group == null)
return;
if (executeAccountId is not > 0)
{
admins.Clear();
Snackbar.Add("该群组暂无可用的执行账号(请先同步群组关联账号,或确保至少有一个账号是管理员)", Severity.Warning);
return;
}
adminsLoading = true;
try
{
admins = await GroupService.GetAdminsAsync(group.CreatorAccountId, group.TelegramId);
admins = await GroupService.GetAdminsAsync(executeAccountId.Value, group.TelegramId);
}
catch (Exception ex)
{
@@ -158,14 +226,51 @@
}
}
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)
return;
if (executeAccountId is not > 0)
{
Snackbar.Add("该群组暂无可用的执行账号,无法导出链接(请先同步群组关联账号)", Severity.Warning);
return;
}
try
{
var link = await GroupService.ExportJoinLinkAsync(group.CreatorAccountId, group.TelegramId);
var link = await GroupService.ExportJoinLinkAsync(executeAccountId.Value, group.TelegramId);
await JS.InvokeVoidAsync("telegramPanel.copyText", link);
Snackbar.Add("已复制链接", Severity.Success);
}
@@ -175,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();
}
}
@@ -1,4 +1,3 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject AccountCategoryManagementService CategoryManagement
@using System.Text.Json
@using TelegramPanel.Data.Entities
@@ -18,8 +17,14 @@
}
else
{
<MudSelect T="int" @bind-Value="selectedCategoryId" @bind-Value:after="OnInputsChanged"
Label="执行账号分类" Variant="Variant.Outlined" Dense="true">
<MudSelect T="int"
Label="执行账号分类(可多选)"
Variant="Variant.Outlined"
Dense="true"
MultiSelection="true"
SelectedValues="selectedCategoryIds"
SelectedValuesChanged="OnSelectedCategoriesChanged"
HelperText="可勾选多个分类,任务会从所有选中分类中抽取可用账号。">
@foreach (var c in categories)
{
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
@@ -91,7 +96,7 @@
private bool loading = true;
private bool initializedFromConfig;
private int selectedCategoryId;
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
private string targetsText = "";
private string dictionaryText = "";
private int delayMinMs = 15;
@@ -113,8 +118,11 @@
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (selectedCategoryId <= 0)
selectedCategoryId = categories.FirstOrDefault()?.Id ?? 0;
if (!selectedCategoryIds.Any())
{
var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0;
selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty<int>();
}
}
finally
{
@@ -140,8 +148,9 @@
if (cfg == null)
return;
if (cfg.CategoryId > 0)
selectedCategoryId = cfg.CategoryId;
var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
if (categoryIds.Count > 0)
selectedCategoryIds = categoryIds;
targetsText = string.Join(Environment.NewLine, cfg.Targets ?? new List<string>());
dictionaryText = string.Join(Environment.NewLine, cfg.Dictionary ?? new List<string>());
@@ -158,6 +167,12 @@
}
}
private async Task OnSelectedCategoriesChanged(IEnumerable<int> values)
{
selectedCategoryIds = NormalizeCategoryIds(values, 0);
await OnInputsChanged();
}
private async Task OnInputsChanged()
{
await PushDraftAsync();
@@ -167,6 +182,8 @@
{
var targets = ParseTargetLines(targetsText);
var dictionary = ParseMessageLines(dictionaryText);
var selectedIds = NormalizeCategoryIds(selectedCategoryIds, 0);
var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList();
if (loading)
{
@@ -180,9 +197,9 @@
return;
}
if (selectedCategoryId <= 0 || categories.All(x => x.Id != selectedCategoryId))
if (selectedCategories.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择执行账号分类"));
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类"));
return;
}
@@ -222,11 +239,13 @@
return;
}
var category = categories.First(x => x.Id == selectedCategoryId);
var categoryNames = selectedCategories.Select(x => x.Name).ToList();
var cfg = new UserChatActiveTaskConfig
{
CategoryId = selectedCategoryId,
CategoryName = category.Name,
CategoryId = selectedCategories[0].Id,
CategoryName = selectedCategories[0].Name,
CategoryIds = selectedCategories.Select(x => x.Id).ToList(),
CategoryNames = categoryNames,
Targets = targets,
Dictionary = dictionary,
DelayMinMs = delayMinMs * 1000,
@@ -242,6 +261,19 @@
await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null));
}
private static List<int> NormalizeCategoryIds(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 int ConvertMillisecondsToSeconds(int value)
{
if (value <= 0)
@@ -288,5 +320,3 @@
.ToList();
}
}
@@ -15,11 +15,13 @@
<MudNavGroup Title="频道管理" Icon="@Icons.Material.Filled.Campaign" Expanded="true">
<MudNavLink Href="/channels" Icon="@Icons.Material.Filled.List">频道列表</MudNavLink>
<MudNavLink Href="/channels/create" Icon="@Icons.Material.Filled.Add">创建频道</MudNavLink>
<MudNavLink Href="/channels/groups" Icon="@Icons.Material.Filled.Folder">频道分组</MudNavLink>
<MudNavLink Href="/channels/groups" Icon="@Icons.Material.Filled.Folder">频道分类</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="群组管理" Icon="@Icons.Material.Filled.Group">
<MudNavLink Href="/groups" Icon="@Icons.Material.Filled.List">群组列表</MudNavLink>
<MudNavLink Href="/groups/create" Icon="@Icons.Material.Filled.Add">创建群组</MudNavLink>
<MudNavLink Href="/groups/categories" Icon="@Icons.Material.Filled.Folder">群组分类</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="机器人管理" Icon="@Icons.Material.Filled.SmartToy">
@@ -115,6 +115,7 @@
<MudTh>频道/群组</MudTh>
<MudTh>状态</MudTh>
<MudTh>Telegram 状态</MudTh>
<MudTh>注册时间(估算,非百分百正确)</MudTh>
<MudTh>最后数据同步</MudTh>
<MudTh Style="min-width: 180px;">操作</MudTh>
</HeaderContent>
@@ -136,7 +137,7 @@
}
</MudTd>
<MudTd DataLabel="频道/群组">
<MudText Typo="Typo.body2">@context.Channels.Count / @context.Groups.Count</MudText>
<MudText Typo="Typo.body2">@context.ChannelCount / @context.GroupCount</MudText>
</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@(context.IsActive ? Color.Success : Color.Default)">
@@ -155,6 +156,7 @@
<MudChip T="string" Size="Size.Small" Color="Color.Default">未检测</MudChip>
}
</MudTd>
<MudTd DataLabel="注册时间(估算,非百分百正确)"><PanelTime Value="context.EstimatedRegistrationAt" EmptyText="-" /></MudTd>
<MudTd DataLabel="最后数据同步"><PanelTime Value="context.LastSyncAt" /></MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
@@ -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)
@@ -1,28 +1,33 @@
@page "/channels/groups"
@inject ChannelGroupManagementService GroupManagement
@inject ChannelManagementService ChannelManagement
@inject AccountManagementService AccountManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
<PageTitle>频道分组 - Telegram Panel</PageTitle>
<PageTitle>频道分类 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">频道分组管理</MudText>
<MudText Typo="Typo.h4" Class="mb-4">频道分类管理</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">添加分组</MudText>
<MudText Typo="Typo.h6">@(editingGroupId > 0 ? "编辑分类" : "添加分类")</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="newGroupName" Label="分组名称" Variant="Variant.Outlined" />
<MudTextField @bind-Value="newGroupName" Label="分类名称" Variant="Variant.Outlined" />
<MudTextField @bind-Value="newGroupDesc" Label="描述" Variant="Variant.Outlined" Lines="3" Class="mt-3" />
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Disabled="@string.IsNullOrEmpty(newGroupName)" OnClick="AddGroup">
添加分组
@if (editingGroupId > 0)
{
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelEdit">取消</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="@(editingGroupId <= 0)"
Disabled="@string.IsNullOrWhiteSpace(newGroupName)" OnClick="SaveGroup">
@(editingGroupId > 0 ? "保存修改" : "添加分类")
</MudButton>
</MudCardActions>
</MudCard>
@@ -31,27 +36,31 @@
<MudItem xs="12" md="8">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">分组列表</MudText>
<MudText Typo="Typo.h6">分类列表</MudText>
</MudCardHeader>
<MudCardContent>
<MudTable Items="@groups" Hover="true" Loading="@loading">
<HeaderContent>
<MudTh>分组名称</MudTh>
<MudTh>分类名称</MudTh>
<MudTh>描述</MudTh>
<MudTh>频道数量</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="分组名称">@context.Name</MudTd>
<MudTd DataLabel="分类名称">@context.Name</MudTd>
<MudTd DataLabel="描述">@(context.Description ?? "-")</MudTd>
<MudTd DataLabel="频道数量">@context.Channels.Count</MudTd>
<MudTd DataLabel="操作">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteGroup(context))" />
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(() => BeginEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteGroup(context))" />
</MudStack>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无分组,请添加分组</MudText>
<MudText>暂无分类,请添加分类</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
@@ -59,32 +68,43 @@
<MudCard Class="mt-4">
<MudCardHeader>
<MudText Typo="Typo.h6">分组绑定频道</MudText>
<MudText Typo="Typo.h6">分类绑定频道</MudText>
</MudCardHeader>
<MudCardContent>
<MudSelect @bind-Value="assignGroupId" Label="选择分组" Variant="Variant.Outlined" Dense="true"
@bind-Value:after="OnAssignGroupChanged">
<MudSelectItem Value="0">-- 请选择分组 --</MudSelectItem>
@foreach (var g in groups)
{
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
}
</MudSelect>
<MudStack Row="true" Spacing="2" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="assignGroupId" Label="选择分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignGroupChanged">
<MudSelectItem Value="0">-- 请选择分类 --</MudSelectItem>
@foreach (var g in groups)
{
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
}
</MudSelect>
<MudTable Items="@channels" Hover="true" Dense="true" Class="mt-3"
<MudSelect @bind-Value="filterAccountId" Label="筛选账号" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignGroupChanged">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudTable Items="@VisibleChannels" Hover="true" Dense="true" Class="mt-3"
MultiSelection="true" @bind-SelectedItems="selectedChannels"
Loading="@channelsLoading">
<HeaderContent>
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>创建账号</MudTh>
<MudTh>当前分组</MudTh>
<MudTh>当前分类</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="频道名称">@context.Title</MudTd>
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : context.Username)</MudTd>
<MudTd DataLabel="创建账号">@(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)"))</MudTd>
<MudTd DataLabel="当前分组">@(context.Group?.Name ?? "未分组")</MudTd>
<MudTd DataLabel="当前分类">@(context.Group?.Name ?? "未分类")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无频道数据</MudText>
@@ -95,7 +115,7 @@
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(assignGroupId == 0 || channelsLoading)"
OnClick="@(async (MouseEventArgs _) => await SaveGroupAssignments())">
保存勾选到分组
保存勾选到分类
</MudButton>
</MudCardActions>
</MudCard>
@@ -105,20 +125,41 @@
@code {
private string newGroupName = "";
private string newGroupDesc = "";
private int editingGroupId = 0;
private List<ChannelGroup> groups = new();
private bool loading = true;
private List<Channel> channels = new();
private List<Account> accounts = new();
private bool channelsLoading = true;
private int assignGroupId = 0;
private int filterAccountId = 0;
private HashSet<Channel> selectedChannels = new();
private IEnumerable<Channel> VisibleChannels => channels.Where(MatchAccountFilter);
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadGroups();
await LoadChannels();
}
private async Task LoadAccounts()
{
try
{
var accountList = await AccountManagement.GetAllAccountsAsync();
accounts = accountList
.Where(a => a.Category?.ExcludeFromOperations != true)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error);
}
}
private async Task LoadGroups()
{
loading = true;
@@ -144,6 +185,7 @@
{
var list = await ChannelManagement.GetAllChannelsAsync();
channels = list.ToList();
await OnAssignGroupChanged();
}
catch (Exception ex)
{
@@ -155,11 +197,19 @@
}
}
private bool MatchAccountFilter(Channel channel)
{
if (filterAccountId <= 0)
return true;
return channel.AccountChannels.Any(x => x.AccountId == filterAccountId);
}
private Task OnAssignGroupChanged()
{
selectedChannels = assignGroupId <= 0
? new HashSet<Channel>()
: channels.Where(c => c.GroupId == assignGroupId).ToHashSet();
: VisibleChannels.Where(c => c.GroupId == assignGroupId).ToHashSet();
return Task.CompletedTask;
}
@@ -167,30 +217,30 @@
{
if (assignGroupId <= 0)
{
Snackbar.Add("请选择分组", Severity.Error);
Snackbar.Add("请选择分类", Severity.Error);
return;
}
try
{
var currentlyInGroup = channels.Where(c => c.GroupId == assignGroupId).ToList();
var scope = VisibleChannels.ToList();
var currentlyInGroup = scope.Where(c => c.GroupId == assignGroupId).ToList();
var selected = selectedChannels.ToHashSet();
// 取消勾选的 -> 移出分组
foreach (var channel in currentlyInGroup.Where(c => !selected.Contains(c)))
{
await ChannelManagement.UpdateChannelGroupAsync(channel.Id, null);
}
// 新勾选的 -> 加入分组
foreach (var channel in selected.Where(c => c.GroupId != assignGroupId))
{
await ChannelManagement.UpdateChannelGroupAsync(channel.Id, assignGroupId);
}
await LoadGroups();
await LoadChannels();
selectedChannels = channels.Where(c => c.GroupId == assignGroupId).ToHashSet();
Snackbar.Add("分组绑定已保存", Severity.Success);
selectedChannels = VisibleChannels.Where(c => c.GroupId == assignGroupId).ToHashSet();
Snackbar.Add("分类绑定已保存", Severity.Success);
}
catch (Exception ex)
{
@@ -198,35 +248,66 @@
}
}
private async Task AddGroup()
private async Task SaveGroup()
{
try
{
var group = new ChannelGroup
if (editingGroupId > 0)
{
Name = newGroupName,
Description = newGroupDesc,
CreatedAt = DateTime.UtcNow
};
var existing = await GroupManagement.GetGroupAsync(editingGroupId);
if (existing == null)
{
Snackbar.Add("分类不存在", Severity.Error);
return;
}
await GroupManagement.CreateGroupAsync(group);
existing.Name = newGroupName.Trim();
existing.Description = string.IsNullOrWhiteSpace(newGroupDesc) ? null : newGroupDesc.Trim();
await GroupManagement.UpdateGroupAsync(existing);
Snackbar.Add($"分类 \"{newGroupName}\" 修改成功", Severity.Success);
}
else
{
var group = new ChannelGroup
{
Name = newGroupName.Trim(),
Description = string.IsNullOrWhiteSpace(newGroupDesc) ? null : newGroupDesc.Trim(),
CreatedAt = DateTime.UtcNow
};
await GroupManagement.CreateGroupAsync(group);
Snackbar.Add($"分类 \"{newGroupName}\" 添加成功", Severity.Success);
}
CancelEdit();
await LoadGroups();
Snackbar.Add($"分组 \"{newGroupName}\" 添加成功", Severity.Success);
newGroupName = "";
newGroupDesc = "";
await LoadChannels();
}
catch (Exception ex)
{
Snackbar.Add($"添加失败:{ex.Message}", Severity.Error);
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
}
private void BeginEdit(ChannelGroup group)
{
editingGroupId = group.Id;
newGroupName = group.Name;
newGroupDesc = group.Description ?? string.Empty;
}
private void CancelEdit()
{
editingGroupId = 0;
newGroupName = string.Empty;
newGroupDesc = string.Empty;
}
private async Task DeleteGroup(ChannelGroup group)
{
bool? result = await DialogService.ShowMessageBox(
"确认删除",
$"确定要删除分组 {group.Name} 吗?关联的频道将变为未分组。",
$"确定要删除分类 {group.Name} 吗?关联的频道将变为未分类。",
yesText: "删除", cancelText: "取消");
if (result == true)
@@ -234,7 +315,10 @@
try
{
await GroupManagement.DeleteGroupAsync(group.Id);
groups.Remove(group);
if (editingGroupId == group.Id)
CancelEdit();
await LoadGroups();
await LoadChannels();
Snackbar.Add("删除成功", Severity.Success);
}
catch (Exception ex)
@@ -243,4 +327,15 @@
}
}
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}
@@ -31,6 +31,22 @@
<MudSelectItem Value='@("public")'>公开</MudSelectItem>
<MudSelectItem Value='@("private")'>私密</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterMembershipRole" @bind-Value:after="OnFiltersChanged" Label="账号角色" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 140px; flex: 0 1 180px;">
<MudSelectItem Value='@("all")'>全部角色</MudSelectItem>
<MudSelectItem Value='@("creator")'>创建者</MudSelectItem>
<MudSelectItem Value='@("admin")'>管理员</MudSelectItem>
<MudSelectItem Value='@("member")'>非管理员</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterGroupId" @bind-Value:after="OnFiltersChanged" Label="频道分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 160px; flex: 0 1 220px;">
<MudSelectItem Value='@(-1)'>全部分类</MudSelectItem>
<MudSelectItem Value='@(0)'>未分类</MudSelectItem>
@foreach (var group in groups)
{
<MudSelectItem Value="@group.Id">@group.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="searchString" Placeholder="搜索频道..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0 tp-input-full-xs"
Style="min-width: 200px; flex: 1; max-width: 400px;"
@@ -59,7 +75,10 @@
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="BatchCopyInvitesSelected">批量复制邀请链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="BatchExportInvitesSelected">批量导出邀请链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetAdminsSelected">批量设置管理员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分组(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedChannels.Count == 0)" OnClick="OpenBatchSetGroupSelected">批量修改分类(已选)</MudMenuItem>
<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)
@@ -81,7 +100,12 @@
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>类型</MudTh>
<MudTh>频道分类</MudTh>
<MudTh>成员数</MudTh>
@if (filterAccount > 0)
{
<MudTh>当前账号角色</MudTh>
}
<MudTh>创建账号</MudTh>
<MudTh>最后同步</MudTh>
<MudTh>操作</MudTh>
@@ -114,7 +138,20 @@
@(string.IsNullOrEmpty(context.Username) ? "私密" : "公开")
</MudChip>
</MudTd>
<MudTd DataLabel="频道分类">
<MudChip T="string" Size="Size.Small" Color="@(context.GroupId.HasValue ? Color.Info : Color.Default)">
@(context.Group?.Name ?? "未分类")
</MudChip>
</MudTd>
<MudTd DataLabel="成员数">@context.MemberCount</MudTd>
@if (filterAccount > 0)
{
<MudTd DataLabel="当前账号角色">
<MudChip T="string" Size="Size.Small" Color="@GetCurrentAccountRoleColor(context)">
@GetCurrentAccountRoleLabel(context)
</MudChip>
</MudTd>
}
<MudTd DataLabel="创建账号">
@if (context.CreatorAccount != null)
{
@@ -140,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>
@@ -161,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();
@@ -169,8 +212,11 @@
private bool loading = true;
private string searchString = "";
private int filterAccount = 0;
private int filterGroupId = -1;
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet<Channel> selectedChannels = new();
private int? _lastAppliedQueryAccountId;
private CancellationTokenSource? _searchDebounceCts;
private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
@@ -196,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
@@ -220,7 +279,7 @@
}
catch (Exception ex)
{
Snackbar.Add($"加载分组失败:{ex.Message}", Severity.Error);
Snackbar.Add($"加载分类失败:{ex.Message}", Severity.Error);
}
}
@@ -230,8 +289,10 @@
try
{
var (items, total) = await ChannelManagement.QueryChannelsForViewPagedAsync(
creatorAccountId: filterAccount,
accountId: filterAccount,
groupId: filterGroupId,
filterType: filterType,
membershipRole: filterMembershipRole,
search: searchString,
pageIndex: state.Page,
pageSize: state.PageSize,
@@ -349,6 +410,42 @@
return namePart;
}
private AccountChannel? GetCurrentAccountRelation(Channel channel)
{
if (filterAccount <= 0)
return null;
return channel.AccountChannels?
.Where(x => x.AccountId == filterAccount)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.FirstOrDefault();
}
private string GetCurrentAccountRoleLabel(Channel channel)
{
var relation = GetCurrentAccountRelation(channel);
if (relation == null)
return "未同步";
if (relation.IsCreator)
return "创建者";
if (relation.IsAdmin)
return "管理员";
return "成员";
}
private Color GetCurrentAccountRoleColor(Channel channel)
{
var relation = GetCurrentAccountRelation(channel);
if (relation == null)
return Color.Default;
if (relation.IsCreator)
return Color.Success;
if (relation.IsAdmin)
return Color.Info;
return Color.Default;
}
private async Task OpenBatchInviteSelected()
{
var targets = selectedChannels.ToList();
@@ -513,6 +610,9 @@
private int GuessDefaultExecuteAccountId(IReadOnlyList<Channel> targets)
{
if (filterAccount > 0)
return filterAccount;
if (targets.Count == 1)
{
var cid = targets[0].CreatorAccountId;
@@ -520,9 +620,6 @@
return cid.Value;
}
if (filterAccount > 0)
return filterAccount;
var ids = targets.Select(x => x.CreatorAccountId).Where(x => x != null && x.Value > 0).Select(x => x!.Value).Distinct().ToList();
return ids.Count == 1 ? ids[0] : 0;
}
@@ -605,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();
@@ -623,7 +728,7 @@
["SelectedCount"] = targets.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BatchSetChannelGroupDialog>("批量修改分组", parameters, options);
var dialog = DialogService.Show<BatchSetChannelGroupDialog>("批量修改分类", parameters, options);
var result = await dialog.Result;
if (result is not { Canceled: false })
return;
@@ -639,11 +744,11 @@
selectedChannels.Clear();
await ReloadTable();
Snackbar.Add($"分组已更新:{targets.Count} 个频道", Severity.Success);
Snackbar.Add($"分类已更新:{targets.Count} 个频道", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"修改分组失败:{ex.Message}", Severity.Error);
Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
}
finally
{
@@ -661,14 +766,14 @@
{
try
{
var executeAccountId = channel.CreatorAccountId ?? filterAccount;
if (executeAccountId <= 0)
var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel);
if (executeAccountId is not > 0)
{
Snackbar.Add("无法导出链接:请先选择账号或确保频道有创建账号", Severity.Warning);
Snackbar.Add("无法导出链接:该频道暂无可用执行账号,请先同步对应账号的频道数据", Severity.Warning);
return;
}
var link = await ChannelService.ExportJoinLinkAsync(executeAccountId, channel.TelegramId);
var link = await ChannelService.ExportJoinLinkAsync(executeAccountId.Value, channel.TelegramId);
await JS.InvokeVoidAsync("telegramPanel.copyText", link);
Snackbar.Add("已复制链接", Severity.Success);
@@ -679,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,341 @@
@page "/groups/categories"
@inject GroupCategoryManagementService CategoryManagement
@inject GroupManagementService GroupManagement
@inject AccountManagementService AccountManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
<PageTitle>群组分类 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">群组分类管理</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">@(editingCategoryId > 0 ? "编辑分类" : "添加分类")</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="newCategoryName" Label="分类名称" Variant="Variant.Outlined" />
<MudTextField @bind-Value="newCategoryDesc" Label="描述" Variant="Variant.Outlined" Lines="3" Class="mt-3" />
</MudCardContent>
<MudCardActions>
@if (editingCategoryId > 0)
{
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelEdit">取消</MudButton>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="@(editingCategoryId <= 0)"
Disabled="@string.IsNullOrWhiteSpace(newCategoryName)" OnClick="SaveCategory">
@(editingCategoryId > 0 ? "保存修改" : "添加分类")
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
<MudItem xs="12" md="8">
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">分类列表</MudText>
</MudCardHeader>
<MudCardContent>
<MudTable Items="@categories" Hover="true" Loading="@categoriesLoading">
<HeaderContent>
<MudTh>分类名称</MudTh>
<MudTh>描述</MudTh>
<MudTh>群组数量</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="分类名称">@context.Name</MudTd>
<MudTd DataLabel="描述">@(context.Description ?? "-")</MudTd>
<MudTd DataLabel="群组数量">@context.Groups.Count</MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(() => BeginEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteCategory(context))" />
</MudStack>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无分类,请添加分类</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
</MudCard>
<MudCard Class="mt-4">
<MudCardHeader>
<MudText Typo="Typo.h6">分类绑定群组</MudText>
</MudCardHeader>
<MudCardContent>
<MudStack Row="true" Spacing="2" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="assignCategoryId" Label="选择分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignCategoryChanged">
<MudSelectItem Value="0">-- 请选择分类 --</MudSelectItem>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="filterAccountId" Label="筛选账号" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 220px;" @bind-Value:after="OnAssignCategoryChanged">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudTable Items="@VisibleGroups" Hover="true" Dense="true" Class="mt-3"
MultiSelection="true" @bind-SelectedItems="selectedGroups"
Loading="@groupsLoading">
<HeaderContent>
<MudTh>群组名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>创建账号</MudTh>
<MudTh>当前分类</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="群组名称">@context.Title</MudTd>
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : context.Username)</MudTd>
<MudTd DataLabel="创建账号">@(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)"))</MudTd>
<MudTd DataLabel="当前分类">@(context.Category?.Name ?? "未分类")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无群组数据</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(assignCategoryId == 0 || groupsLoading)"
OnClick="@(async (MouseEventArgs _) => await SaveCategoryAssignments())">
保存勾选到分类
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
</MudGrid>
@code {
private string newCategoryName = string.Empty;
private string newCategoryDesc = string.Empty;
private int editingCategoryId;
private List<GroupCategory> categories = new();
private bool categoriesLoading = true;
private List<Group> groups = new();
private List<Account> accounts = new();
private bool groupsLoading = true;
private int assignCategoryId;
private int filterAccountId;
private HashSet<Group> selectedGroups = new();
private IEnumerable<Group> VisibleGroups => groups.Where(MatchAccountFilter);
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadCategories();
await LoadGroups();
}
private async Task LoadAccounts()
{
try
{
var accountList = await AccountManagement.GetAllAccountsAsync();
accounts = accountList
.Where(a => a.Category?.ExcludeFromOperations != true)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error);
}
}
private async Task LoadCategories()
{
categoriesLoading = true;
try
{
var result = await CategoryManagement.GetAllCategoriesAsync();
categories = result.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载分类失败:{ex.Message}", Severity.Error);
}
finally
{
categoriesLoading = false;
}
}
private async Task LoadGroups()
{
groupsLoading = true;
try
{
var list = await GroupManagement.GetAllGroupsAsync();
groups = list.ToList();
await OnAssignCategoryChanged();
}
catch (Exception ex)
{
Snackbar.Add($"加载群组失败:{ex.Message}", Severity.Error);
}
finally
{
groupsLoading = false;
}
}
private bool MatchAccountFilter(Group group)
{
if (filterAccountId <= 0)
return true;
return group.AccountGroups.Any(x => x.AccountId == filterAccountId);
}
private Task OnAssignCategoryChanged()
{
selectedGroups = assignCategoryId <= 0
? new HashSet<Group>()
: VisibleGroups.Where(g => g.CategoryId == assignCategoryId).ToHashSet();
return Task.CompletedTask;
}
private async Task SaveCategoryAssignments()
{
if (assignCategoryId <= 0)
{
Snackbar.Add("请选择分类", Severity.Error);
return;
}
try
{
var scope = VisibleGroups.ToList();
var currentlyInCategory = scope.Where(g => g.CategoryId == assignCategoryId).ToList();
var selected = selectedGroups.ToHashSet();
foreach (var group in currentlyInCategory.Where(g => !selected.Contains(g)))
{
await GroupManagement.UpdateGroupCategoryAsync(group.Id, null);
}
foreach (var group in selected.Where(g => g.CategoryId != assignCategoryId))
{
await GroupManagement.UpdateGroupCategoryAsync(group.Id, assignCategoryId);
}
await LoadCategories();
await LoadGroups();
selectedGroups = VisibleGroups.Where(g => g.CategoryId == assignCategoryId).ToHashSet();
Snackbar.Add("分类绑定已保存", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
}
private async Task SaveCategory()
{
try
{
if (editingCategoryId > 0)
{
var existing = await CategoryManagement.GetCategoryAsync(editingCategoryId);
if (existing == null)
{
Snackbar.Add("分类不存在", Severity.Error);
return;
}
existing.Name = newCategoryName.Trim();
existing.Description = string.IsNullOrWhiteSpace(newCategoryDesc) ? null : newCategoryDesc.Trim();
await CategoryManagement.UpdateCategoryAsync(existing);
Snackbar.Add($"分类 \"{newCategoryName}\" 修改成功", Severity.Success);
}
else
{
var category = new GroupCategory
{
Name = newCategoryName.Trim(),
Description = string.IsNullOrWhiteSpace(newCategoryDesc) ? null : newCategoryDesc.Trim(),
CreatedAt = DateTime.UtcNow
};
await CategoryManagement.CreateCategoryAsync(category);
Snackbar.Add($"分类 \"{newCategoryName}\" 添加成功", Severity.Success);
}
CancelEdit();
await LoadCategories();
await LoadGroups();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
}
}
private void BeginEdit(GroupCategory category)
{
editingCategoryId = category.Id;
newCategoryName = category.Name;
newCategoryDesc = category.Description ?? string.Empty;
}
private void CancelEdit()
{
editingCategoryId = 0;
newCategoryName = string.Empty;
newCategoryDesc = string.Empty;
}
private async Task DeleteCategory(GroupCategory category)
{
bool? result = await DialogService.ShowMessageBox(
"确认删除",
$"确定要删除分类 {category.Name} 吗?关联的群组将变为未分类。",
yesText: "删除", cancelText: "取消");
if (result == true)
{
try
{
await CategoryManagement.DeleteCategoryAsync(category.Id);
if (editingCategoryId == category.Id)
CancelEdit();
await LoadCategories();
await LoadGroups();
Snackbar.Add("删除成功", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"删除失败:{ex.Message}", Severity.Error);
}
}
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}
@@ -0,0 +1,224 @@
@page "/groups/create"
@inject IGroupService GroupService
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject GroupManagementService GroupManagement
@inject GroupCategoryManagementService GroupCategoryManagement
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
@using TelegramPanel.Web.Components.Dialogs
<PageTitle>创建群组 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">创建群组</MudText>
<MudGrid>
<MudItem xs="12" md="8" Class="mx-auto">
<MudCard>
<MudCardContent>
<MudSelect @bind-Value="selectedAccountId" Label="选择账号" Variant="Variant.Outlined" Required="true">
<MudSelectItem Value="0">-- 请选择账号 --</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="selectedCategoryId" Label="群组分类" Variant="Variant.Outlined" Class="mt-4">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="groupTitle" Label="群组名称" Variant="Variant.Outlined" Required="true" Class="mt-4"
HelperText="最多255个字符" MaxLength="255" />
<MudTextField @bind-Value="groupAbout" Label="群组简介" Variant="Variant.Outlined" Lines="3" Class="mt-4"
HelperText="最多255个字符" MaxLength="255" />
<MudSwitch @bind-Value="isPublic" Label="设为公开群组" Color="Color.Primary" Class="mt-4" />
@if (isPublic)
{
<MudTextField @bind-Value="username" Label="群组用户名" Variant="Variant.Outlined" Class="mt-3"
Adornment="Adornment.Start" AdornmentText="&#64;" Required="true"
HelperText="5-32个字符,只能包含 a-z、0-9 和下划线" />
}
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mt-4">
当前创建的是 Telegram 超级群组。创建成功后会自动写入本地数据库,并绑定到当前执行账号。
</MudAlert>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Text" Color="Color.Default"
OnClick="@(async (MouseEventArgs _) => await Cancel())">取消</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(selectedAccountId == 0 || string.IsNullOrWhiteSpace(groupTitle) || creating)"
OnClick="@(async (MouseEventArgs _) => await CreateGroup())">
@if (creating)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>创建中...</span>
}
else
{
<span>创建群组</span>
}
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
</MudGrid>
@code {
private List<Account> accounts = new();
private List<GroupCategory> categories = new();
private int selectedAccountId;
private int selectedCategoryId;
private string groupTitle = string.Empty;
private string groupAbout = string.Empty;
private bool isPublic;
private string username = string.Empty;
private bool creating;
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadCategories();
}
private async Task LoadAccounts()
{
try
{
var accountList = await AccountManagement.GetActiveAccountsAsync();
accounts = accountList
.Where(a => a.Category?.ExcludeFromOperations != true)
.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载账号列表失败:{ex.Message}", Severity.Error);
}
}
private async Task LoadCategories()
{
try
{
var list = await GroupCategoryManagement.GetAllCategoriesAsync();
categories = list.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载群组分类失败:{ex.Message}", Severity.Error);
}
}
private async Task CreateGroup()
{
if (selectedAccountId == 0)
{
Snackbar.Add("请选择账号", Severity.Error);
return;
}
var title = groupTitle.Trim();
if (string.IsNullOrWhiteSpace(title))
{
Snackbar.Add("请输入群组名称", Severity.Error);
return;
}
var about = string.IsNullOrWhiteSpace(groupAbout) ? string.Empty : groupAbout.Trim();
var normalizedUsername = NormalizeUsername(username);
if (isPublic && string.IsNullOrWhiteSpace(normalizedUsername))
{
Snackbar.Add("公开群组需要设置用户名", Severity.Error);
return;
}
var account = await AccountManagement.GetAccountAsync(selectedAccountId);
if (account != null)
{
var riskCheck = RiskService.CheckLoginDuration(account);
if (riskCheck.IsRisky)
{
var parameters = new DialogParameters
{
["Message"] = riskCheck.Message,
["DetailedMessage"] = riskCheck.DetailedMessage,
["ShowRecommendations"] = true
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium };
var dialog = DialogService.Show<RiskWarningDialog>("风控警告", parameters, options);
var result = await dialog.Result;
if (result.Canceled)
return;
}
}
creating = true;
try
{
var groupInfo = await GroupService.CreateGroupAsync(selectedAccountId, title, about, isPublic, normalizedUsername);
var now = DateTime.UtcNow;
var group = new Group
{
TelegramId = groupInfo.TelegramId,
AccessHash = groupInfo.AccessHash,
Title = groupInfo.Title,
Username = groupInfo.Username,
MemberCount = Math.Max(groupInfo.MemberCount, 1),
About = groupInfo.About,
CreatorAccountId = selectedAccountId,
CategoryId = selectedCategoryId > 0 ? selectedCategoryId : null,
CreatedAt = groupInfo.CreatedAt ?? now,
SyncedAt = now
};
var savedGroup = await GroupManagement.CreateOrUpdateGroupAsync(group);
await GroupManagement.UpsertAccountGroupAsync(selectedAccountId, savedGroup.Id, isCreator: true, isAdmin: true, syncedAtUtc: now);
Snackbar.Add($"群组 \"{title}\" 创建成功", Severity.Success);
Navigation.NavigateTo("/groups");
}
catch (Exception ex)
{
Snackbar.Add($"创建失败:{ex.Message}", Severity.Error);
}
finally
{
creating = false;
}
}
private Task Cancel()
{
Navigation.NavigateTo("/groups");
return Task.CompletedTask;
}
private static string? NormalizeUsername(string? value)
{
var normalized = value?.Trim().TrimStart('@');
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}
@@ -1,6 +1,8 @@
@page "/groups"
@inject GroupManagementService GroupManagement
@inject GroupCategoryManagementService GroupCategoryManagement
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Web.Services.DataSyncService DataSync
@inject IGroupService GroupService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -13,26 +15,105 @@
<MudText Typo="Typo.h4" Class="mb-4">群组列表</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="0" Outlined="true">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<MudSelect @bind-Value="filterAccount" @bind-Value:after="OnFiltersChanged" Label="筛选账号" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 200px; flex: 1; max-width: 400px;">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="filterType" @bind-Value:after="OnFiltersChanged" Label="群组类型" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 120px; flex: 0 1 150px;">
<MudSelectItem Value='@("all")'>全部</MudSelectItem>
<MudSelectItem Value='@("public")'>公开</MudSelectItem>
<MudSelectItem Value='@("private")'>私密</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterMembershipRole" @bind-Value:after="OnFiltersChanged" Label="账号角色" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 140px; flex: 0 1 180px;">
<MudSelectItem Value='@("all")'>全部角色</MudSelectItem>
<MudSelectItem Value='@("creator")'>我创建</MudSelectItem>
<MudSelectItem Value='@("admin")'>我管理</MudSelectItem>
<MudSelectItem Value='@("member")'>非管理员</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterCategoryId" @bind-Value:after="OnFiltersChanged" Label="群组分类" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs" Style="min-width: 160px; flex: 0 1 220px;">
<MudSelectItem Value='@(-1)'>全部分类</MudSelectItem>
<MudSelectItem Value='@(0)'>未分类</MudSelectItem>
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="searchString" Placeholder="搜索群组..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0 tp-input-full-xs"
Style="min-width: 200px; flex: 1; max-width: 400px;"
Immediate="true" @bind-Value:after="OnSearchChanged" />
</MudStack>
</MudPaper>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="mb-2" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@SelectionToggleIcon"
Disabled="@(loading || groups.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small">
@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">
同步当前
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync"
Disabled="@loading" OnClick="SyncAllAccountsGroups" Size="Size.Small">
同步全部
</MudButton>
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@loading" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="BatchCopyLinksSelected">批量复制加入链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="BatchExportLinksSelected">批量导出加入链接(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" OnClick="OpenBatchSetCategorySelected">批量修改分类(已选)</MudMenuItem>
<MudDivider />
<MudMenuItem Disabled="@(selectedGroups.Count == 0)" 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>
@if (selectedGroups.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="ml-2">已选 @selectedGroups.Count</MudChip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">群组 (@totalCount)</MudText>
}
</MudStack>
<MudCard>
<MudCardContent>
<MudTable Items="@groups" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading" Filter="new Func<Group,bool>(FilterFunc)">
<ToolBarContent>
<MudText Typo="Typo.h6">我的群组 (@groups.Count)</MudText>
<MudSpacer />
<MudSelect @bind-Value="filterAccount" Label="筛选账号" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 200px;">
<MudSelectItem Value="0">全部账号</MudSelectItem>
@foreach (var account in accounts)
{
<MudSelectItem Value="@account.Id">@account.DisplayPhone (@account.Username)</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="searchString" Placeholder="搜索群组..." Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0" />
</ToolBarContent>
<MudTable ServerData="LoadGroupsServerData" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
Class="tp-table"
MultiSelection="true" @bind-SelectedItems="selectedGroups" @ref="_table">
<HeaderContent>
<MudTh>群组名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>类型</MudTh>
<MudTh>群组分类</MudTh>
<MudTh>成员数</MudTh>
@if (filterAccount > 0)
{
<MudTh>当前账号角色</MudTh>
}
<MudTh>创建账号</MudTh>
<MudTh>最后同步</MudTh>
<MudTh>操作</MudTh>
@@ -60,25 +141,53 @@
<span>-</span>
}
</MudTd>
<MudTd DataLabel="类型">
<MudChip T="string" Size="Size.Small" Color="@(string.IsNullOrWhiteSpace(context.Username) ? Color.Default : Color.Primary)">
@(string.IsNullOrWhiteSpace(context.Username) ? "私密" : "公开")
</MudChip>
</MudTd>
<MudTd DataLabel="群组分类">
<MudChip T="string" Size="Size.Small" Color="@(context.CategoryId.HasValue ? Color.Info : Color.Default)">
@(context.Category?.Name ?? "未分类")
</MudChip>
</MudTd>
<MudTd DataLabel="成员数">@context.MemberCount</MudTd>
@if (filterAccount > 0)
{
<MudTd DataLabel="当前账号角色">
<MudChip T="string" Size="Size.Small" Color="@GetCurrentAccountRoleColor(context)">
@GetCurrentAccountRoleLabel(context)
</MudChip>
</MudTd>
}
<MudTd DataLabel="创建账号">
@if (context.CreatorAccount != null)
{
<span>@context.CreatorAccount.DisplayPhone</span>
}
else
else if (context.CreatorAccountId != null)
{
<span>账号 @context.CreatorAccountId</span>
}
else
{
<span>-</span>
}
</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
<MudTd DataLabel="操作">
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small">
<MudMenuItem Icon="@Icons.Material.Filled.Info" OnClick="@(() => ShowDetails(context))">查看详情</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyLink(context))">复制链接</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(() => DeleteGroup(context.Id))">删除群组</MudMenuItem>
</MudMenu>
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<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>
</MudTd>
</RowTemplate>
<NoRecordsContent>
@@ -87,21 +196,66 @@
<LoadingContent>
<MudProgressCircular Indeterminate="true" />
</LoadingContent>
<PagerContent>
<MudTablePager PageSizeOptions="new int[] { 20, 50, 100, 200 }" />
</PagerContent>
</MudTable>
</MudCardContent>
</MudCard>
@code {
[SupplyParameterFromQuery(Name = "accountId")]
public int? QueryAccountId { get; set; }
private MudTable<Group>? _table;
private List<Group> groups = new();
private List<Account> accounts = new();
private List<GroupCategory> categories = new();
private int totalCount;
private bool loading = true;
private string searchString = "";
private int filterAccount = 0;
private int filterCategoryId = -1;
private string filterType = "all";
private string filterMembershipRole = "all";
private HashSet<Group> selectedGroups = new();
private int? _lastAppliedQueryAccountId;
private CancellationTokenSource? _searchDebounceCts;
private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
private string SelectionToggleText => _selectionToggleMode switch
{
SelectionToggleMode.SelectAll => "全选本页",
SelectionToggleMode.Invert => "反选本页",
SelectionToggleMode.Clear => "清空选择",
_ => "全选本页"
};
private string SelectionToggleIcon => _selectionToggleMode switch
{
SelectionToggleMode.SelectAll => Icons.Material.Filled.SelectAll,
SelectionToggleMode.Invert => Icons.Material.Filled.Flip,
SelectionToggleMode.Clear => Icons.Material.Filled.Clear,
_ => Icons.Material.Filled.SelectAll
};
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
await LoadGroups();
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()
@@ -119,17 +273,44 @@
}
}
private async Task LoadGroups()
private async Task LoadCategories()
{
try
{
var list = await GroupCategoryManagement.GetAllCategoriesAsync();
categories = list.ToList();
}
catch (Exception ex)
{
Snackbar.Add($"加载分类失败:{ex.Message}", Severity.Error);
}
}
private async Task<TableData<Group>> LoadGroupsServerData(TableState state, CancellationToken cancellationToken)
{
loading = true;
try
{
var groupList = await GroupManagement.GetAllGroupsAsync();
groups = groupList.ToList();
var (items, total) = await GroupManagement.QueryGroupsForViewPagedAsync(
accountId: filterAccount,
categoryId: filterCategoryId,
filterType: filterType,
membershipRole: filterMembershipRole,
search: searchString,
pageIndex: state.Page,
pageSize: state.PageSize,
cancellationToken: cancellationToken);
groups = items.ToList();
totalCount = total;
selectedGroups.Clear();
return new TableData<Group> { Items = groups, TotalItems = totalCount };
}
catch (Exception ex)
{
Snackbar.Add($"加载失败:{ex.Message}", Severity.Error);
return new TableData<Group> { Items = Array.Empty<Group>(), TotalItems = 0 };
}
finally
{
@@ -137,35 +318,366 @@
}
}
private bool FilterFunc(Group group)
private async Task ReloadTable()
{
if (filterAccount > 0 && group.CreatorAccountId != filterAccount)
return false;
if (string.IsNullOrWhiteSpace(searchString))
return true;
if (group.Title.Contains(searchString, StringComparison.OrdinalIgnoreCase))
return true;
if (group.Username?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true)
return true;
return false;
selectedGroups.Clear();
if (_table != null)
await _table.ReloadServerData();
}
private void ShowDetails(Group group)
private Task SelectAllFiltered()
{
selectedGroups = groups.ToHashSet();
return Task.CompletedTask;
}
private Task InvertSelection()
{
var targets = groups.ToList();
foreach (var group in targets)
{
if (selectedGroups.Contains(group))
selectedGroups.Remove(group);
else
selectedGroups.Add(group);
}
return Task.CompletedTask;
}
private Task ClearSelection()
{
selectedGroups.Clear();
return Task.CompletedTask;
}
private async Task OnFiltersChanged()
{
await ReloadTable();
}
private async Task OnSearchChanged()
{
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
_searchDebounceCts = new CancellationTokenSource();
var token = _searchDebounceCts.Token;
try
{
await Task.Delay(300, token);
await ReloadTable();
}
catch (OperationCanceledException)
{
// ignore
}
}
private async Task CycleSelectionToggle()
{
switch (_selectionToggleMode)
{
case SelectionToggleMode.SelectAll:
await SelectAllFiltered();
_selectionToggleMode = SelectionToggleMode.Invert;
break;
case SelectionToggleMode.Invert:
await InvertSelection();
_selectionToggleMode = SelectionToggleMode.Clear;
break;
case SelectionToggleMode.Clear:
await ClearSelection();
_selectionToggleMode = SelectionToggleMode.SelectAll;
break;
default:
_selectionToggleMode = SelectionToggleMode.SelectAll;
break;
}
}
private enum SelectionToggleMode
{
SelectAll = 0,
Invert = 1,
Clear = 2
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
private AccountGroup? GetCurrentAccountRelation(Group group)
{
if (filterAccount <= 0)
return null;
return group.AccountGroups?
.Where(x => x.AccountId == filterAccount)
.OrderByDescending(x => x.IsCreator)
.ThenByDescending(x => x.IsAdmin)
.FirstOrDefault();
}
private string GetCurrentAccountRoleLabel(Group group)
{
var relation = GetCurrentAccountRelation(group);
if (relation == null)
return "未同步";
if (relation.IsCreator)
return "创建者";
if (relation.IsAdmin)
return "管理员";
return "成员";
}
private Color GetCurrentAccountRoleColor(Group group)
{
var relation = GetCurrentAccountRelation(group);
if (relation == null)
return Color.Default;
if (relation.IsCreator)
return Color.Success;
if (relation.IsAdmin)
return Color.Info;
return Color.Default;
}
private async Task BatchCopyLinksSelected()
{
if (loading)
return;
var targets = selectedGroups.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认复制",
$"将为 {targets.Count} 个群组生成加入链接并复制到剪贴板。是否继续?",
yesText: "继续", cancelText: "取消");
if (result != true)
return;
loading = true;
try
{
var lines = new List<string>
{
$"# ExportedAtUtc: {DateTime.UtcNow:O}",
"# Format: <TelegramId>\\t<Title>\\t<Link>",
""
};
var ok = 0;
var skipped = 0;
var failed = 0;
foreach (var group in targets)
{
var executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync(
group,
preferredAccountId: filterAccount > 0 ? filterAccount : null);
if (executeAccountId is not > 0)
{
skipped++;
lines.Add($"{group.TelegramId}\t{group.Title}\t(无可用执行账号)");
continue;
}
try
{
var link = await GroupService.ExportJoinLinkAsync(executeAccountId.Value, group.TelegramId);
ok++;
lines.Add($"{group.TelegramId}\t{group.Title}\t{link}");
}
catch
{
failed++;
lines.Add($"{group.TelegramId}\t{group.Title}\t(无法生成/不可见/无权限)");
}
}
var text = string.Join(Environment.NewLine, lines);
await JS.InvokeVoidAsync("telegramPanel.copyText", text);
Snackbar.Add($"已复制加入链接:成功 {ok}/{targets.Count},失败 {failed}/{targets.Count},跳过 {skipped}/{targets.Count}", failed == 0 ? Severity.Success : Severity.Warning);
}
catch (Exception ex)
{
Snackbar.Add($"复制失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task BatchExportLinksSelected()
{
if (loading)
return;
var targets = selectedGroups.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认导出",
$"将为 {targets.Count} 个群组生成加入链接并导出。是否继续?",
yesText: "继续", cancelText: "取消");
if (result != true)
return;
var ids = string.Join(",", targets.Select(x => x.TelegramId));
var url = $"/downloads/groups/invites.txt?ids={Uri.EscapeDataString(ids)}";
if (filterAccount > 0)
url += $"&accountId={filterAccount}";
Navigation.NavigateTo(url, forceLoad: true);
await Task.CompletedTask;
}
private async Task OpenBatchSetCategorySelected()
{
var targets = selectedGroups.ToList();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
if (categories.Count == 0)
await LoadCategories();
var parameters = new DialogParameters
{
["Categories"] = categories,
["SelectedCount"] = targets.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<BatchSetGroupCategoryDialog>("批量修改分类", parameters, options);
var result = await dialog.Result;
if (result is not { Canceled: false })
return;
var categoryIdRaw = result.Data is int x ? x : 0;
int? categoryId = categoryIdRaw <= 0 ? null : categoryIdRaw;
loading = true;
try
{
foreach (var group in targets)
await GroupManagement.UpdateGroupCategoryAsync(group.Id, categoryId);
selectedGroups.Clear();
await ReloadTable();
Snackbar.Add($"分类已更新:{targets.Count} 个群组", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task SyncCurrentAccountGroups()
{
if (loading || filterAccount <= 0)
return;
loading = true;
try
{
var summary = await DataSync.SyncAccountAsync(filterAccount, CancellationToken.None);
foreach (var failure in summary.AccountFailures)
Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning);
Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning);
await ReloadTable();
}
catch (Exception ex)
{
Snackbar.Add($"同步失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task SyncAllAccountsGroups()
{
if (loading)
return;
loading = true;
try
{
var summary = await DataSync.SyncAllActiveAccountsAsync(CancellationToken.None);
foreach (var failure in summary.AccountFailures)
Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning);
Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning);
await ReloadTable();
}
catch (Exception ex)
{
Snackbar.Add($"同步失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task ShowDetails(Group group)
{
var parameters = new DialogParameters { ["GroupDbId"] = group.Id };
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true };
DialogService.Show<GroupDetailsDialog>("群组详情", parameters, options);
await Task.CompletedTask;
}
private async Task CopyLink(Group group)
private async Task 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 link = await GroupService.ExportJoinLinkAsync(group.CreatorAccountId, group.TelegramId);
var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group);
if (executeAccountId is not > 0)
{
Snackbar.Add("无法导出链接:该群组暂无可用执行账号,请先同步对应账号的群组数据", Severity.Warning);
return;
}
var link = await GroupService.ExportJoinLinkAsync(executeAccountId.Value, group.TelegramId);
await JS.InvokeVoidAsync("telegramPanel.copyText", link);
Snackbar.Add("已复制链接", Severity.Success);
}
@@ -175,10 +687,250 @@
}
}
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();
if (targets.Count == 0)
{
Snackbar.Add("未选择任何群组", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认删除",
$"确定要删除选中的 {targets.Count} 个群组吗?此操作不可恢复。",
yesText: "删除", cancelText: "取消");
if (result != true)
return;
loading = true;
try
{
foreach (var group in targets)
await GroupManagement.DeleteGroupAsync(group.Id);
selectedGroups.Clear();
await ReloadTable();
Snackbar.Add($"删除成功:{targets.Count} 个群组", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"删除失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task DeleteGroup(int id)
{
var group = groups.FirstOrDefault(g => g.Id == id);
if (group == null) return;
if (group == null)
return;
bool? result = await DialogService.ShowMessageBox(
"确认删除",
@@ -190,7 +942,7 @@
try
{
await GroupManagement.DeleteGroupAsync(id);
groups.Remove(group);
await ReloadTable();
Snackbar.Add("删除成功", Severity.Success);
}
catch (Exception ex)
@@ -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)
{
@@ -142,9 +145,13 @@
<MudCardContent>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mb-3">
<MudText Typo="Typo.body2">
<strong>同步说明:</strong>将每个活跃账号“创建的频道 + 创建的群组”拉取并写入本地数据库,用于频道/群组列表展示,以及批量任务自动选择执行账号。
<strong>同步说明:</strong>将每个活跃账号“当前可见的频道 + 当前可见的群组”拉取并写入本地数据库,用于频道/群组列表展示,以及批量任务自动选择执行账号。
<br />
<strong>不包含:</strong>Telegram 状态检测(冻结/封号等)与验证码收取。
<strong>任务中心:</strong>自动同步与“立即同步”都会记录到【任务中心】,可查看是否正在运行、已同步到第几个账号以及失败数量。
<br />
<strong>补充:</strong>同步过程中若遇到连接异常,会顺带执行一次轻量 Telegram 状态刷新(不做创建频道深度探测)。
<br />
<strong>不包含:</strong>深度 Telegram 状态检测(创建频道探测)与验证码收取。
</MudText>
</MudAlert>
@@ -154,10 +161,20 @@
<MudNumericField @bind-Value="syncInterval" Label="同步间隔 (小时)" Variant="Variant.Outlined" Class="mt-3"
Min="1" Max="24" />
}
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true" Class="mt-3"
OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())">
保存同步设置
</MudButton>
<MudStack Row="true" Spacing="2" Class="mt-3" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Class="tp-input-full-xs"
Style="flex: 1; min-width: 220px;"
OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())">
保存同步设置
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Info" Class="tp-input-full-xs"
Style="flex: 1; min-width: 220px;"
Disabled="@syncing"
OnClick="@(async (MouseEventArgs _) => await SyncNow())">
@(syncing ? "同步中..." : "立即同步频道/群组")
</MudButton>
</MudStack>
<MudDivider Class="my-4" />
@@ -176,10 +193,6 @@
保存 Bot 自动同步设置
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" FullWidth="true" Class="mt-4"
OnClick="@(async (MouseEventArgs _) => await SyncNow())">
立即同步
</MudButton>
</MudCardContent>
</MudCard>
@@ -255,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;
@@ -304,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;
@@ -557,6 +572,12 @@
return;
}
if (historyRetentionLimit < 0 || historyRetentionLimit > 5000)
{
Snackbar.Add("历史任务保留上限范围应为 0-5000", Severity.Error);
return;
}
try
{
var root = await LoadOrCreateLocalConfigAsync();
@@ -567,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)
@@ -629,20 +660,16 @@
}
syncing = true;
Snackbar.Add("开始同步频道和群组信息...", Severity.Info);
Snackbar.Add("正在后台创建同步任务...", Severity.Info);
try
{
var summary = await DataSync.SyncAllActiveAccountsAsync(CancellationToken.None);
foreach (var failure in summary.AccountFailures)
Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning);
Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning);
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
{
@@ -110,6 +110,8 @@
<MudIconButton Icon="@Icons.Material.Filled.Cancel" Size="Size.Small" Color="Color.Warning"
OnClick="@(() => CancelTask(context.Id))" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteTask(context))" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
@@ -175,7 +177,7 @@
OnClick="@(() => RerunTask(context))" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteTask(context.Id))" />
OnClick="@(() => DeleteTask(context))" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
@@ -296,6 +298,7 @@
BatchTaskTypes.UserChatActive => "user",
BatchTaskTypes.BotChannelSetAdminsByAccount => "bot",
BatchTaskTypes.BotSetAdmins => "bot",
BatchTaskTypes.AccountAutoSync => "system",
_ => "other"
};
}
@@ -327,7 +330,11 @@
&& !string.IsNullOrWhiteSpace(reg.Definition.Icon))
return reg.Definition.Icon;
return Icons.Material.Filled.Task;
return type switch
{
BatchTaskTypes.AccountAutoSync => Icons.Material.Filled.Sync,
_ => Icons.Material.Filled.Task
};
}
private string GetTaskTypeName(string type)
@@ -342,6 +349,7 @@
BatchTaskTypes.UserChatActive => "账号持续活跃(群组/频道)",
BatchTaskTypes.BotChannelSetAdminsByAccount => "Bot频道批量设置管理员(账号执行)",
BatchTaskTypes.BotSetAdmins => "Bot频道批量设置管理员(机器人执行)",
BatchTaskTypes.AccountAutoSync => "账号数据同步",
_ => type
};
}
@@ -538,8 +546,7 @@
using var doc = JsonDocument.Parse(task.Config);
var root = doc.RootElement;
var categoryName = root.TryGetProperty("category_name", out var categoryNameEl) ? (categoryNameEl.GetString() ?? "").Trim() : "";
var categoryId = root.TryGetProperty("category_id", out var categoryIdEl) && categoryIdEl.TryGetInt32(out var cid) ? cid : 0;
var categoryText = BuildSelectedCategorySummary(root);
var maxMessages = root.TryGetProperty("max_messages", out var maxMessagesEl) && maxMessagesEl.TryGetInt32(out var mm) ? mm : 0;
var accountMode = root.TryGetProperty("account_mode", out var accountModeEl) ? (accountModeEl.GetString() ?? "").Trim() : "";
var targetMode = root.TryGetProperty("target_mode", out var targetModeEl) ? (targetModeEl.GetString() ?? "").Trim() : "";
@@ -573,7 +580,6 @@
}
}
var categoryText = categoryName.Length > 0 ? $"{categoryName} (#{categoryId})" : $"#{categoryId}";
details += "\n配置摘要:\n";
details += $"分类: {categoryText}\n";
details += $"目标数: {targetCount}\n";
@@ -607,6 +613,72 @@
return seconds.ToString("0.###");
}
private static string BuildSelectedCategorySummary(JsonElement root)
{
var ids = ExtractSelectedCategoryIds(root);
var names = ExtractSelectedCategoryNames(root);
var count = Math.Max(ids.Count, names.Count);
if (count == 0)
return "-";
var parts = new List<string>();
for (var i = 0; i < count; i++)
{
var id = i < ids.Count ? ids[i] : 0;
var name = i < names.Count ? names[i] : string.Empty;
if (id > 0 && name.Length > 0)
parts.Add($"{name} (#{id})");
else if (id > 0)
parts.Add($"#{id}");
else if (name.Length > 0)
parts.Add(name);
}
return parts.Count == 0 ? "-" : string.Join(", ", parts);
}
private static List<int> ExtractSelectedCategoryIds(JsonElement root)
{
var ids = new List<int>();
if (root.TryGetProperty("category_ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
foreach (var item in idsEl.EnumerateArray())
{
if (item.TryGetInt32(out var id) && id > 0)
ids.Add(id);
}
}
if (ids.Count == 0 && root.TryGetProperty("category_id", out var categoryIdEl) && categoryIdEl.TryGetInt32(out var categoryId) && categoryId > 0)
ids.Add(categoryId);
return ids.Distinct().ToList();
}
private static List<string> ExtractSelectedCategoryNames(JsonElement root)
{
var names = new List<string>();
if (root.TryGetProperty("category_names", out var namesEl) && namesEl.ValueKind == JsonValueKind.Array)
{
foreach (var item in namesEl.EnumerateArray())
{
var name = (item.GetString() ?? string.Empty).Trim();
if (name.Length > 0)
names.Add(name);
}
}
if (names.Count == 0 && root.TryGetProperty("category_name", out var categoryNameEl))
{
var fallbackName = (categoryNameEl.GetString() ?? string.Empty).Trim();
if (fallbackName.Length > 0)
names.Add(fallbackName);
}
return names.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static string NormalizeTaskModeDisplay(string? mode)
{
var text = (mode ?? "").Trim();
@@ -688,20 +760,26 @@
}
}
private async Task DeleteTask(int id)
private async Task DeleteTask(BatchTask task)
{
var status = GetDisplayStatus(task);
var isActive = IsActiveStatus(status);
var message = isActive
? $"确定要删除任务 #{task.Id} 吗?删除后将停止后台执行并移除记录,此操作不可恢复。"
: $"确定要删除任务 #{task.Id} 的记录吗?此操作不可恢复。";
bool? result = await DialogService.ShowMessageBox(
"确认删除",
"确定要删除这个任务记录吗?此操作不可恢复。",
message,
yesText: "删除", cancelText: "取消");
if (result == true)
{
try
{
await TaskManagement.DeleteTaskAsync(id);
tasks.RemoveAll(t => t.Id == id);
Snackbar.Add("任务已删除", Severity.Success);
await TaskManagement.DeleteTaskAsync(task.Id);
await LoadTasks();
Snackbar.Add(isActive ? "任务已删除,后台执行将在下一次状态检查时停止" : "任务已删除", Severity.Success);
}
catch (Exception ex)
{
@@ -885,9 +963,14 @@
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
}
if (cfg.CategoryId <= 0)
cfg.CategoryIds = NormalizeCategoryIdsForRerun(cfg.CategoryIds, cfg.CategoryId);
if (cfg.CategoryIds.Count == 0)
throw new InvalidOperationException("任务缺少账号分类,无法重新运行");
cfg.CategoryId = cfg.CategoryIds[0];
cfg.CategoryNames = NormalizeCategoryNamesForRerun(cfg.CategoryNames, cfg.CategoryName);
cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName;
cfg.Targets = (cfg.Targets ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
@@ -923,6 +1006,34 @@
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)
@@ -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;
}
+72
View File
@@ -1005,6 +1005,78 @@ var channelInvitesDownload = app.MapGet("/downloads/channels/invites.txt", async
if (adminAuthEnabled)
channelInvitesDownload.RequireAuthorization();
// 下载:导出群组加入链接(文本)
var groupInvitesDownload = app.MapGet("/downloads/groups/invites.txt", async (
HttpContext http,
GroupManagementService groupManagement,
IGroupService groupService,
CancellationToken cancellationToken) =>
{
var idsRaw = http.Request.Query["ids"].ToString();
IReadOnlyList<long> telegramIds;
if (string.IsNullOrWhiteSpace(idsRaw))
{
telegramIds = (await groupManagement.GetAllGroupsAsync()).Select(x => x.TelegramId).Where(x => x > 0).Distinct().ToList();
}
else
{
telegramIds = idsRaw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var x) ? x : 0)
.Where(x => x > 0)
.Distinct()
.ToList();
}
var preferredAccountIdRaw = http.Request.Query["accountId"].ToString();
var preferredAccountId = int.TryParse(preferredAccountIdRaw, out var x) ? x : 0;
if (preferredAccountId <= 0)
preferredAccountId = 0;
var lines = new List<string>
{
$"# ExportedAtUtc: {DateTime.UtcNow:O}",
"# Format: <TelegramId>\\t<Title>\\t<Link>",
""
};
foreach (var telegramId in telegramIds)
{
cancellationToken.ThrowIfCancellationRequested();
var group = await groupManagement.GetGroupByTelegramIdAsync(telegramId);
if (group == null)
{
lines.Add($"{telegramId}\t(unknown)\t(群组不存在)");
continue;
}
var executeAccountId = await groupManagement.ResolveExecuteAccountIdAsync(group, preferredAccountId: preferredAccountId);
if (executeAccountId is not > 0)
{
lines.Add($"{telegramId}\t{group.Title}\t(无可用执行账号)");
continue;
}
try
{
var link = await groupService.ExportJoinLinkAsync(executeAccountId.Value, telegramId);
lines.Add($"{telegramId}\t{group.Title}\t{link}");
}
catch
{
lines.Add($"{telegramId}\t{group.Title}\t(无法生成/不可见/无权限)");
}
}
var text = string.Join(Environment.NewLine, lines);
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
var fileName = $"telegram-panel-group-invites-{DateTime.UtcNow:yyyyMMdd-HHmmss}.txt";
return Results.File(bytes, "text/plain; charset=utf-8", fileName);
}).DisableAntiforgery();
if (adminAuthEnabled)
groupInvitesDownload.RequireAuthorization();
// Telegram Bot Webhook 端点
// 接收 Telegram 服务器推送的更新,用于 Webhook 模式
app.MapPost("/api/bot/webhook/{secretToken}", async (
@@ -67,15 +67,23 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService
var dataSync = scope.ServiceProvider.GetRequiredService<DataSyncService>();
_logger.LogInformation("Account auto sync started");
var summary = await dataSync.SyncAllActiveAccountsAsync(stoppingToken);
var run = await dataSync.RunAllActiveAccountsTrackedAsync("auto", stoppingToken);
var summary = run.Summary;
if (summary.AccountFailures.Count > 0)
{
foreach (var f in summary.AccountFailures)
_logger.LogWarning("Account auto sync failed: {Phone} {Error}", f.Phone, f.Error);
}
_logger.LogInformation("Account auto sync completed: {Channels} channels, {Groups} groups (failures={Failures})",
summary.TotalChannelsSynced, summary.TotalGroupsSynced, summary.AccountFailures.Count);
_logger.LogInformation(
"Account auto sync completed: taskId={TaskId}, accounts={Processed}/{Total}, channels={Channels}, groups={Groups}, failures={Failures}",
run.TaskId,
summary.ProcessedAccounts,
summary.TotalAccounts,
summary.TotalChannelsSynced,
summary.TotalGroupsSynced,
summary.AccountFailures.Count);
// 记录“上次自动同步时间”,用于容器重启后继续按间隔调度,避免“重启即跑一轮”导致限流。
await PersistLastAutoSyncAtUtcAsync(DateTime.UtcNow, stoppingToken);
@@ -84,6 +92,11 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService
{
// ignore
}
catch (InvalidOperationException ex) when (ex.Message.Contains("已在运行", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Account auto sync skipped: {Message}", ex.Message);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Account auto sync failed");
@@ -151,4 +164,3 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService
return created;
}
}
+298 -18
View File
@@ -1,5 +1,8 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.BatchTasks;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Services;
using TelegramPanel.Core.Services.Telegram;
@@ -12,12 +15,16 @@ namespace TelegramPanel.Web.Services;
/// </summary>
public class DataSyncService
{
private static readonly SemaphoreSlim SyncGate = new(1, 1);
private readonly AccountManagementService _accountManagement;
private readonly ChannelManagementService _channelManagement;
private readonly GroupManagementService _groupManagement;
private readonly IChannelService _channelService;
private readonly IGroupService _groupService;
private readonly AccountTelegramToolsService _telegramTools;
private readonly BatchTaskManagementService _taskManagement;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<DataSyncService> _logger;
@@ -28,6 +35,8 @@ public class DataSyncService
IChannelService channelService,
IGroupService groupService,
AccountTelegramToolsService telegramTools,
BatchTaskManagementService taskManagement,
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
ILogger<DataSyncService> logger)
{
@@ -37,29 +46,194 @@ public class DataSyncService
_channelService = channelService;
_groupService = groupService;
_telegramTools = telegramTools;
_taskManagement = taskManagement;
_scopeFactory = scopeFactory;
_configuration = configuration;
_logger = logger;
}
public async Task<SyncSummary> SyncAllActiveAccountsAsync(CancellationToken cancellationToken)
public async Task<TrackedSyncResult> RunAllActiveAccountsTrackedAsync(string trigger, CancellationToken cancellationToken)
{
var accounts = await _accountManagement.GetActiveAccountsAsync();
return await SyncAccountsAsync(accounts, cancellationToken);
var task = await CreateTrackedTaskAsync(trigger, cancellationToken);
return await ExecuteTrackedSyncAsync(task.Id, trigger, cancellationToken);
}
public async Task<SyncSummary> SyncAccountAsync(int accountId, CancellationToken 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;
try
{
gateEntered = await SyncGate.WaitAsync(0, cancellationToken);
if (!gateEntered)
throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度");
var accounts = await GetDistinctActiveAccountsAsync();
await _taskManagement.UpdateTaskDraftAsync(
taskId,
accounts.Count,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: accounts.Count,
processedAccounts: 0,
failedAccounts: 0,
totalChannelsSynced: 0,
totalGroupsSynced: 0,
failures: Array.Empty<SyncFailureItem>(),
error: null));
await _taskManagement.StartTaskAsync(taskId);
var summary = await SyncAccountsAsync(
accounts,
cancellationToken,
progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(taskId, progress.ProcessedAccounts, progress.FailedAccounts));
await _taskManagement.UpdateTaskProgressAsync(taskId, summary.ProcessedAccounts, summary.FailedAccountsCount);
await _taskManagement.UpdateTaskConfigAsync(
taskId,
BuildSyncTaskConfig(
trigger: trigger,
totalAccounts: summary.TotalAccounts,
processedAccounts: summary.ProcessedAccounts,
failedAccounts: summary.FailedAccountsCount,
totalChannelsSynced: summary.TotalChannelsSynced,
totalGroupsSynced: summary.TotalGroupsSynced,
failures: summary.AccountFailures.Select(ToFailureItem).ToList(),
error: null));
await _taskManagement.CompleteTaskAsync(taskId, success: true);
return new TrackedSyncResult(taskId, summary);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
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)
{
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
{
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)
{
var accounts = await _accountManagement.GetActiveAccountsAsync();
return await SyncAccountsAsync(accounts, cancellationToken, progressCallback);
}
public async Task<SyncSummary> SyncAccountAsync(
int accountId,
CancellationToken cancellationToken,
Func<SyncProgress, Task>? progressCallback = null)
{
var account = await _accountManagement.GetAccountAsync(accountId)
?? throw new InvalidOperationException($"账号不存在:{accountId}");
return await SyncAccountsAsync(new[] { account }, cancellationToken);
return await SyncAccountsAsync(new[] { account }, cancellationToken, progressCallback);
}
public async Task<SyncSummary> SyncAccountsAsync(IEnumerable<Account> accounts, CancellationToken cancellationToken)
public async Task<SyncSummary> SyncAccountsAsync(
IEnumerable<Account> accounts,
CancellationToken cancellationToken,
Func<SyncProgress, Task>? progressCallback = null)
{
var summary = new SyncSummary();
var accountList = accounts.ToList();
var accountList = accounts
.Where(x => x != null)
.GroupBy(x => x.Id)
.Select(x => x.First())
.ToList();
summary.TotalAccounts = accountList.Count;
var delayMs = _configuration.GetValue("Telegram:DefaultDelayMs", 2000);
if (delayMs < 0) delayMs = 0;
if (delayMs > 60000) delayMs = 60000;
@@ -67,19 +241,27 @@ public class DataSyncService
for (var index = 0; index < accountList.Count; index++)
{
var account = accountList[index];
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.GetOwnedChannelsAsync(account.Id);
// 同步频道:拉取账号当前可见的全部频道,并记录该账号在频道中的角色关系。
var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id, cancellationToken);
var keepChannelIds = new List<int>(capacity: channelInfos.Count);
foreach (var channelInfo in channelInfos)
{
cancellationToken.ThrowIfCancellationRequested();
var channel = new TelegramPanel.Data.Entities.Channel
var channel = new Channel
{
TelegramId = channelInfo.TelegramId,
AccessHash = channelInfo.AccessHash,
@@ -88,41 +270,67 @@ public class DataSyncService
IsBroadcast = channelInfo.IsBroadcast,
MemberCount = channelInfo.MemberCount,
About = channelInfo.About,
CreatorAccountId = account.Id,
CreatorAccountId = channelInfo.IsCreator ? account.Id : null,
CreatedAt = channelInfo.CreatedAt
};
var saved = await _channelManagement.CreateOrUpdateChannelAsync(channel);
keepChannelIds.Add(saved.Id);
await _channelManagement.UpsertAccountChannelAsync(
accountId: account.Id,
channelId: saved.Id,
isCreator: channelInfo.IsCreator,
isAdmin: channelInfo.IsAdmin,
syncedAtUtc: DateTime.UtcNow);
summary.TotalChannelsSynced++;
}
// 同步群组:保持原逻辑(仅创建的群组)
var groups = await _groupService.GetOwnedGroupsAsync(account.Id);
foreach (var groupInfo in groups)
await _channelManagement.DeleteStaleAccountChannelsAsync(account.Id, keepChannelIds);
// 同步群组:拉取账号当前可见的全部群组,并记录该账号在群组中的角色关系。
var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id, cancellationToken);
var keepGroupIds = new List<int>(capacity: groupInfos.Count);
foreach (var groupInfo in groupInfos)
{
cancellationToken.ThrowIfCancellationRequested();
var group = new TelegramPanel.Data.Entities.Group
var group = new Group
{
TelegramId = groupInfo.TelegramId,
AccessHash = groupInfo.AccessHash,
Title = groupInfo.Title,
Username = groupInfo.Username,
MemberCount = groupInfo.MemberCount,
About = null,
CreatorAccountId = account.Id
About = groupInfo.About,
CreatorAccountId = groupInfo.IsCreator ? account.Id : null,
CreatedAt = groupInfo.CreatedAt
};
await _groupManagement.CreateOrUpdateGroupAsync(group);
var saved = await _groupManagement.CreateOrUpdateGroupAsync(group);
keepGroupIds.Add(saved.Id);
await _groupManagement.UpsertAccountGroupAsync(
accountId: account.Id,
groupId: saved.Id,
isCreator: groupInfo.IsCreator,
isAdmin: groupInfo.IsAdmin,
syncedAtUtc: DateTime.UtcNow);
summary.TotalGroupsSynced++;
}
await _groupManagement.DeleteStaleAccountGroupsAsync(account.Id, keepGroupIds);
await _telegramTools.EnsureEstimatedRegistrationAsync(account.Id, cancellationToken);
await _accountManagement.UpdateLastSyncTimeAsync(account.Id);
}
catch (Exception ex)
{
accountFailed = true;
_logger.LogDebug(ex, "Account sync failed (debug details): {AccountId}", account.Id);
summary.AccountFailures.Add((account.Id, account.Phone, ex.Message));
@@ -175,6 +383,20 @@ public class DataSyncService
_logger.LogWarning(statusEx, "Failed to update Telegram status for account {AccountId}", account.Id);
}
}
finally
{
summary.ProcessedAccounts++;
if (!accountFailed)
summary.SucceededAccounts++;
if (progressCallback != null)
{
await progressCallback(new SyncProgress(
TotalAccounts: summary.TotalAccounts,
ProcessedAccounts: summary.ProcessedAccounts,
FailedAccounts: summary.FailedAccountsCount));
}
}
// 降速:同步多个账号时插入延迟,降低触发 FLOOD_WAIT 的概率
if (delayMs > 0 && index < accountList.Count - 1)
@@ -187,10 +409,68 @@ public class DataSyncService
return summary;
}
private static SyncFailureItem ToFailureItem((int AccountId, string Phone, string Error) failure)
{
return new SyncFailureItem(failure.AccountId, failure.Phone, failure.Error);
}
private static string BuildSyncTaskConfig(
string trigger,
int totalAccounts,
int processedAccounts,
int failedAccounts,
int totalChannelsSynced,
int totalGroupsSynced,
IReadOnlyCollection<SyncFailureItem> failures,
string? error)
{
var payload = new
{
trigger = string.IsNullOrWhiteSpace(trigger) ? "manual" : trigger.Trim(),
scope = "all_active_accounts",
includes = new[]
{
"visible_channels_sync",
"visible_groups_sync",
"lightweight_telegram_status_refresh_on_sync_error"
},
excludes = new[]
{
"deep_telegram_status_probe",
"verification_code_collection"
},
progress = new
{
totalAccounts,
processedAccounts,
failedAccounts
},
result = new
{
totalChannelsSynced,
totalGroupsSynced
},
failures = failures.Take(50).ToList(),
error
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
private sealed record SyncFailureItem(int AccountId, string Phone, string Error);
public sealed class SyncSummary
{
public int TotalAccounts { get; set; }
public int ProcessedAccounts { get; set; }
public int SucceededAccounts { get; set; }
public int FailedAccountsCount => AccountFailures.Count;
public int TotalChannelsSynced { get; set; }
public int TotalGroupsSynced { get; set; }
public List<(int AccountId, string Phone, string Error)> AccountFailures { get; } = new();
}
public readonly record struct SyncProgress(int TotalAccounts, int ProcessedAccounts, int FailedAccounts);
public readonly record struct TrackedSyncResult(int TaskId, SyncSummary Summary);
}
@@ -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;
}
}
@@ -25,9 +25,10 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler
var config = DeserializeConfig(host.Config);
ValidateAndNormalizeConfig(config);
var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet();
var allAccounts = (await accountManagement.GetAllAccountsAsync())
.Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true)
.Where(x => x.CategoryId == config.CategoryId)
.Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value))
.OrderBy(x => x.Id)
.ToList();
@@ -196,9 +197,14 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler
private static void ValidateAndNormalizeConfig(UserChatActiveTaskConfig config)
{
if (config.CategoryId <= 0)
config.CategoryIds = NormalizeSelectedCategoryIds(config);
if (config.CategoryIds.Count == 0)
throw new InvalidOperationException("任务缺少账号分类");
config.CategoryId = config.CategoryIds[0];
config.CategoryNames = NormalizeSelectedCategoryNames(config);
config.CategoryName = config.CategoryNames.FirstOrDefault() ?? config.CategoryName;
config.Targets = config.Targets
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
@@ -231,6 +237,34 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler
config.RecentFailures ??= new List<UserChatActiveTaskRuntimeFailure>();
}
private static List<int> NormalizeSelectedCategoryIds(UserChatActiveTaskConfig config)
{
var ids = (config.CategoryIds ?? new List<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0 && config.CategoryId > 0)
ids.Add(config.CategoryId);
return ids;
}
private static List<string> NormalizeSelectedCategoryNames(UserChatActiveTaskConfig config)
{
var names = (config.CategoryNames ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var fallbackName = (config.CategoryName ?? string.Empty).Trim();
if (names.Count == 0 && fallbackName.Length > 0)
names.Add(fallbackName);
return names;
}
private static string NormalizeMode(string? mode)
{
return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase)
@@ -16,6 +16,12 @@ public sealed class UserChatActiveTaskConfig
[JsonPropertyName("category_name")]
public string? CategoryName { get; set; }
[JsonPropertyName("category_ids")]
public List<int> CategoryIds { get; set; } = new();
[JsonPropertyName("category_names")]
public List<string> CategoryNames { get; set; } = new();
[JsonPropertyName("targets")]
public List<string> Targets { get; set; } = new();
@@ -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;
}
}