feat: 任务中心区分用户/Bot任务并新增批量订阅加群
This commit is contained in:
1 parent
5c2b59ed02
commit
76496f935e
7 files changed
+689
-7
No files matched your search
@@ -0,0 +1,15 @@
|
||||
namespace TelegramPanel.Core.BatchTasks;
|
||||
|
||||
/// <summary>
|
||||
/// 批量任务类型常量(数据库中 BatchTask.TaskType 的取值)。
|
||||
/// </summary>
|
||||
public static class BatchTaskTypes
|
||||
{
|
||||
// Bot 任务(现有)
|
||||
public const string Invite = "invite";
|
||||
public const string SetAdmin = "set_admin";
|
||||
|
||||
// User 任务(新增)
|
||||
public const string UserJoinSubscribe = "user_join_subscribe";
|
||||
}
|
||||
|
||||
@@ -635,6 +635,86 @@ public class AccountTelegramToolsService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过链接/用户名加入群组或订阅频道(支持 https://t.me/xxx、t.me/+hash、@username、username、tg://join?invite=hash 等)。
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string? Error, string? JoinedTitle)> JoinChatOrChannelAsync(
|
||||
int accountId,
|
||||
string linkOrUsername,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = (linkOrUsername ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return (false, "链接/用户名为空", null);
|
||||
|
||||
var url = NormalizeTelegramJoinUrl(raw);
|
||||
|
||||
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chat = await client.AnalyzeInviteLink(url, join: true);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = chat switch
|
||||
{
|
||||
Channel c => c.title,
|
||||
Chat c => c.title,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return (true, null, title);
|
||||
}
|
||||
catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_ALREADY_PARTICIPANT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (true, null, "已在群组/频道中");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var (summary, details) = MapTelegramException(ex);
|
||||
var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}";
|
||||
return (false, msg, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTelegramJoinUrl(string input)
|
||||
{
|
||||
var s = (input ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
throw new ArgumentException("链接/用户名为空", nameof(input));
|
||||
|
||||
// tg://join?invite=xxxx
|
||||
if (s.StartsWith("tg://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var inviteKey = "invite=";
|
||||
var idx = s.IndexOf(inviteKey, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var hash = s[(idx + inviteKey.Length)..];
|
||||
var amp = hash.IndexOf('&');
|
||||
if (amp >= 0)
|
||||
hash = hash[..amp];
|
||||
hash = hash.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(hash))
|
||||
return $"https://t.me/+{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
// 直接是 t.me/xxx
|
||||
if (s.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) || s.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase))
|
||||
return "https://" + s;
|
||||
|
||||
// @username / username
|
||||
if (s.StartsWith("@", StringComparison.Ordinal))
|
||||
s = s.TrimStart('@');
|
||||
|
||||
if (!s.Contains("://", StringComparison.Ordinal))
|
||||
return $"https://t.me/{s}";
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新当前账号头像(静态图片)。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
@namespace TelegramPanel.Web.Components.Dialogs
|
||||
@inject AccountManagementService AccountManagement
|
||||
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
|
||||
@inject BatchTaskManagementService TaskManagement
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Navigation
|
||||
@using System.Text.Json
|
||||
@using TelegramPanel.Core.BatchTasks
|
||||
@using TelegramPanel.Data.Entities
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
任务会提交到后台执行,可在【任务中心】查看进度与结果。
|
||||
</MudAlert>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="selectedCategory" Label="任务分类" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value='@("user")'>用户任务</MudSelectItem>
|
||||
<MudSelectItem Value='@("bot")'>Bot 任务</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudSelect @bind-Value="selectedTaskType" Label="任务类型" Variant="Variant.Outlined" Dense="true">
|
||||
@foreach (var opt in AvailableOptions)
|
||||
{
|
||||
<MudSelectItem Value="@opt.TaskType">@opt.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.Description))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary">@CurrentOption.Description</MudText>
|
||||
}
|
||||
|
||||
@if (selectedTaskType == BatchTaskTypes.UserJoinSubscribe)
|
||||
{
|
||||
<MudDivider Class="my-2" />
|
||||
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
批量订阅频道/加群(用户任务):支持输入多个链接/用户名,换行分隔。支持:<b>https://t.me/xxx</b>、<b>t.me/+hash</b>、<b>@username</b>、<b>username</b>、<b>tg://join?invite=hash</b>。
|
||||
</MudAlert>
|
||||
|
||||
<MudTextField @bind-Value="linksText" Label="频道/群链接列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
|
||||
HelperText="每行一个链接或用户名;将对所选账号逐个执行订阅/加入。" />
|
||||
|
||||
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="10000"
|
||||
HelperText="建议设置 1500-4000ms,避免触发风控(会额外加少量随机抖动)。" />
|
||||
|
||||
<MudStack direction="Row" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.subtitle2">选择执行账号:@selectedAccounts.Count / @accounts.Count</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SelectAll" Disabled="@(accounts.Count == 0)">全选</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Secondary" OnClick="ClearSelection" Disabled="@(selectedAccounts.Count == 0)">清空</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTextField @bind-Value="accountSearch" Placeholder="搜索账号(手机号/昵称/用户名)" Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Immediate="true" />
|
||||
|
||||
<MudTable Items="@FilteredAccounts" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"
|
||||
MultiSelection="true" @bind-SelectedItems="selectedAccounts" Loading="@loadingAccounts">
|
||||
<HeaderContent>
|
||||
<MudTh>手机号</MudTh>
|
||||
<MudTh>昵称</MudTh>
|
||||
<MudTh>用户名</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="手机号">@context.Phone</MudTd>
|
||||
<MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Nickname) ? "-" : context.Nickname)</MudTd>
|
||||
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : "@" + context.Username)</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>暂无账号</MudText>
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
}
|
||||
else if (selectedTaskType == BatchTaskTypes.Invite)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
当前版本暂未在任务中心内置“批量邀请”的创建表单,请使用“批量邀请”页面创建任务。
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => NavigateTo("/batch/invite"))">
|
||||
前往批量邀请
|
||||
</MudButton>
|
||||
}
|
||||
else if (selectedTaskType == BatchTaskTypes.SetAdmin)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
当前版本暂未在任务中心内置“设置管理员”的创建表单,请使用“批量设置管理员”页面创建任务。
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => NavigateTo("/batch/admins"))">
|
||||
前往批量设置管理员
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@creating">关闭</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Create" Disabled="@creating">
|
||||
@if (creating)
|
||||
{
|
||||
<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!;
|
||||
|
||||
private sealed record TaskOption(string Category, string TaskType, string DisplayName, string? Description);
|
||||
|
||||
private static readonly List<TaskOption> Options = new()
|
||||
{
|
||||
new("bot", BatchTaskTypes.Invite, "批量邀请", "对所选频道批量邀请用户名列表(Bot/执行账号需具备权限)。"),
|
||||
new("bot", BatchTaskTypes.SetAdmin, "设置管理员", "对所选频道批量设置管理员(Bot/执行账号需具备权限)。"),
|
||||
new("user", BatchTaskTypes.UserJoinSubscribe, "批量订阅/加群", "用户账号批量加入群组/订阅频道(Bot 无法主动加入)。")
|
||||
};
|
||||
|
||||
private string _selectedCategory = "user";
|
||||
private string selectedCategory
|
||||
{
|
||||
get => _selectedCategory;
|
||||
set
|
||||
{
|
||||
if (string.Equals(_selectedCategory, value, StringComparison.Ordinal))
|
||||
return;
|
||||
_selectedCategory = value;
|
||||
EnsureTaskTypeValid();
|
||||
}
|
||||
}
|
||||
private string selectedTaskType = BatchTaskTypes.UserJoinSubscribe;
|
||||
|
||||
private bool creating;
|
||||
|
||||
// join/subscribe config
|
||||
private string linksText = "";
|
||||
private int delayMs = 2000;
|
||||
|
||||
private bool loadingAccounts;
|
||||
private List<Account> accounts = new();
|
||||
private string accountSearch = "";
|
||||
private HashSet<Account> selectedAccounts = new();
|
||||
|
||||
private IEnumerable<TaskOption> AvailableOptions => Options.Where(o => o.Category == selectedCategory);
|
||||
private TaskOption? CurrentOption => Options.FirstOrDefault(o => o.TaskType == selectedTaskType);
|
||||
|
||||
private IEnumerable<Account> FilteredAccounts =>
|
||||
accounts.Where(a =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountSearch))
|
||||
return true;
|
||||
var q = accountSearch.Trim();
|
||||
return a.Phone.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| (a.Nickname?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
|| (a.Username?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
});
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
EnsureTaskTypeValid();
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
{
|
||||
loadingAccounts = true;
|
||||
try
|
||||
{
|
||||
var list = await AccountManagement.GetActiveAccountsAsync();
|
||||
accounts = list.OrderBy(a => a.Id).ToList();
|
||||
selectedAccounts = selectedAccounts.Where(a => accounts.Any(x => x.Id == a.Id)).ToHashSet();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingAccounts = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTaskTypeValid()
|
||||
{
|
||||
if (AvailableOptions.All(o => o.TaskType != selectedTaskType))
|
||||
{
|
||||
selectedTaskType = AvailableOptions.FirstOrDefault()?.TaskType
|
||||
?? Options.First().TaskType;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAll() => selectedAccounts = accounts.ToHashSet();
|
||||
private void ClearSelection() => selectedAccounts = new HashSet<Account>();
|
||||
|
||||
private async Task Create()
|
||||
{
|
||||
if (creating)
|
||||
return;
|
||||
|
||||
EnsureTaskTypeValid();
|
||||
|
||||
if (selectedTaskType != BatchTaskTypes.UserJoinSubscribe)
|
||||
{
|
||||
Snackbar.Add("当前任务类型暂不支持从任务中心直接创建", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var links = ParseLines(linksText);
|
||||
if (links.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少输入一个频道/群链接", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = selectedAccounts.ToList();
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少选择一个执行账号", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量风控检查:加入群/订阅频道是敏感操作
|
||||
var batchCheck = RiskService.CheckBatchAccounts(selected);
|
||||
if (batchCheck.HasRiskyAccounts)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
|
||||
["DetailedMessage"] = batchCheck.GetRiskySummary(),
|
||||
["ShowRecommendations"] = true,
|
||||
["ShowExcludeOption"] = true,
|
||||
["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
|
||||
};
|
||||
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
|
||||
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result.Canceled)
|
||||
return;
|
||||
|
||||
if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
|
||||
{
|
||||
selected = batchCheck.SafeAccounts.ToList();
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool? confirm = await DialogService.ShowMessageBox(
|
||||
"确认提交任务",
|
||||
$"将对 {selected.Count} 个账号执行 {links.Count} 个链接的订阅/加群操作,总计 {selected.Count * links.Count} 次。是否继续?",
|
||||
yesText: "继续", cancelText: "取消");
|
||||
|
||||
if (confirm != true)
|
||||
return;
|
||||
|
||||
creating = true;
|
||||
try
|
||||
{
|
||||
var ids = selected.Select(a => a.Id).Distinct().ToList();
|
||||
var task = new BatchTask
|
||||
{
|
||||
TaskType = BatchTaskTypes.UserJoinSubscribe,
|
||||
Total = ids.Count * links.Count,
|
||||
Completed = 0,
|
||||
Failed = 0,
|
||||
Config = JsonSerializer.Serialize(new
|
||||
{
|
||||
AccountIds = ids,
|
||||
Links = links,
|
||||
DelayMs = delayMs
|
||||
})
|
||||
};
|
||||
|
||||
var created = await TaskManagement.CreateTaskAsync(task);
|
||||
Snackbar.Add($"已提交后台任务 #{created.Id}(批量订阅/加群)。请到【任务中心】查看进度。", Severity.Success);
|
||||
MudDialog.Close(DialogResult.Ok(created.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"提交失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateTo(string url)
|
||||
{
|
||||
MudDialog.Close();
|
||||
Navigation.NavigateTo(url);
|
||||
}
|
||||
|
||||
private static List<string> ParseLines(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new List<string>();
|
||||
|
||||
return text
|
||||
.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Close();
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
@namespace TelegramPanel.Web.Components.Dialogs
|
||||
@inject AccountManagementService AccountManagement
|
||||
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
|
||||
@inject BatchTaskManagementService TaskManagement
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using System.Text.Json
|
||||
@using TelegramPanel.Core.BatchTasks
|
||||
@using TelegramPanel.Data.Entities
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
将使用指定账号订阅频道/加入群组。支持输入多个链接/用户名,换行分隔。
|
||||
<MudText Typo="Typo.body2" Class="mt-1">
|
||||
支持:<b>https://t.me/xxx</b>、<b>t.me/+hash</b>、<b>@username</b>、<b>username</b>、<b>tg://join?invite=hash</b>
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
|
||||
@if (account == null)
|
||||
{
|
||||
<MudText Color="Color.Error">账号不存在或已被删除</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2">
|
||||
执行账号:@account.Phone @(string.IsNullOrWhiteSpace(account.Nickname) ? "" : $"({account.Nickname})")
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="linksText" Label="频道/群链接列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
|
||||
HelperText="每行一个链接或用户名;提交后将创建后台任务执行。" />
|
||||
|
||||
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="10000"
|
||||
HelperText="建议设置 1500-4000ms,避免触发风控(会额外加少量随机抖动)。" />
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@submitting">关闭</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(submitting || account == null)">
|
||||
@if (submitting)
|
||||
{
|
||||
<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 AccountId { get; set; }
|
||||
|
||||
private Account? account;
|
||||
private string linksText = "";
|
||||
private int delayMs = 2000;
|
||||
private bool submitting;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (AccountId <= 0)
|
||||
return;
|
||||
|
||||
account = await AccountManagement.GetAccountAsync(AccountId);
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (submitting || account == null)
|
||||
return;
|
||||
|
||||
var links = ParseLines(linksText);
|
||||
if (links.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少输入一个频道/群链接", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// 风控检查:加入群/订阅频道是敏感操作
|
||||
var batchCheck = RiskService.CheckBatchAccounts(new List<Account> { account });
|
||||
if (batchCheck.HasRiskyAccounts)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
|
||||
["DetailedMessage"] = batchCheck.GetRiskySummary(),
|
||||
["ShowRecommendations"] = true,
|
||||
["ShowExcludeOption"] = false,
|
||||
["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
|
||||
};
|
||||
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
|
||||
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
if (result.Canceled)
|
||||
return;
|
||||
}
|
||||
|
||||
bool? confirm = await DialogService.ShowMessageBox(
|
||||
"确认执行",
|
||||
$"将使用账号 {account.Phone} 对 {links.Count} 个链接执行订阅/加群操作。是否继续?",
|
||||
yesText: "继续", cancelText: "取消");
|
||||
|
||||
if (confirm != true)
|
||||
return;
|
||||
|
||||
submitting = true;
|
||||
try
|
||||
{
|
||||
var task = new BatchTask
|
||||
{
|
||||
TaskType = BatchTaskTypes.UserJoinSubscribe,
|
||||
Total = links.Count,
|
||||
Completed = 0,
|
||||
Failed = 0,
|
||||
Config = JsonSerializer.Serialize(new
|
||||
{
|
||||
AccountIds = new List<int> { account.Id },
|
||||
Links = links,
|
||||
DelayMs = delayMs
|
||||
})
|
||||
};
|
||||
|
||||
var created = await TaskManagement.CreateTaskAsync(task);
|
||||
Snackbar.Add($"已提交后台任务 #{created.Id}(订阅/加群)。请到【任务中心】查看进度。", Severity.Success);
|
||||
MudDialog.Close(DialogResult.Ok(created.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"提交失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ParseLines(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new List<string>();
|
||||
|
||||
return text
|
||||
.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Close();
|
||||
}
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
OnClick="@(() => ShowDetails(context))" Title="查看详情" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Info"
|
||||
Disabled="@loading" OnClick="@(() => OpenEditUserProfile(context))" Title="编辑用户资料" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GroupAdd" Size="Size.Small" Color="Color.Warning"
|
||||
Disabled="@loading" OnClick="@(() => OpenJoinSubscribe(context))" Title="进群/订阅" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Sync" Size="Size.Small" Color="Color.Primary"
|
||||
Disabled="@loading" OnClick="@(() => RefreshTelegramStatus(context))" Title="刷新 Telegram 状态" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.MarkEmailUnread" Size="Size.Small" Color="Color.Info"
|
||||
@@ -288,6 +290,22 @@
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task OpenJoinSubscribe(Account account)
|
||||
{
|
||||
if (loading)
|
||||
return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["AccountId"] = account.Id
|
||||
};
|
||||
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.JoinSubscribeDialog>("进群/订阅", parameters, options)!;
|
||||
var result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task OpenBatchChangeTwoFactorPassword()
|
||||
{
|
||||
if (loading)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using TelegramPanel.Data.Entities
|
||||
@using TelegramPanel.Core.BatchTasks
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>任务中心 - Telegram Panel</PageTitle>
|
||||
@@ -17,6 +18,15 @@
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Color="Color.Default" Size="Size.Small"
|
||||
Disabled="@loading" OnClick="@(async (MouseEventArgs _) => await LoadTasks())" />
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddTask"
|
||||
Disabled="@loading" OnClick="OpenCreateTaskDialog" Class="ml-2">
|
||||
新建任务
|
||||
</MudButton>
|
||||
<MudSelect @bind-Value="filterCategory" Label="任务分类" Variant="Variant.Outlined" Dense="true" Class="ml-2" Style="min-width: 140px;">
|
||||
<MudSelectItem Value='@("all")'>全部</MudSelectItem>
|
||||
<MudSelectItem Value='@("user")'>用户任务</MudSelectItem>
|
||||
<MudSelectItem Value='@("bot")'>Bot 任务</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect @bind-Value="filterStatus" Label="状态筛选" Variant="Variant.Outlined" Dense="true" Style="min-width: 120px;">
|
||||
<MudSelectItem Value='@("all")'>全部</MudSelectItem>
|
||||
<MudSelectItem Value='@("pending")'>待执行</MudSelectItem>
|
||||
@@ -27,6 +37,7 @@
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>任务ID</MudTh>
|
||||
<MudTh>归属</MudTh>
|
||||
<MudTh>任务类型</MudTh>
|
||||
<MudTh>状态</MudTh>
|
||||
<MudTh>进度</MudTh>
|
||||
@@ -36,6 +47,9 @@
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="任务ID">@context.Id</MudTd>
|
||||
<MudTd DataLabel="归属">
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetCategoryColor(context.TaskType)">@GetCategoryName(context.TaskType)</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="任务类型">
|
||||
<MudChip T="string" Size="Size.Small" Icon="@GetTaskIcon(context.TaskType)">@GetTaskTypeName(context.TaskType)</MudChip>
|
||||
</MudTd>
|
||||
@@ -83,12 +97,15 @@
|
||||
private List<BatchTask> tasks = new();
|
||||
private bool loading = true;
|
||||
private string filterStatus = "all";
|
||||
private string filterCategory = "all";
|
||||
|
||||
private PeriodicTimer? refreshTimer;
|
||||
private CancellationTokenSource? refreshCts;
|
||||
|
||||
private IEnumerable<BatchTask> filteredTasks =>
|
||||
filterStatus == "all" ? tasks : tasks.Where(t => t.Status == filterStatus);
|
||||
tasks.Where(t =>
|
||||
(filterStatus == "all" || t.Status == filterStatus) &&
|
||||
(filterCategory == "all" || GetCategory(t.TaskType) == filterCategory));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -154,17 +171,28 @@
|
||||
return (double)task.Completed / task.Total * 100;
|
||||
}
|
||||
|
||||
private static string GetCategory(string taskType) =>
|
||||
taskType == BatchTaskTypes.UserJoinSubscribe ? "user" : "bot";
|
||||
|
||||
private static string GetCategoryName(string taskType) =>
|
||||
GetCategory(taskType) == "user" ? "用户" : "Bot";
|
||||
|
||||
private static Color GetCategoryColor(string taskType) =>
|
||||
GetCategory(taskType) == "user" ? Color.Info : Color.Secondary;
|
||||
|
||||
private string GetTaskIcon(string type) => type switch
|
||||
{
|
||||
"invite" => Icons.Material.Filled.PersonAdd,
|
||||
"set_admin" => Icons.Material.Filled.AdminPanelSettings,
|
||||
BatchTaskTypes.Invite => Icons.Material.Filled.PersonAdd,
|
||||
BatchTaskTypes.SetAdmin => Icons.Material.Filled.AdminPanelSettings,
|
||||
BatchTaskTypes.UserJoinSubscribe => Icons.Material.Filled.GroupAdd,
|
||||
_ => Icons.Material.Filled.Task
|
||||
};
|
||||
|
||||
private string GetTaskTypeName(string type) => type switch
|
||||
{
|
||||
"invite" => "批量邀请",
|
||||
"set_admin" => "设置管理员",
|
||||
BatchTaskTypes.Invite => "批量邀请",
|
||||
BatchTaskTypes.SetAdmin => "设置管理员",
|
||||
BatchTaskTypes.UserJoinSubscribe => "批量订阅/加群",
|
||||
_ => type
|
||||
};
|
||||
|
||||
@@ -258,4 +286,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenCreateTaskDialog()
|
||||
{
|
||||
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true };
|
||||
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.CreateBatchTaskDialog>("新建任务", new DialogParameters(), options)!;
|
||||
var result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
await LoadTasks();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TelegramPanel.Core.BatchTasks;
|
||||
using TelegramPanel.Core.Interfaces;
|
||||
using TelegramPanel.Core.Services;
|
||||
using TelegramPanel.Core.Services.Telegram;
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Web.Services;
|
||||
@@ -71,6 +73,7 @@ public sealed class BatchTaskBackgroundService : BackgroundService
|
||||
var taskManagement = scope.ServiceProvider.GetRequiredService<BatchTaskManagementService>();
|
||||
var channelManagement = scope.ServiceProvider.GetRequiredService<ChannelManagementService>();
|
||||
var channelService = scope.ServiceProvider.GetRequiredService<IChannelService>();
|
||||
var accountTelegramTools = scope.ServiceProvider.GetRequiredService<AccountTelegramToolsService>();
|
||||
|
||||
var pending = (await taskManagement.GetTasksByStatusAsync("pending"))
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
@@ -89,12 +92,15 @@ public sealed class BatchTaskBackgroundService : BackgroundService
|
||||
{
|
||||
switch (pending.TaskType)
|
||||
{
|
||||
case "invite":
|
||||
case BatchTaskTypes.Invite:
|
||||
(completed, failed) = await RunInviteAsync(pending, taskManagement, channelManagement, channelService, completed, failed, cancellationToken);
|
||||
break;
|
||||
case "set_admin":
|
||||
case BatchTaskTypes.SetAdmin:
|
||||
(completed, failed) = await RunSetAdminAsync(pending, taskManagement, channelManagement, channelService, completed, failed, cancellationToken);
|
||||
break;
|
||||
case BatchTaskTypes.UserJoinSubscribe:
|
||||
(completed, failed) = await RunUserJoinSubscribeAsync(pending, taskManagement, accountTelegramTools, completed, failed, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
failed = pending.Total == 0 ? 1 : pending.Total;
|
||||
await taskManagement.UpdateTaskProgressAsync(pending.Id, completed, failed);
|
||||
@@ -246,6 +252,44 @@ public sealed class BatchTaskBackgroundService : BackgroundService
|
||||
return (completed, failed);
|
||||
}
|
||||
|
||||
private static async Task<(int completed, int failed)> RunUserJoinSubscribeAsync(
|
||||
BatchTask task,
|
||||
BatchTaskManagementService taskManagement,
|
||||
AccountTelegramToolsService accountTelegramTools,
|
||||
int completed,
|
||||
int failed,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(task.Config))
|
||||
throw new InvalidOperationException("任务配置为空");
|
||||
|
||||
var cfg = JsonSerializer.Deserialize<UserJoinSubscribeTaskConfig>(task.Config);
|
||||
if (cfg == null || cfg.AccountIds.Count == 0 || cfg.Links.Count == 0)
|
||||
throw new InvalidOperationException("任务配置缺少 AccountIds 或 Links");
|
||||
|
||||
foreach (var accountId in cfg.AccountIds)
|
||||
{
|
||||
foreach (var link in cfg.Links)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var latest = await taskManagement.GetTaskAsync(task.Id);
|
||||
if (latest != null && latest.Status != "running")
|
||||
return (completed, failed);
|
||||
|
||||
var (ok, _, _) = await accountTelegramTools.JoinChatOrChannelAsync(accountId, link, cancellationToken);
|
||||
if (ok) completed++; else failed++;
|
||||
|
||||
await taskManagement.UpdateTaskProgressAsync(task.Id, completed, failed);
|
||||
|
||||
if (cfg.DelayMs > 0)
|
||||
await Task.Delay(cfg.DelayMs + Random.Shared.Next(300, 900), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return (completed, failed);
|
||||
}
|
||||
|
||||
private sealed record InviteTaskConfig(
|
||||
List<long> ChannelTelegramIds,
|
||||
List<string> Usernames,
|
||||
@@ -260,4 +304,10 @@ public sealed class BatchTaskBackgroundService : BackgroundService
|
||||
string Title,
|
||||
int PreferredExecuteAccountId
|
||||
);
|
||||
|
||||
private sealed record UserJoinSubscribeTaskConfig(
|
||||
List<int> AccountIds,
|
||||
List<string> Links,
|
||||
int DelayMs
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user