merge(dev): 合并 v1.31.5 开发分支功能
This commit is contained in:
commit
9a78210403
75 files changed
+9133
-343
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
+688
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+731
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+739
@@ -0,0 +1,739 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TelegramPanel.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TelegramPanel.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260308181608_OptimizeChatListIndexes")]
|
||||
partial class OptimizeChatListIndexes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ApiHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ApiId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSyncAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SessionPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TelegramStatusDetails")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool?>("TelegramStatusOk")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TelegramStatusSummary")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TwoFactorPassword")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ExcludeFromOperations")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCreator")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SyncedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("AccountId", "ChannelId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ChannelId", "IsCreator", "IsAdmin");
|
||||
|
||||
b.ToTable("AccountChannels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GroupId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCreator")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SyncedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
|
||||
b.HasIndex("AccountId", "GroupId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("GroupId", "IsCreator", "IsAdmin");
|
||||
|
||||
b.ToTable("AccountGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Completed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Config")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Failed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TaskType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Total")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("BatchTasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastSyncAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastUpdateId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username");
|
||||
|
||||
b.ToTable("Bots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("About")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AccessHash")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ChannelStatusError")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool?>("ChannelStatusOk")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBroadcast")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MemberCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SyncedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TelegramId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("ChannelStatusOk");
|
||||
|
||||
b.HasIndex("TelegramId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username");
|
||||
|
||||
b.ToTable("BotChannels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BotChannelCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BotChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BotId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SyncedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BotChannelId");
|
||||
|
||||
b.HasIndex("BotId");
|
||||
|
||||
b.HasIndex("BotId", "BotChannelId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BotChannelMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("About")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AccessHash")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("CreatorAccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("GroupId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsBroadcast")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MemberCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SyncedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TelegramId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorAccountId");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
|
||||
b.HasIndex("SyncedAt");
|
||||
|
||||
b.HasIndex("TelegramId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username");
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChannelGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("About")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AccessHash")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("CreatorAccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MemberCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SyncedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TelegramId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("CreatorAccountId");
|
||||
|
||||
b.HasIndex("SyncedAt");
|
||||
|
||||
b.HasIndex("TelegramId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Username");
|
||||
|
||||
b.ToTable("Groups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("GroupCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
|
||||
.WithMany("AccountChannels")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
|
||||
.WithMany("AccountChannels")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
|
||||
.WithMany("AccountGroups")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
|
||||
.WithMany("AccountGroups")
|
||||
.HasForeignKey("GroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
|
||||
.WithMany("Channels")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("BotChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
|
||||
.WithMany("ChannelMembers")
|
||||
.HasForeignKey("BotId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Bot");
|
||||
|
||||
b.Navigation("BotChannel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
|
||||
.WithMany("Channels")
|
||||
.HasForeignKey("CreatorAccountId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
|
||||
.WithMany("Channels")
|
||||
.HasForeignKey("GroupId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("CreatorAccount");
|
||||
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
|
||||
{
|
||||
b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
|
||||
.WithMany("Groups")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
|
||||
.WithMany("Groups")
|
||||
.HasForeignKey("CreatorAccountId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("CreatorAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
|
||||
{
|
||||
b.Navigation("AccountChannels");
|
||||
|
||||
b.Navigation("AccountGroups");
|
||||
|
||||
b.Navigation("Channels");
|
||||
|
||||
b.Navigation("Groups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
|
||||
{
|
||||
b.Navigation("ChannelMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
|
||||
{
|
||||
b.Navigation("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
|
||||
{
|
||||
b.Navigation("AccountChannels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
|
||||
{
|
||||
b.Navigation("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
|
||||
{
|
||||
b.Navigation("AccountGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
|
||||
{
|
||||
b.Navigation("Groups");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TelegramPanel.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OptimizeChatListIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Groups_SyncedAt",
|
||||
table: "Groups",
|
||||
column: "SyncedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Channels_SyncedAt",
|
||||
table: "Channels",
|
||||
column: "SyncedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin",
|
||||
table: "AccountGroups",
|
||||
columns: new[] { "GroupId", "IsCreator", "IsAdmin" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin",
|
||||
table: "AccountChannels",
|
||||
columns: new[] { "ChannelId", "IsCreator", "IsAdmin" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Groups_SyncedAt",
|
||||
table: "Groups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Channels_SyncedAt",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin",
|
||||
table: "AccountGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin",
|
||||
table: "AccountChannels");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+745
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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<ModuleTaskDraft>)\n /// </summary>
|
||||
/// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。
|
||||
/// 该组件需要支持参数:
|
||||
/// - Draft (ModuleTaskDraft)
|
||||
/// - DraftChanged (EventCallback<ModuleTaskDraft>)
|
||||
/// 编辑场景下还应支持可选参数:
|
||||
/// - InitialConfigJson (string?)
|
||||
/// </summary>
|
||||
[JsonPropertyName("editorComponentType")]
|
||||
public string? EditorComponentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskCenter")]
|
||||
public ModuleTaskCenterCapabilities TaskCenter { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; set; } = 0;
|
||||
}
|
||||
|
||||
public sealed class ModuleTaskCenterCapabilities
|
||||
{
|
||||
[JsonPropertyName("canPause")]
|
||||
public bool CanPause { get; set; }
|
||||
|
||||
[JsonPropertyName("canResume")]
|
||||
public bool CanResume { get; set; }
|
||||
|
||||
[JsonPropertyName("canEdit")]
|
||||
public bool CanEdit { get; set; }
|
||||
|
||||
[JsonPropertyName("canRerun")]
|
||||
public bool CanRerun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编辑时使用的组件类型;为空时回退到 EditorComponentType。
|
||||
/// </summary>
|
||||
[JsonPropertyName("editComponentType")]
|
||||
public string? EditComponentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 若任务仍在运行,打开编辑器前是否自动先暂停。
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoPauseBeforeEdit")]
|
||||
public bool AutoPauseBeforeEdit { get; set; }
|
||||
}
|
||||
|
||||
public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError);
|
||||
|
||||
public sealed class ModuleTaskSnapshot
|
||||
{
|
||||
[JsonPropertyName("taskId")]
|
||||
public int TaskId { get; set; }
|
||||
|
||||
[JsonPropertyName("taskType")]
|
||||
public string TaskType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
public int Completed { get; set; }
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
public int Failed { get; set; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ModuleTaskCreateRequest
|
||||
{
|
||||
[JsonPropertyName("taskType")]
|
||||
public string TaskType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; set; }
|
||||
}
|
||||
|
||||
public interface IModuleTaskRerunBuilder
|
||||
{
|
||||
string TaskType { get; }
|
||||
ModuleTaskCreateRequest Build(ModuleTaskSnapshot task);
|
||||
}
|
||||
|
||||
public interface IModuleApiProvider
|
||||
{
|
||||
IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context);
|
||||
@@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost
|
||||
Task<bool> IsStillRunningAsync(CancellationToken cancellationToken);
|
||||
Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
@namespace TelegramPanel.Web.Components.Dialogs
|
||||
@inject ChannelManagementService ChannelManagement
|
||||
@inject ISnackbar Snackbar
|
||||
@using TelegramPanel.Data.Entities
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">共 @memberships.Count 个频道</MudText>
|
||||
|
||||
@if (memberships.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">该账号当前没有已同步的频道记录</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@memberships" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
|
||||
<HeaderContent>
|
||||
<MudTh>频道名称</MudTh>
|
||||
<MudTh>用户名</MudTh>
|
||||
<MudTh>角色</MudTh>
|
||||
<MudTh>频道分类</MudTh>
|
||||
<MudTh>成员数</MudTh>
|
||||
<MudTh>最后同步</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="频道名称">@context.Channel.Title</MudTd>
|
||||
<MudTd DataLabel="用户名">@FormatUsername(context.Channel.Username)</MudTd>
|
||||
<MudTd DataLabel="角色">
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetRoleColor(context)">@GetRoleLabel(context)</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="频道分类">@(context.Channel.Group?.Name ?? "未分类")</MudTd>
|
||||
<MudTd DataLabel="成员数">@context.Channel.MemberCount</MudTd>
|
||||
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Close">关闭</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
|
||||
[Parameter] public int AccountId { get; set; }
|
||||
|
||||
private bool loading = true;
|
||||
private List<AccountChannel> memberships = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
loading = true;
|
||||
try
|
||||
{
|
||||
memberships = (await ChannelManagement.GetAccountChannelMembershipsAsync(AccountId))
|
||||
.Where(x => x.Channel != null)
|
||||
.OrderByDescending(x => x.IsCreator)
|
||||
.ThenByDescending(x => x.IsAdmin)
|
||||
.ThenByDescending(x => x.SyncedAt)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"加载账号频道关系失败:{ex.Message}", Severity.Error);
|
||||
memberships.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatUsername(string? username)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim();
|
||||
}
|
||||
|
||||
private static string GetRoleLabel(AccountChannel membership)
|
||||
{
|
||||
if (membership.IsCreator)
|
||||
return "创建者";
|
||||
if (membership.IsAdmin)
|
||||
return "管理员";
|
||||
return "成员";
|
||||
}
|
||||
|
||||
private static Color GetRoleColor(AccountChannel membership)
|
||||
{
|
||||
if (membership.IsCreator)
|
||||
return Color.Success;
|
||||
if (membership.IsAdmin)
|
||||
return Color.Info;
|
||||
return Color.Default;
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
MudDialog.Close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="@" 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user