351 lines
14 KiB
Plaintext
351 lines
14 KiB
Plaintext
@page "/batch/invite"
|
||
@inject IChannelService ChannelService
|
||
@inject AccountManagementService AccountManagement
|
||
@inject ChannelManagementService ChannelManagement
|
||
@inject ChannelGroupManagementService ChannelGroupManagement
|
||
@inject BatchTaskManagementService TaskManagement
|
||
@inject ISnackbar Snackbar
|
||
@using TelegramPanel.Data.Entities
|
||
|
||
<PageTitle>批量邀请 - Telegram Panel</PageTitle>
|
||
|
||
<MudText Typo="Typo.h4" Class="mb-4">批量邀请用户</MudText>
|
||
|
||
<MudGrid>
|
||
<MudItem xs="12" md="6">
|
||
<MudCard>
|
||
<MudCardHeader>
|
||
<MudText Typo="Typo.h6">配置邀请</MudText>
|
||
</MudCardHeader>
|
||
<MudCardContent>
|
||
<MudSelect @bind-Value="selectedAccountId" Label="执行账号(可选)" Variant="Variant.Outlined">
|
||
<MudSelectItem Value="0">(默认:按频道创建账号执行)</MudSelectItem>
|
||
@foreach (var account in accounts)
|
||
{
|
||
<MudSelectItem Value="@account.Id">@FormatAccountLabel(account)</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
|
||
<MudSelect @bind-Value="filterGroupId" Label="频道分组筛选(可选)" Variant="Variant.Outlined" Dense="true" Class="mt-4">
|
||
<MudSelectItem Value="0">全部分组</MudSelectItem>
|
||
@foreach (var g in groups)
|
||
{
|
||
<MudSelectItem Value="@g.Id">@g.Name</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
|
||
<MudStack Row="true" Spacing="2" Class="mt-3">
|
||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.SelectAll"
|
||
Disabled="@channelsLoading" OnClick="SelectAllFilteredChannels">
|
||
全选当前筛选
|
||
</MudButton>
|
||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Checklist"
|
||
Disabled="@(channelsLoading || filterGroupId <= 0)" OnClick="SelectCurrentGroupChannels">
|
||
选择该分组全部
|
||
</MudButton>
|
||
<MudButton Variant="Variant.Text" Color="Color.Secondary" Disabled="@channelsLoading" OnClick="ClearSelectedChannels">
|
||
清空选择
|
||
</MudButton>
|
||
</MudStack>
|
||
|
||
<MudTable Items="@filteredChannels" Hover="true" Dense="true" Class="mt-3"
|
||
MultiSelection="true" @bind-SelectedItems="selectedChannels"
|
||
Loading="@channelsLoading">
|
||
<HeaderContent>
|
||
<MudTh>频道名称</MudTh>
|
||
<MudTh>分组</MudTh>
|
||
<MudTh>创建账号</MudTh>
|
||
<MudTh>用户名</MudTh>
|
||
</HeaderContent>
|
||
<RowTemplate>
|
||
<MudTd DataLabel="频道名称">@context.Title</MudTd>
|
||
<MudTd DataLabel="分组">@(context.Group?.Name ?? "未分组")</MudTd>
|
||
<MudTd DataLabel="创建账号">@(context.CreatorAccount?.Phone ?? (context.CreatorAccountId?.ToString() ?? "(非系统创建)"))</MudTd>
|
||
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : "@" + context.Username)</MudTd>
|
||
</RowTemplate>
|
||
<NoRecordsContent>
|
||
<MudText>暂无频道数据</MudText>
|
||
</NoRecordsContent>
|
||
</MudTable>
|
||
|
||
<MudTextField @bind-Value="usernames" Label="用户名列表" Variant="Variant.Outlined" Lines="10" Class="mt-4"
|
||
HelperText="每行一个用户名" />
|
||
|
||
<MudNumericField @bind-Value="delayMs" Label="邀请间隔 (毫秒)" Variant="Variant.Outlined" Min="1000" Max="10000" Class="mt-4"
|
||
HelperText="建议设置2000-5000ms,避免触发风控" />
|
||
</MudCardContent>
|
||
<MudCardActions>
|
||
<MudButton Variant="Variant.Text" Color="Color.Secondary" OnClick="ClearForm">清空</MudButton>
|
||
<MudSpacer />
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||
Disabled="@(selectedChannels.Count == 0 || string.IsNullOrEmpty(usernames) || inviting)"
|
||
OnClick="StartInvite">
|
||
@if (inviting)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||
<span>邀请中...</span>
|
||
}
|
||
else
|
||
{
|
||
<span>开始邀请</span>
|
||
}
|
||
</MudButton>
|
||
</MudCardActions>
|
||
</MudCard>
|
||
</MudItem>
|
||
|
||
<MudItem xs="12" md="6">
|
||
<MudCard>
|
||
<MudCardHeader>
|
||
<MudText Typo="Typo.h6">邀请结果</MudText>
|
||
</MudCardHeader>
|
||
<MudCardContent>
|
||
<MudList T="string" Dense="true">
|
||
@foreach (var result in inviteResults)
|
||
{
|
||
<MudListItem T="string" Icon="@(result.Success ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.Error)"
|
||
IconColor="@(result.Success ? Color.Success : Color.Error)">
|
||
<div>
|
||
<MudText Typo="Typo.body2">
|
||
<strong>@result.ChannelTitle</strong>:@("@" + result.Username)
|
||
</MudText>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">执行账号:@result.ExecutedByAccountId</MudText>
|
||
@if (!result.Success && result.Error != null)
|
||
{
|
||
<MudText Typo="Typo.caption" Color="Color.Error">@result.Error</MudText>
|
||
}
|
||
</div>
|
||
</MudListItem>
|
||
}
|
||
</MudList>
|
||
@if (inviteResults.Any())
|
||
{
|
||
<MudDivider Class="my-3" />
|
||
<MudText Typo="Typo.body2">
|
||
成功:@inviteResults.Count(r => r.Success) / 总计:@inviteResults.Count(频道:@inviteResults.Select(r => r.ChannelTelegramId).Distinct().Count())
|
||
</MudText>
|
||
}
|
||
</MudCardContent>
|
||
</MudCard>
|
||
</MudItem>
|
||
</MudGrid>
|
||
|
||
@code {
|
||
[SupplyParameterFromQuery(Name = "channelId")] public int? ChannelDbIdQuery { get; set; }
|
||
|
||
private List<Account> accounts = new();
|
||
private List<Channel> channels = new();
|
||
private List<ChannelGroup> groups = new();
|
||
|
||
private int selectedAccountId = 0;
|
||
private int filterGroupId = 0;
|
||
private bool channelsLoading = true;
|
||
private HashSet<Channel> selectedChannels = new();
|
||
|
||
private string usernames = "";
|
||
private int delayMs = 2000;
|
||
private bool inviting = false;
|
||
|
||
private List<InviteOutcome> inviteResults = new();
|
||
|
||
private IEnumerable<Channel> filteredChannels =>
|
||
channels.Where(c => filterGroupId <= 0 || c.GroupId == filterGroupId);
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
await LoadAccounts();
|
||
await LoadGroups();
|
||
await LoadChannels();
|
||
|
||
if (ChannelDbIdQuery.HasValue && ChannelDbIdQuery.Value > 0)
|
||
{
|
||
var c = channels.FirstOrDefault(x => x.Id == ChannelDbIdQuery.Value);
|
||
if (c != null)
|
||
selectedChannels = new HashSet<Channel> { c };
|
||
}
|
||
}
|
||
|
||
private async Task LoadAccounts()
|
||
{
|
||
try
|
||
{
|
||
var accountList = await AccountManagement.GetActiveAccountsAsync();
|
||
accounts = accountList.ToList();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Snackbar.Add($"加载账号列表失败:{ex.Message}", Severity.Error);
|
||
}
|
||
}
|
||
|
||
private async Task LoadGroups()
|
||
{
|
||
try
|
||
{
|
||
var list = await ChannelGroupManagement.GetAllGroupsAsync();
|
||
groups = list.ToList();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Snackbar.Add($"加载频道分组失败:{ex.Message}", Severity.Error);
|
||
}
|
||
}
|
||
|
||
private async Task LoadChannels()
|
||
{
|
||
channelsLoading = true;
|
||
try
|
||
{
|
||
var channelList = await ChannelManagement.GetAllChannelsAsync();
|
||
channels = channelList.ToList();
|
||
selectedChannels = selectedChannels.Where(c => channels.Any(x => x.Id == c.Id)).ToHashSet();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Snackbar.Add($"加载频道列表失败:{ex.Message}", Severity.Error);
|
||
}
|
||
finally
|
||
{
|
||
channelsLoading = false;
|
||
}
|
||
}
|
||
|
||
private void SelectAllFilteredChannels()
|
||
{
|
||
selectedChannels = filteredChannels.ToHashSet();
|
||
}
|
||
|
||
private void SelectCurrentGroupChannels()
|
||
{
|
||
if (filterGroupId <= 0)
|
||
return;
|
||
|
||
selectedChannels = channels.Where(c => c.GroupId == filterGroupId).ToHashSet();
|
||
}
|
||
|
||
private void ClearSelectedChannels()
|
||
{
|
||
selectedChannels = new HashSet<Channel>();
|
||
}
|
||
|
||
private async Task StartInvite()
|
||
{
|
||
inviting = true;
|
||
inviteResults.Clear();
|
||
|
||
try
|
||
{
|
||
var usernameList = usernames.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(u => u.Trim().TrimStart('@'))
|
||
.Where(u => !string.IsNullOrEmpty(u))
|
||
.ToList();
|
||
|
||
if (usernameList.Count == 0)
|
||
{
|
||
Snackbar.Add("请输入至少一个用户名", Severity.Warning);
|
||
return;
|
||
}
|
||
|
||
var targetChannels = selectedChannels.ToList();
|
||
if (targetChannels.Count == 0)
|
||
{
|
||
Snackbar.Add("请先勾选至少一个频道", Severity.Warning);
|
||
return;
|
||
}
|
||
|
||
var task = new BatchTask
|
||
{
|
||
TaskType = "invite",
|
||
Total = usernameList.Count * targetChannels.Count,
|
||
Completed = 0,
|
||
Failed = 0,
|
||
Config = System.Text.Json.JsonSerializer.Serialize(new
|
||
{
|
||
ChannelIds = targetChannels.Select(c => c.TelegramId).ToList(),
|
||
ChannelTitles = targetChannels.Select(c => c.Title).ToList(),
|
||
DelayMs = delayMs,
|
||
ExecutedByAccountId = selectedAccountId
|
||
})
|
||
};
|
||
var createdTask = await TaskManagement.CreateTaskAsync(task);
|
||
await TaskManagement.StartTaskAsync(createdTask.Id);
|
||
|
||
var totalSuccess = 0;
|
||
var totalFailed = 0;
|
||
|
||
foreach (var channel in targetChannels)
|
||
{
|
||
var executeAccountId = await ChannelManagement.ResolveExecuteAccountIdAsync(channel, selectedAccountId > 0 ? selectedAccountId : null);
|
||
if (executeAccountId is not > 0)
|
||
throw new InvalidOperationException($"频道“{channel.Title}”没有可用的执行账号:请先【同步数据】或手动选择一个执行账号");
|
||
|
||
List<InviteResult> results;
|
||
try
|
||
{
|
||
results = await ChannelService.BatchInviteUsersAsync(executeAccountId.Value, channel.TelegramId, usernameList, delayMs);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
results = usernameList.Select(u => new InviteResult(u, false, ex.Message)).ToList();
|
||
}
|
||
|
||
foreach (var r in results)
|
||
{
|
||
inviteResults.Add(new InviteOutcome(
|
||
ChannelTelegramId: channel.TelegramId,
|
||
ChannelTitle: channel.Title,
|
||
Username: r.Username,
|
||
Success: r.Success,
|
||
Error: r.Error,
|
||
ExecutedByAccountId: executeAccountId.Value
|
||
));
|
||
}
|
||
|
||
var successCount = results.Count(r => r.Success);
|
||
var failedCount = results.Count - successCount;
|
||
totalSuccess += successCount;
|
||
totalFailed += failedCount;
|
||
await TaskManagement.UpdateTaskProgressAsync(createdTask.Id, totalSuccess, totalFailed);
|
||
StateHasChanged();
|
||
}
|
||
|
||
await TaskManagement.CompleteTaskAsync(createdTask.Id, true);
|
||
Snackbar.Add($"邀请完成:成功 {totalSuccess},失败 {totalFailed}(频道:{targetChannels.Count})", totalFailed == 0 ? Severity.Success : Severity.Warning);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Snackbar.Add($"邀请失败:{ex.Message}", Severity.Error);
|
||
}
|
||
finally
|
||
{
|
||
inviting = false;
|
||
}
|
||
}
|
||
|
||
private void ClearForm()
|
||
{
|
||
usernames = "";
|
||
inviteResults.Clear();
|
||
}
|
||
|
||
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 record InviteOutcome(
|
||
long ChannelTelegramId,
|
||
string ChannelTitle,
|
||
string Username,
|
||
bool Success,
|
||
string? Error,
|
||
int ExecutedByAccountId
|
||
);
|
||
}
|