mirror of
https://github.com/moeacgx/Telegram-Panel.git
synced 2026-06-02 07:31:12 +08:00
feat: bot channels batch ops + per-channel creator execution
This commit is contained in:
@@ -5,10 +5,10 @@
|
||||
<!-- 主版本: 重大变更/不兼容更新 -->
|
||||
<!-- 次版本: 新功能/兼容性更新 -->
|
||||
<!-- 修订号: Bug修复/小改进 -->
|
||||
<Version>1.2.6</Version>
|
||||
<AssemblyVersion>1.2.6.0</AssemblyVersion>
|
||||
<FileVersion>1.2.6.0</FileVersion>
|
||||
<InformationalVersion>1.2.6</InformationalVersion>
|
||||
<Version>1.2.7</Version>
|
||||
<AssemblyVersion>1.2.7.0</AssemblyVersion>
|
||||
<FileVersion>1.2.7.0</FileVersion>
|
||||
<InformationalVersion>1.2.7</InformationalVersion>
|
||||
|
||||
<!-- 项目元信息 -->
|
||||
<Authors>Telegram Panel Contributors</Authors>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
- 🔐 **二级密码(2FA)与找回邮箱**:支持单个/批量修改二级密码;支持绑定/换绑 2FA 找回邮箱(验证码确认)
|
||||
- 🧯 **忘记二级密码可申请重置**:支持单个或批量向 Telegram 提交“忘记密码重置”申请(通常等待 7 天后可重新设置)
|
||||
- 🪪 **账号资料管理**:支持单账号编辑昵称/Bio/用户名/头像;支持批量修改昵称(自动追加手机号后 4 位便于区分)与批量修改 Bio
|
||||
- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量设置管理员;支持对每个 Bot 一键启用/停用(停用后相关后台不再轮询 getUpdates)
|
||||
- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量邀请成员/设置管理员(可按管理员列表匹配系统账号执行);支持对每个 Bot 一键启用/停用(停用后相关后台不再轮询 getUpdates)
|
||||
- 🏷️ **账号分类(排除操作)**:支持把“卖号/不参与运营”的账号分类标记为“排除操作”,不会出现在创建频道/批量任务的执行账号下拉里
|
||||
|
||||
|
||||
## 🧊 防冻结指南(新号必看)
|
||||
|
||||
@@ -60,6 +60,7 @@ public class AppDbContext : DbContext
|
||||
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
|
||||
entity.Property(e => e.Color).HasMaxLength(20);
|
||||
entity.Property(e => e.Description).HasMaxLength(500);
|
||||
entity.Property(e => e.ExcludeFromOperations).HasDefaultValue(false);
|
||||
|
||||
entity.HasIndex(e => e.Name).IsUnique();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ public class AccountCategory
|
||||
public string Name { get; set; } = null!;
|
||||
public string? Color { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 排除操作:该分类下的账号不出现在“创建频道/批量邀请/批量设置管理员”等操作的执行账号选择中
|
||||
/// </summary>
|
||||
public bool ExcludeFromOperations { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// 导航属性
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TelegramPanel.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260102000000_AddAccountCategoryExcludeFromOperations")]
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountCategoryExcludeFromOperations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ExcludeFromOperations",
|
||||
table: "AccountCategories",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExcludeFromOperations",
|
||||
table: "AccountCategories");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ namespace TelegramPanel.Data.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ExcludeFromOperations")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<MudTextField T="string" @bind-Value="name" Label="分类名称" Variant="Variant.Outlined" Required="true" />
|
||||
<MudColorPicker @bind-Value="color" Label="分类颜色" Class="mt-2" />
|
||||
<MudTextField T="string" @bind-Value="description" Label="描述" Variant="Variant.Outlined" Lines="3" Class="mt-2" />
|
||||
<MudSwitch @bind-Value="excludeFromOperations" Color="Color.Primary" Class="mt-2"
|
||||
Label="排除操作(不出现在创建/批量任务中)" />
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -28,12 +30,14 @@
|
||||
private string name = "";
|
||||
private MudColor color = "#9E9E9E";
|
||||
private string? description;
|
||||
private bool excludeFromOperations;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
name = (Category?.Name ?? "").Trim();
|
||||
description = (Category?.Description ?? "").Trim();
|
||||
color = string.IsNullOrWhiteSpace(Category?.Color) ? "#9E9E9E" : Category!.Color!;
|
||||
excludeFromOperations = Category?.ExcludeFromOperations ?? false;
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
@@ -46,9 +50,9 @@
|
||||
var model = new AccountCategoryEditModel(
|
||||
Name: name.Trim(),
|
||||
Color: string.IsNullOrWhiteSpace(color.Value) ? null : color.Value,
|
||||
Description: string.IsNullOrWhiteSpace(description) ? null : description!.Trim());
|
||||
Description: string.IsNullOrWhiteSpace(description) ? null : description!.Trim(),
|
||||
ExcludeFromOperations: excludeFromOperations);
|
||||
|
||||
MudDialog.Close(DialogResult.Ok(model));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
namespace TelegramPanel.Web.Components.Dialogs;
|
||||
|
||||
public sealed record AccountCategoryEditModel(string Name, string? Color, string? Description);
|
||||
|
||||
public sealed record AccountCategoryEditModel(string Name, string? Color, string? Description, bool ExcludeFromOperations);
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
@inject AccountManagementService AccountManagement
|
||||
@inject IChannelService ChannelService
|
||||
@inject BotTelegramService BotTelegram
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject TelegramPanel.Web.Services.ChannelAdminDefaultsService DefaultsService
|
||||
@inject TelegramPanel.Web.Services.ChannelAdminPresetsService PresetsService
|
||||
@using TelegramPanel.Data.Entities
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
<span>将使用“执行账号”为所选 Bot 频道批量设置管理员。执行账号必须是该频道管理员(通过 Bot API 获取管理员列表并与系统账号匹配)。</span>
|
||||
<MudText Typo="Typo.body2" Class="mt-1">
|
||||
支持:<b>@@username</b>、<b>username</b>
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
|
||||
<MudText Typo="Typo.subtitle2">目标频道:@channels.Count 个</MudText>
|
||||
|
||||
@if (loadingAdmins)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">正在读取频道管理员列表(Bot API)...</MudText>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(adminSummary))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@adminSummary</MudText>
|
||||
}
|
||||
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" sm="8">
|
||||
<MudSelect T="string" Value="@selectedPresetName" ValueChanged="OnPresetChanged"
|
||||
Label="管理员预设(用户名列表)" Variant="Variant.Outlined" Dense="true" Disabled="@(running || loadingPresets)">
|
||||
<MudSelectItem Value="@string.Empty">(不使用预设)</MudSelectItem>
|
||||
@foreach (var p in Presets)
|
||||
{
|
||||
<MudSelectItem Value="@p.Name">@p.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteSelectedPreset"
|
||||
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(selectedPresetName))">
|
||||
删除预设
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudSelect @bind-Value="selectedAccountId" Label="执行账号" Variant="Variant.Outlined" Dense="true"
|
||||
Disabled="@(running || loadingAdmins || eligibleAccounts.Count == 0)">
|
||||
<MudSelectItem Value="0">自动选择(按频道)</MudSelectItem>
|
||||
@foreach (var a in eligibleAccounts)
|
||||
{
|
||||
<MudSelectItem Value="@a.Id">@FormatAccountLabel(a)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
@if (eligibleAccounts.Count == 0 && !loadingAdmins && channels.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
当前所选频道中未发现“既是频道管理员又在系统中的账号”。你仍可继续执行,但这些频道会被跳过。
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="usernamesText" Label="用户名列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
|
||||
HelperText="@UsernamesHelperText" />
|
||||
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" sm="8">
|
||||
<MudTextField @bind-Value="presetNameToSave" Label="保存当前用户名为预设组(名称)"
|
||||
Variant="Variant.Outlined" Disabled="@(running || loadingPresets)" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SavePreset"
|
||||
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(presetNameToSave))">
|
||||
保存为预设
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudTextField @bind-Value="adminTitle" Label="管理员头衔" Variant="Variant.Outlined"
|
||||
HelperText="例如:Admin/Editor。留空将使用默认值。" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2">权限(可多选)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
说明:这里选择的是“你希望目标账号拥有的权限”。如果设置后发现某些权限未生效,面板会提示缺失的权限名称。
|
||||
</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="1" Style="flex-wrap: wrap;">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => ApplyRights(AdminRights.BasicAdmin))" Disabled="@running">常用权限</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => ApplyRights(FullRights))" Disabled="@running">全选权限</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="@(() => ApplyRights(AdminRights.None))" Disabled="@running">清空</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveAsDefaultRightsAsync" Disabled="@running">保存为默认权限</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Outlined="true" Class="pa-3">
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.ChangeInfo" Label="修改信息" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.PostMessages" Label="发消息" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.EditMessages" Label="编辑消息" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.DeleteMessages" Label="删除消息" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.BanUsers" Label="封禁用户" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.InviteUsers" Label="邀请用户" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.PinMessages" Label="置顶消息" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.ManageCall" Label="语音/直播" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.AddAdmins" Label="添加管理员" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.Anonymous" Label="匿名" Disabled="@running" /></MudItem>
|
||||
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.ManageTopics" Label="管理话题" Disabled="@running" /></MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="30000"
|
||||
HelperText="建议设置 1000-2500ms,避免触发风控(会额外加少量随机抖动)。" />
|
||||
|
||||
@if (running)
|
||||
{
|
||||
<MudDivider />
|
||||
<MudText Typo="Typo.body2">进度:@done / @total(失败:@failed)</MudText>
|
||||
<MudProgressLinear Value="@Progress" Color="Color.Primary" />
|
||||
@if (!string.IsNullOrWhiteSpace(currentHint))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary">@currentHint</MudText>
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelOrClose">
|
||||
@(running ? "停止" : "关闭")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(running || channels.Count == 0)">
|
||||
开始设置
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
|
||||
|
||||
[Parameter] public int BotId { get; set; }
|
||||
[Parameter] public List<BotChannel> Channels { get; set; } = new();
|
||||
|
||||
private readonly Random _rand = new();
|
||||
private readonly List<Account> eligibleAccounts = new();
|
||||
private readonly Dictionary<long, Account> accountsByUserId = new();
|
||||
private readonly Dictionary<long, HashSet<long>> channelAdminUserIds = new();
|
||||
|
||||
private List<BotChannel> channels = new();
|
||||
private int selectedAccountId;
|
||||
private string usernamesText = "";
|
||||
private string adminTitle = "Admin";
|
||||
private int delayMs = 1500;
|
||||
private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。";
|
||||
|
||||
private bool loadingAdmins;
|
||||
private string? adminSummary;
|
||||
|
||||
private bool loadingPresets;
|
||||
private List<TelegramPanel.Web.Services.ChannelAdminPreset> Presets { get; set; } = new();
|
||||
private string selectedPresetName = "";
|
||||
private string presetNameToSave = "";
|
||||
|
||||
private RightsModel rights = new();
|
||||
private static readonly AdminRights FullRights =
|
||||
AdminRights.ChangeInfo
|
||||
| AdminRights.PostMessages
|
||||
| AdminRights.EditMessages
|
||||
| AdminRights.DeleteMessages
|
||||
| AdminRights.BanUsers
|
||||
| AdminRights.InviteUsers
|
||||
| AdminRights.PinMessages
|
||||
| AdminRights.ManageCall
|
||||
| AdminRights.AddAdmins
|
||||
| AdminRights.Anonymous
|
||||
| AdminRights.ManageTopics;
|
||||
|
||||
private bool running;
|
||||
private int total;
|
||||
private int done;
|
||||
private int failed;
|
||||
private string? currentHint;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
private int Progress => total <= 0 ? 0 : (int)Math.Round(done * 100d / total);
|
||||
|
||||
private AdminRights SelectedRights => rights.ToRights();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
channels = (Channels ?? new List<BotChannel>())
|
||||
.Where(x => x != null)
|
||||
.GroupBy(x => x.TelegramId)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
|
||||
await LoadAccountsAsync();
|
||||
|
||||
// 默认权限:优先读取上次保存的默认值;否则使用“常用权限”
|
||||
var defaults = await DefaultsService.GetAsync();
|
||||
ApplyRights(defaults?.Rights ?? AdminRights.BasicAdmin);
|
||||
|
||||
await ReloadPresetsAsync();
|
||||
|
||||
if (BotId > 0 && channels.Count > 0)
|
||||
await LoadAdminsAndEligibleAccountsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAccountsAsync()
|
||||
{
|
||||
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
|
||||
accountsByUserId.Clear();
|
||||
foreach (var a in all.Where(x => x.IsActive && x.Category?.ExcludeFromOperations != true))
|
||||
{
|
||||
if (a.UserId <= 0)
|
||||
continue;
|
||||
if (!accountsByUserId.ContainsKey(a.UserId))
|
||||
accountsByUserId[a.UserId] = a;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAdminsAndEligibleAccountsAsync()
|
||||
{
|
||||
loadingAdmins = true;
|
||||
adminSummary = null;
|
||||
eligibleAccounts.Clear();
|
||||
channelAdminUserIds.Clear();
|
||||
try
|
||||
{
|
||||
var eligibleUserIds = new HashSet<long>();
|
||||
var matchedChannels = 0;
|
||||
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
var adminIds = new HashSet<long>();
|
||||
try
|
||||
{
|
||||
var admins = await BotTelegram.GetChatAdminsAsync(BotId, ch.TelegramId, CancellationToken.None);
|
||||
foreach (var a in admins)
|
||||
{
|
||||
adminIds.Add(a.UserId);
|
||||
if (accountsByUserId.ContainsKey(a.UserId))
|
||||
eligibleUserIds.Add(a.UserId);
|
||||
}
|
||||
|
||||
if (admins.Any(a => accountsByUserId.ContainsKey(a.UserId)))
|
||||
matchedChannels++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
channelAdminUserIds[ch.TelegramId] = adminIds;
|
||||
}
|
||||
|
||||
foreach (var uid in eligibleUserIds)
|
||||
{
|
||||
if (accountsByUserId.TryGetValue(uid, out var acc))
|
||||
eligibleAccounts.Add(acc);
|
||||
}
|
||||
|
||||
eligibleAccounts.Sort((a, b) => string.Compare(FormatAccountLabel(a), FormatAccountLabel(b), StringComparison.OrdinalIgnoreCase));
|
||||
adminSummary = $"可用执行账号:{eligibleAccounts.Count} 个;可执行频道:{matchedChannels}/{channels.Count}。";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingAdmins = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadPresetsAsync()
|
||||
{
|
||||
loadingPresets = true;
|
||||
try
|
||||
{
|
||||
Presets = (await PresetsService.GetPresetsAsync()).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingPresets = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnPresetChanged(string? name)
|
||||
{
|
||||
selectedPresetName = name ?? "";
|
||||
if (string.IsNullOrWhiteSpace(selectedPresetName))
|
||||
return Task.CompletedTask;
|
||||
|
||||
var p = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase));
|
||||
if (p != null)
|
||||
usernamesText = string.Join(Environment.NewLine, p.Usernames.Select(x => $"@{x}"));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SavePreset()
|
||||
{
|
||||
var name = (presetNameToSave ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
Snackbar.Add("请输入预设名称", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var usernames = ParseUsernames(usernamesText);
|
||||
if (usernames.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少输入一个用户名", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await PresetsService.SavePresetAsync(name, usernames);
|
||||
Snackbar.Add("已保存预设", Severity.Success);
|
||||
presetNameToSave = "";
|
||||
await ReloadPresetsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"保存预设失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSelectedPreset()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedPresetName))
|
||||
return;
|
||||
|
||||
bool? ok = await DialogService.ShowMessageBox(
|
||||
title: "删除预设",
|
||||
message: $"确定删除预设“{selectedPresetName}”吗?",
|
||||
yesText: "删除",
|
||||
cancelText: "取消");
|
||||
|
||||
if (ok != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await PresetsService.DeletePresetAsync(selectedPresetName);
|
||||
Snackbar.Add("已删除预设", Severity.Success);
|
||||
selectedPresetName = "";
|
||||
await ReloadPresetsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyRights(AdminRights selected)
|
||||
{
|
||||
rights = RightsModel.FromRights(selected);
|
||||
}
|
||||
|
||||
private async Task SaveAsDefaultRightsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await DefaultsService.SaveAsync(new TelegramPanel.Web.Services.ChannelAdminDefaults(SelectedRights));
|
||||
Snackbar.Add("已保存默认权限", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"保存失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (running)
|
||||
return;
|
||||
|
||||
if (BotId <= 0)
|
||||
{
|
||||
Snackbar.Add("BotId 无效", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var usernames = ParseUsernames(usernamesText);
|
||||
if (usernames.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少输入一个用户名", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
Snackbar.Add("未选择任何频道", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(adminTitle) ? "Admin" : adminTitle.Trim();
|
||||
|
||||
bool? confirm = await DialogService.ShowMessageBox(
|
||||
"确认执行",
|
||||
$"设置管理员:{channels.Count} 个频道 × {usernames.Count} 个用户(共 {channels.Count * usernames.Count} 次操作)。是否继续?",
|
||||
yesText: "继续", cancelText: "取消");
|
||||
|
||||
if (confirm != true)
|
||||
return;
|
||||
|
||||
running = true;
|
||||
total = channels.Count * usernames.Count;
|
||||
done = 0;
|
||||
failed = 0;
|
||||
currentHint = null;
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
var executorId = ResolveExecutorAccountId(ch);
|
||||
if (executorId == null)
|
||||
{
|
||||
foreach (var _ in usernames)
|
||||
{
|
||||
done++;
|
||||
failed++;
|
||||
}
|
||||
|
||||
failures.Add($"{ch.Title}:无可用执行账号(需要该频道管理员且存在于系统账号)");
|
||||
StateHasChanged();
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var username in usernames)
|
||||
{
|
||||
_cts.Token.ThrowIfCancellationRequested();
|
||||
|
||||
currentHint = $"{ch.Title} => {username}";
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var ok = await ChannelService.SetAdminAsync(executorId.Value, ch.TelegramId, username, SelectedRights, title);
|
||||
if (!ok)
|
||||
{
|
||||
failed++;
|
||||
failures.Add($"{ch.Title} => {username}:失败");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
failures.Add($"{ch.Title} => {username}:{ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
done++;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
var wait = delayMs;
|
||||
if (wait < 0) wait = 0;
|
||||
if (wait > 30000) wait = 30000;
|
||||
var jitter = _rand.Next(500, 1000);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
var summary = $"完成:{done}/{total}(失败:{failed})";
|
||||
Snackbar.Add(summary, failed == 0 ? Severity.Success : Severity.Warning);
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var details = string.Join(Environment.NewLine, failures.Take(80));
|
||||
await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭");
|
||||
}
|
||||
|
||||
MudDialog.Close(DialogResult.Ok(new { Done = done, Failed = failed }));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Snackbar.Add("已停止执行", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"执行异常:{ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
running = false;
|
||||
currentHint = null;
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private int? ResolveExecutorAccountId(BotChannel channel)
|
||||
{
|
||||
if (!channelAdminUserIds.TryGetValue(channel.TelegramId, out var adminIds) || adminIds.Count == 0)
|
||||
return null;
|
||||
|
||||
if (selectedAccountId > 0)
|
||||
{
|
||||
var selected = eligibleAccounts.FirstOrDefault(x => x.Id == selectedAccountId);
|
||||
if (selected == null || selected.UserId <= 0)
|
||||
return null;
|
||||
|
||||
return adminIds.Contains(selected.UserId) ? selected.Id : null;
|
||||
}
|
||||
|
||||
foreach (var uid in adminIds)
|
||||
{
|
||||
if (accountsByUserId.TryGetValue(uid, out var acc))
|
||||
return acc.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CancelOrClose()
|
||||
{
|
||||
if (running)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
MudDialog.Close();
|
||||
}
|
||||
|
||||
private static List<string> ParseUsernames(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))
|
||||
.Select(x => x.StartsWith("@", StringComparison.Ordinal) ? x.Substring(1) : x)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
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 sealed class RightsModel
|
||||
{
|
||||
public bool ChangeInfo { get; set; }
|
||||
public bool PostMessages { get; set; }
|
||||
public bool EditMessages { get; set; }
|
||||
public bool DeleteMessages { get; set; }
|
||||
public bool BanUsers { get; set; }
|
||||
public bool InviteUsers { get; set; }
|
||||
public bool PinMessages { get; set; }
|
||||
public bool ManageCall { get; set; }
|
||||
public bool AddAdmins { get; set; }
|
||||
public bool Anonymous { get; set; }
|
||||
public bool ManageTopics { get; set; }
|
||||
|
||||
public AdminRights ToRights()
|
||||
{
|
||||
var r = AdminRights.None;
|
||||
if (ChangeInfo) r |= AdminRights.ChangeInfo;
|
||||
if (PostMessages) r |= AdminRights.PostMessages;
|
||||
if (EditMessages) r |= AdminRights.EditMessages;
|
||||
if (DeleteMessages) r |= AdminRights.DeleteMessages;
|
||||
if (BanUsers) r |= AdminRights.BanUsers;
|
||||
if (InviteUsers) r |= AdminRights.InviteUsers;
|
||||
if (PinMessages) r |= AdminRights.PinMessages;
|
||||
if (ManageCall) r |= AdminRights.ManageCall;
|
||||
if (AddAdmins) r |= AdminRights.AddAdmins;
|
||||
if (Anonymous) r |= AdminRights.Anonymous;
|
||||
if (ManageTopics) r |= AdminRights.ManageTopics;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static RightsModel FromRights(AdminRights rights)
|
||||
{
|
||||
return new RightsModel
|
||||
{
|
||||
ChangeInfo = rights.HasFlag(AdminRights.ChangeInfo),
|
||||
PostMessages = rights.HasFlag(AdminRights.PostMessages),
|
||||
EditMessages = rights.HasFlag(AdminRights.EditMessages),
|
||||
DeleteMessages = rights.HasFlag(AdminRights.DeleteMessages),
|
||||
BanUsers = rights.HasFlag(AdminRights.BanUsers),
|
||||
InviteUsers = rights.HasFlag(AdminRights.InviteUsers),
|
||||
PinMessages = rights.HasFlag(AdminRights.PinMessages),
|
||||
ManageCall = rights.HasFlag(AdminRights.ManageCall),
|
||||
AddAdmins = rights.HasFlag(AdminRights.AddAdmins),
|
||||
Anonymous = rights.HasFlag(AdminRights.Anonymous),
|
||||
ManageTopics = rights.HasFlag(AdminRights.ManageTopics)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
@inject AccountManagementService AccountManagement
|
||||
@inject IChannelService ChannelService
|
||||
@inject BotTelegramService BotTelegram
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject TelegramPanel.Web.Services.ChannelInvitePresetsService PresetsService
|
||||
@using TelegramPanel.Data.Entities
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
<span>将使用“执行账号”邀请用户加入所选 Bot 频道。执行账号必须是该频道管理员(通过 Bot API 获取管理员列表并与系统账号匹配)。</span>
|
||||
<MudText Typo="Typo.body2" Class="mt-1">
|
||||
支持:<b>@@username</b>、<b>username</b>
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
|
||||
<MudText Typo="Typo.subtitle2">目标频道:@channels.Count 个</MudText>
|
||||
|
||||
@if (loadingAdmins)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">正在读取频道管理员列表(Bot API)...</MudText>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(adminSummary))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@adminSummary</MudText>
|
||||
}
|
||||
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" sm="8">
|
||||
<MudSelect T="string" Value="@selectedPresetName" ValueChanged="OnPresetChanged"
|
||||
Label="邀请预设" Variant="Variant.Outlined" Dense="true" Disabled="@(running || loadingPresets)">
|
||||
<MudSelectItem Value="@string.Empty">(不使用预设)</MudSelectItem>
|
||||
@foreach (var p in Presets)
|
||||
{
|
||||
<MudSelectItem Value="@p.Name">@p.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteSelectedPreset"
|
||||
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(selectedPresetName))">
|
||||
删除预设
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudSelect @bind-Value="selectedAccountId" Label="执行账号" Variant="Variant.Outlined" Dense="true"
|
||||
Disabled="@(running || loadingAdmins || eligibleAccounts.Count == 0)">
|
||||
<MudSelectItem Value="0">自动选择(按频道)</MudSelectItem>
|
||||
@foreach (var a in eligibleAccounts)
|
||||
{
|
||||
<MudSelectItem Value="@a.Id">@FormatAccountLabel(a)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
@if (eligibleAccounts.Count == 0 && !loadingAdmins && channels.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
当前所选频道中未发现“既是频道管理员又在系统中的账号”。你仍可继续执行,但这些频道会被跳过。
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="usernamesText" Label="用户名列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
|
||||
HelperText="@UsernamesHelperText" />
|
||||
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="12" sm="8">
|
||||
<MudTextField @bind-Value="presetNameToSave" Label="保存当前用户名为预设组(名称)"
|
||||
Variant="Variant.Outlined" Disabled="@(running || loadingPresets)" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SavePreset"
|
||||
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(presetNameToSave))">
|
||||
保存为预设
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudNumericField @bind-Value="delayMs" Label="邀请间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="30000"
|
||||
HelperText="建议设置 1500-4000ms,避免触发风控(会额外加少量随机抖动)。" />
|
||||
|
||||
@if (running)
|
||||
{
|
||||
<MudDivider />
|
||||
<MudText Typo="Typo.body2">进度:@done / @total(失败:@failed)</MudText>
|
||||
<MudProgressLinear Value="@Progress" Color="Color.Primary" />
|
||||
@if (!string.IsNullOrWhiteSpace(currentHint))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary">@currentHint</MudText>
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelOrClose">
|
||||
@(running ? "停止" : "关闭")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(running || channels.Count == 0)">
|
||||
开始邀请
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
|
||||
|
||||
[Parameter] public int BotId { get; set; }
|
||||
[Parameter] public List<BotChannel> Channels { get; set; } = new();
|
||||
|
||||
private readonly Random _rand = new();
|
||||
private readonly List<Account> eligibleAccounts = new();
|
||||
private readonly Dictionary<long, Account> accountsByUserId = new();
|
||||
private readonly Dictionary<long, HashSet<long>> channelAdminUserIds = new();
|
||||
|
||||
private List<BotChannel> channels = new();
|
||||
private int selectedAccountId;
|
||||
private string usernamesText = "";
|
||||
private int delayMs = 2000;
|
||||
private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。";
|
||||
|
||||
private bool loadingAdmins;
|
||||
private string? adminSummary;
|
||||
|
||||
private bool loadingPresets;
|
||||
private List<TelegramPanel.Web.Services.ChannelInvitePreset> Presets { get; set; } = new();
|
||||
private string selectedPresetName = "";
|
||||
private string presetNameToSave = "";
|
||||
|
||||
private bool running;
|
||||
private int total;
|
||||
private int done;
|
||||
private int failed;
|
||||
private string? currentHint;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
private int Progress => total <= 0 ? 0 : (int)Math.Round(done * 100d / total);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
channels = (Channels ?? new List<BotChannel>())
|
||||
.Where(x => x != null)
|
||||
.GroupBy(x => x.TelegramId)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
|
||||
await LoadAccountsAsync();
|
||||
await ReloadPresetsAsync();
|
||||
|
||||
if (BotId > 0 && channels.Count > 0)
|
||||
await LoadAdminsAndEligibleAccountsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAccountsAsync()
|
||||
{
|
||||
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
|
||||
accountsByUserId.Clear();
|
||||
foreach (var a in all)
|
||||
{
|
||||
if (!a.IsActive)
|
||||
continue;
|
||||
if (a.Category?.ExcludeFromOperations == true)
|
||||
continue;
|
||||
|
||||
if (a.UserId <= 0)
|
||||
continue;
|
||||
if (!accountsByUserId.ContainsKey(a.UserId))
|
||||
accountsByUserId[a.UserId] = a;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAdminsAndEligibleAccountsAsync()
|
||||
{
|
||||
loadingAdmins = true;
|
||||
adminSummary = null;
|
||||
eligibleAccounts.Clear();
|
||||
channelAdminUserIds.Clear();
|
||||
try
|
||||
{
|
||||
var eligibleUserIds = new HashSet<long>();
|
||||
var matchedChannels = 0;
|
||||
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
var adminIds = new HashSet<long>();
|
||||
try
|
||||
{
|
||||
var admins = await BotTelegram.GetChatAdminsAsync(BotId, ch.TelegramId, CancellationToken.None);
|
||||
foreach (var a in admins)
|
||||
{
|
||||
adminIds.Add(a.UserId);
|
||||
if (accountsByUserId.ContainsKey(a.UserId))
|
||||
eligibleUserIds.Add(a.UserId);
|
||||
}
|
||||
|
||||
if (admins.Any(a => accountsByUserId.ContainsKey(a.UserId)))
|
||||
matchedChannels++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore: 无权限/风控/频道已失效等,后续按“无可用执行账号”处理
|
||||
}
|
||||
|
||||
channelAdminUserIds[ch.TelegramId] = adminIds;
|
||||
}
|
||||
|
||||
foreach (var uid in eligibleUserIds)
|
||||
{
|
||||
if (accountsByUserId.TryGetValue(uid, out var acc))
|
||||
eligibleAccounts.Add(acc);
|
||||
}
|
||||
|
||||
eligibleAccounts.Sort((a, b) => string.Compare(FormatAccountLabel(a), FormatAccountLabel(b), StringComparison.OrdinalIgnoreCase));
|
||||
adminSummary = $"可用执行账号:{eligibleAccounts.Count} 个;可执行频道:{matchedChannels}/{channels.Count}。";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingAdmins = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadPresetsAsync()
|
||||
{
|
||||
loadingPresets = true;
|
||||
try
|
||||
{
|
||||
Presets = (await PresetsService.GetPresetsAsync()).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingPresets = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnPresetChanged(string value)
|
||||
{
|
||||
selectedPresetName = value ?? "";
|
||||
if (string.IsNullOrWhiteSpace(selectedPresetName))
|
||||
return Task.CompletedTask;
|
||||
|
||||
var preset = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase));
|
||||
if (preset != null)
|
||||
usernamesText = string.Join(Environment.NewLine, preset.Usernames);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SavePreset()
|
||||
{
|
||||
var name = (presetNameToSave ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
Snackbar.Add("请输入预设名称", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var usernames = ParseUsernames(usernamesText);
|
||||
if (usernames.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少输入一个用户名", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await PresetsService.SavePresetAsync(name, usernames);
|
||||
Snackbar.Add("已保存预设", Severity.Success);
|
||||
presetNameToSave = "";
|
||||
await ReloadPresetsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"保存预设失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSelectedPreset()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedPresetName))
|
||||
return;
|
||||
|
||||
bool? ok = await DialogService.ShowMessageBox(
|
||||
title: "删除预设",
|
||||
message: $"确定删除预设“{selectedPresetName}”吗?",
|
||||
yesText: "删除",
|
||||
cancelText: "取消");
|
||||
|
||||
if (ok != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await PresetsService.DeletePresetAsync(selectedPresetName);
|
||||
Snackbar.Add("已删除预设", Severity.Success);
|
||||
selectedPresetName = "";
|
||||
await ReloadPresetsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (running)
|
||||
return;
|
||||
|
||||
if (BotId <= 0)
|
||||
{
|
||||
Snackbar.Add("BotId 无效", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var usernames = ParseUsernames(usernamesText);
|
||||
if (usernames.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请至少输入一个用户名", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
Snackbar.Add("未选择任何频道", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
bool? confirm = await DialogService.ShowMessageBox(
|
||||
"确认执行",
|
||||
$"邀请:{channels.Count} 个频道 × {usernames.Count} 个用户(共 {channels.Count * usernames.Count} 次操作)。是否继续?",
|
||||
yesText: "继续", cancelText: "取消");
|
||||
|
||||
if (confirm != true)
|
||||
return;
|
||||
|
||||
running = true;
|
||||
total = channels.Count * usernames.Count;
|
||||
done = 0;
|
||||
failed = 0;
|
||||
currentHint = null;
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
var executorId = ResolveExecutorAccountId(ch);
|
||||
if (executorId == null)
|
||||
{
|
||||
foreach (var _ in usernames)
|
||||
{
|
||||
done++;
|
||||
failed++;
|
||||
}
|
||||
|
||||
failures.Add($"{ch.Title}:无可用执行账号(需要该频道管理员且存在于系统账号)");
|
||||
StateHasChanged();
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var username in usernames)
|
||||
{
|
||||
_cts.Token.ThrowIfCancellationRequested();
|
||||
|
||||
currentHint = $"{ch.Title} => {username}";
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ChannelService.InviteUserAsync(executorId.Value, ch.TelegramId, username);
|
||||
if (!result.Success)
|
||||
{
|
||||
failed++;
|
||||
failures.Add($"{ch.Title} => {username}:{result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
failures.Add($"{ch.Title} => {username}:{ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
done++;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
var wait = delayMs;
|
||||
if (wait < 0) wait = 0;
|
||||
if (wait > 30000) wait = 30000;
|
||||
var jitter = _rand.Next(500, 1500);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
var summary = $"完成:{done}/{total}(失败:{failed})";
|
||||
Snackbar.Add(summary, failed == 0 ? Severity.Success : Severity.Warning);
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var details = string.Join(Environment.NewLine, failures.Take(80));
|
||||
await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭");
|
||||
}
|
||||
|
||||
MudDialog.Close(DialogResult.Ok(new { Done = done, Failed = failed }));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Snackbar.Add("已停止执行", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"执行异常:{ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
running = false;
|
||||
currentHint = null;
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private int? ResolveExecutorAccountId(BotChannel channel)
|
||||
{
|
||||
if (!channelAdminUserIds.TryGetValue(channel.TelegramId, out var adminIds) || adminIds.Count == 0)
|
||||
return null;
|
||||
|
||||
if (selectedAccountId > 0)
|
||||
{
|
||||
var selected = eligibleAccounts.FirstOrDefault(x => x.Id == selectedAccountId);
|
||||
if (selected == null || selected.UserId <= 0)
|
||||
return null;
|
||||
|
||||
return adminIds.Contains(selected.UserId) ? selected.Id : null;
|
||||
}
|
||||
|
||||
foreach (var uid in adminIds)
|
||||
{
|
||||
if (accountsByUserId.TryGetValue(uid, out var acc))
|
||||
return acc.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CancelOrClose()
|
||||
{
|
||||
if (running)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
MudDialog.Close();
|
||||
}
|
||||
|
||||
private static List<string> ParseUsernames(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))
|
||||
.Select(x => x.StartsWith("@", StringComparison.Ordinal) ? x.Substring(1) : x)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
<span>将使用“执行账号”为所选频道批量设置管理员。支持输入多个用户名,换行分隔。</span>
|
||||
<span>将使用“执行账号”为所选频道批量设置管理员(默认:每个频道的创建账号)。支持输入多个用户名,换行分隔。</span>
|
||||
<MudText Typo="Typo.body2" Class="mt-1">
|
||||
支持:<b>@@username</b>、<b>username</b>
|
||||
</MudText>
|
||||
@@ -39,6 +39,7 @@
|
||||
</MudGrid>
|
||||
|
||||
<MudSelect @bind-Value="selectedAccountId" Label="执行账号" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="0">每个频道创建账号(默认)</MudSelectItem>
|
||||
@foreach (var a in accounts)
|
||||
{
|
||||
<MudSelectItem Value="@a.Id">@FormatAccountLabel(a)</MudSelectItem>
|
||||
@@ -173,12 +174,8 @@
|
||||
.ToList();
|
||||
|
||||
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
|
||||
accounts.AddRange(all.Where(a => a.IsActive));
|
||||
|
||||
if (DefaultAccountId > 0 && accounts.Any(a => a.Id == DefaultAccountId))
|
||||
selectedAccountId = DefaultAccountId;
|
||||
else
|
||||
selectedAccountId = accounts.FirstOrDefault()?.Id ?? 0;
|
||||
accounts.AddRange(all.Where(a => a.IsActive && a.Category?.ExcludeFromOperations != true));
|
||||
selectedAccountId = 0;
|
||||
|
||||
// 默认权限:优先读取上次保存的默认值;否则使用“常用权限”
|
||||
var defaults = await DefaultsService.GetAsync();
|
||||
@@ -187,6 +184,21 @@
|
||||
await ReloadPresetsAsync();
|
||||
}
|
||||
|
||||
private int? ResolveExecuteAccountId(Channel channel)
|
||||
{
|
||||
if (channel == null)
|
||||
return null;
|
||||
|
||||
if (selectedAccountId > 0)
|
||||
return selectedAccountId;
|
||||
|
||||
var creatorId = channel.CreatorAccountId;
|
||||
if (creatorId == null || creatorId.Value <= 0)
|
||||
return null;
|
||||
|
||||
return accounts.Any(a => a.Id == creatorId.Value) ? creatorId.Value : null;
|
||||
}
|
||||
|
||||
private async Task ReloadPresetsAsync()
|
||||
{
|
||||
loadingPresets = true;
|
||||
@@ -291,12 +303,6 @@
|
||||
if (running)
|
||||
return;
|
||||
|
||||
if (selectedAccountId <= 0)
|
||||
{
|
||||
Snackbar.Add("请选择一个执行账号", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var usernames = ParseUsernames(usernamesText);
|
||||
if (usernames.Count == 0)
|
||||
{
|
||||
@@ -335,6 +341,16 @@
|
||||
{
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
var executeAccountId = ResolveExecuteAccountId(ch);
|
||||
if (executeAccountId == null)
|
||||
{
|
||||
done += usernames.Count;
|
||||
failed += usernames.Count;
|
||||
failures.Add($"{ch.Title}:无可用执行账号(将使用创建账号执行;但该频道无创建账号/创建账号不可用)");
|
||||
StateHasChanged();
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var username in usernames)
|
||||
{
|
||||
_cts.Token.ThrowIfCancellationRequested();
|
||||
@@ -344,7 +360,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var ok = await ChannelService.SetAdminAsync(selectedAccountId, ch.TelegramId, username, SelectedRights, title);
|
||||
var ok = await ChannelService.SetAdminAsync(executeAccountId.Value, ch.TelegramId, username, SelectedRights, title);
|
||||
if (!ok)
|
||||
{
|
||||
failed++;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
<span>将使用“执行账号”邀请用户加入所选频道。支持输入多个用户名,换行分隔。</span>
|
||||
<span>将使用“执行账号”邀请用户加入所选频道(默认:每个频道的创建账号)。支持输入多个用户名,换行分隔。</span>
|
||||
<MudText Typo="Typo.body2" Class="mt-1">
|
||||
支持:<b>@@username</b>、<b>username</b>
|
||||
</MudText>
|
||||
@@ -38,6 +38,7 @@
|
||||
</MudGrid>
|
||||
|
||||
<MudSelect @bind-Value="selectedAccountId" Label="执行账号" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="0">每个频道创建账号(默认)</MudSelectItem>
|
||||
@foreach (var a in accounts)
|
||||
{
|
||||
<MudSelectItem Value="@a.Id">@FormatAccountLabel(a)</MudSelectItem>
|
||||
@@ -123,16 +124,27 @@
|
||||
.ToList();
|
||||
|
||||
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
|
||||
accounts.AddRange(all.Where(a => a.IsActive));
|
||||
|
||||
if (DefaultAccountId > 0 && accounts.Any(a => a.Id == DefaultAccountId))
|
||||
selectedAccountId = DefaultAccountId;
|
||||
else
|
||||
selectedAccountId = accounts.FirstOrDefault()?.Id ?? 0;
|
||||
accounts.AddRange(all.Where(a => a.IsActive && a.Category?.ExcludeFromOperations != true));
|
||||
selectedAccountId = 0;
|
||||
|
||||
await ReloadPresetsAsync();
|
||||
}
|
||||
|
||||
private int? ResolveExecuteAccountId(Channel channel)
|
||||
{
|
||||
if (channel == null)
|
||||
return null;
|
||||
|
||||
if (selectedAccountId > 0)
|
||||
return selectedAccountId;
|
||||
|
||||
var creatorId = channel.CreatorAccountId;
|
||||
if (creatorId == null || creatorId.Value <= 0)
|
||||
return null;
|
||||
|
||||
return accounts.Any(a => a.Id == creatorId.Value) ? creatorId.Value : null;
|
||||
}
|
||||
|
||||
private async Task ReloadPresetsAsync()
|
||||
{
|
||||
loadingPresets = true;
|
||||
@@ -218,12 +230,6 @@
|
||||
if (running)
|
||||
return;
|
||||
|
||||
if (selectedAccountId <= 0)
|
||||
{
|
||||
Snackbar.Add("请选择一个执行账号", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var usernames = ParseUsernames(usernamesText);
|
||||
if (usernames.Count == 0)
|
||||
{
|
||||
@@ -258,6 +264,16 @@
|
||||
{
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
var executeAccountId = ResolveExecuteAccountId(ch);
|
||||
if (executeAccountId == null)
|
||||
{
|
||||
done += usernames.Count;
|
||||
failed += usernames.Count;
|
||||
failures.Add($"{ch.Title}:无可用执行账号(将使用创建账号执行;但该频道无创建账号/创建账号不可用)");
|
||||
StateHasChanged();
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var username in usernames)
|
||||
{
|
||||
_cts.Token.ThrowIfCancellationRequested();
|
||||
@@ -267,7 +283,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ChannelService.InviteUserAsync(selectedAccountId, ch.TelegramId, username);
|
||||
var result = await ChannelService.InviteUserAsync(executeAccountId.Value, ch.TelegramId, username);
|
||||
if (!result.Success)
|
||||
{
|
||||
failed++;
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<MudTextField @bind-Value="newCategoryName" Label="分类名称" Variant="Variant.Outlined" />
|
||||
<MudColorPicker @bind-Value="newCategoryColor" Label="分类颜色" Class="mt-3" />
|
||||
<MudTextField @bind-Value="newCategoryDesc" Label="描述" Variant="Variant.Outlined" Lines="3" Class="mt-3" />
|
||||
<MudSwitch @bind-Value="newCategoryExcludeFromOperations" Color="Color.Primary" Class="mt-3"
|
||||
Label="排除操作(不出现在创建/批量任务中)" />
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||||
@@ -40,6 +42,7 @@
|
||||
<MudTh>分类名称</MudTh>
|
||||
<MudTh>颜色</MudTh>
|
||||
<MudTh>描述</MudTh>
|
||||
<MudTh>排除操作</MudTh>
|
||||
<MudTh>账号数量</MudTh>
|
||||
<MudTh>操作</MudTh>
|
||||
</HeaderContent>
|
||||
@@ -51,6 +54,16 @@
|
||||
<div style="@($"width: 30px; height: 30px; background-color: {context.Color ?? "#9E9E9E"}; border-radius: 4px;")"></div>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="描述">@(context.Description ?? "-")</MudTd>
|
||||
<MudTd DataLabel="排除操作">
|
||||
@if (context.ExcludeFromOperations)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">是</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>-</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="账号数量">@context.Accounts.Count</MudTd>
|
||||
<MudTd DataLabel="操作">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
|
||||
@@ -115,6 +128,7 @@
|
||||
private string newCategoryName = "";
|
||||
private MudColor newCategoryColor = "#1976d2";
|
||||
private string newCategoryDesc = "";
|
||||
private bool newCategoryExcludeFromOperations;
|
||||
private List<AccountCategory> categories = new();
|
||||
private bool loading = true;
|
||||
|
||||
@@ -215,6 +229,7 @@
|
||||
Name = newCategoryName,
|
||||
Color = newCategoryColor.Value,
|
||||
Description = newCategoryDesc,
|
||||
ExcludeFromOperations = newCategoryExcludeFromOperations,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -226,6 +241,7 @@
|
||||
newCategoryName = "";
|
||||
newCategoryColor = "#1976d2";
|
||||
newCategoryDesc = "";
|
||||
newCategoryExcludeFromOperations = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -290,6 +306,7 @@
|
||||
category.Name = model.Name.Trim();
|
||||
category.Color = model.Color;
|
||||
category.Description = model.Description;
|
||||
category.ExcludeFromOperations = model.ExcludeFromOperations;
|
||||
|
||||
await CategoryManagement.UpdateCategoryAsync(category);
|
||||
await LoadCategories();
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
|
||||
Size="Size.Small" Disabled="@(loading || selectedBotId <= 0)" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="ExportInvites">导出邀请(已选)</MudMenuItem>
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenSetAdmins">批量设置管理员(已选)</MudMenuItem>
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchInviteMembers">批量邀请成员(已选)</MudMenuItem>
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetAdminsByAccount">批量设置管理员(账号执行)</MudMenuItem>
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenSetAdmins">批量设置管理员(机器人/ID)</MudMenuItem>
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetCategory">批量设置分类(已选)</MudMenuItem>
|
||||
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBanMember">踢出用户(已选)</MudMenuItem>
|
||||
</MudMenu>
|
||||
@@ -450,6 +452,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenBatchInviteMembers()
|
||||
{
|
||||
if (selectedBotId <= 0)
|
||||
return;
|
||||
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请先选择频道", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["BotId"] = selectedBotId,
|
||||
["Channels"] = selected.ToList()
|
||||
};
|
||||
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
var dialog = DialogService.Show<BotChannelBatchInviteDialog>("批量邀请成员", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
if (!result.Canceled)
|
||||
{
|
||||
// 邀请不影响本地数据,但用户可能期望刷新一下
|
||||
await LoadBotData();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenBatchSetAdminsByAccount()
|
||||
{
|
||||
if (selectedBotId <= 0)
|
||||
return;
|
||||
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
Snackbar.Add("请先选择频道", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["BotId"] = selectedBotId,
|
||||
["Channels"] = selected.ToList()
|
||||
};
|
||||
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
var dialog = DialogService.Show<BotChannelBatchAdminsDialog>("批量设置管理员(账号执行)", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
if (!result.Canceled)
|
||||
{
|
||||
// 管理员设置后不影响本地数据,但用户可能期望刷新一下
|
||||
await LoadBotData();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenBatchSetCategory()
|
||||
{
|
||||
if (selectedBotId <= 0)
|
||||
|
||||
@@ -101,7 +101,9 @@
|
||||
try
|
||||
{
|
||||
var accountList = await AccountManagement.GetActiveAccountsAsync();
|
||||
accounts = accountList.ToList();
|
||||
accounts = accountList
|
||||
.Where(a => a.Category?.ExcludeFromOperations != true)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user