diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor index 9966e90..aa60aac 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor @@ -13,20 +13,23 @@ - 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”或“昵称列表”。这两个输入都支持 `{time}` 和文本字典变量。 + 将对选中的账号批量修改昵称。只需要填写“昵称模板”,支持一行一个模板,并支持多个变量组合,例如:{diqu}歌神{geshou}。 账号数量:@accounts.Count - - + - - + - 例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。 + 当同一批次生成出重复昵称时,自动在后面追加该账号手机号后 4 位。 @if (textDictionaryNames.Count > 0) @@ -96,9 +99,7 @@ private List accounts = new(); private List textDictionaryNames = new(); - private string baseNickname = ""; - private bool appendPhoneLast4 = true; - private string nicknameLines = ""; + private string nicknameTemplatesText = string.Empty; private bool appendPhoneLast4WhenDuplicate = true; private bool running; @@ -139,16 +140,13 @@ return; } - var nicknameList = ParseNicknameLines(nicknameLines); - var useList = nicknameList.Count > 0; - - if (!useList && string.IsNullOrWhiteSpace(baseNickname)) + var templates = ParseNicknameLines(nicknameTemplatesText); + if (templates.Count == 0) { - Snackbar.Add("批量昵称不能为空", Severity.Warning); + Snackbar.Add("请至少填写一行昵称模板", Severity.Warning); return; } - var templates = useList ? nicknameList : new List { baseNickname }; if (!ValidateTemplates(templates, out var templateError)) { Snackbar.Add(templateError ?? "昵称模板不合法", Severity.Warning); @@ -200,10 +198,10 @@ try { - var baseUsedCount = new Dictionary(StringComparer.Ordinal); + var usedNicknames = new Dictionary(StringComparer.Ordinal); foreach (var account in accounts) { - var nicknameTemplate = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; + var nicknameTemplate = templates[processed % templates.Count]; string resolvedNickname; try { @@ -218,15 +216,20 @@ continue; } - var needAppend = appendPhoneLast4; - if (useList) + if (string.IsNullOrWhiteSpace(resolvedNickname)) { - baseUsedCount.TryGetValue(resolvedNickname, out var used); - baseUsedCount[resolvedNickname] = used + 1; - needAppend = appendPhoneLast4WhenDuplicate && used > 0; + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, "生成的昵称为空,请检查模板或字典内容")); + StateHasChanged(); + continue; } - var newNickname = BuildNickname(resolvedNickname, account.Phone, needAppend); + usedNicknames.TryGetValue(resolvedNickname, out var usedCount); + usedNicknames[resolvedNickname] = usedCount + 1; + + var shouldAppendPhoneTail = appendPhoneLast4WhenDuplicate && usedCount > 0; + var newNickname = BuildNickname(resolvedNickname, account.Phone, shouldAppendPhoneTail); var (ok, err) = await AccountTelegramTools.UpdateUserProfileAsync( accountId: account.Id, diff --git a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor index 9e7d4aa..b7f9538 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor @@ -1,4 +1,6 @@ @inject AccountCategoryManagementService CategoryManagement +@inject DataDictionaryService DictionaryService +@inject TemplateRenderingService TemplateRendering @using System.Text.Json @using TelegramPanel.Data.Entities @using TelegramPanel.Modules @@ -8,6 +10,7 @@ 持续活跃任务会在后台循环执行并按间隔发送内容。MaxMessages=0 表示持续运行,直到你在任务中心手动取消。 + 词典消息现在支持 {time} 和文本字典变量;任务仍会先按“随机 / 队列”模式选中一行,再解析其中变量后发送。 @if (loading) @@ -36,14 +39,23 @@ Variant="Variant.Outlined" Lines="5" Immediate="true" - HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890),如私密群组无法复制链接可登录Telegram Web面板打开群组聊天界面查看链接,数字就是ID" /> + HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890),如私密群组无法复制链接可登录 Telegram Web 面板打开群组聊天界面查看链接,数字就是 ID" /> + HelperText="支持 {time} 和文本字典变量。任务会先按“随机 / 队列”模式选中一行,再解析变量后发送。" /> + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:{time}、@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + else + { + 当前可用内置变量:{time} + } @@ -93,15 +105,16 @@ [Parameter] public string? InitialConfigJson { get; set; } private List categories = new(); + private List textDictionaryNames = new(); private bool loading = true; private bool initializedFromConfig; private IEnumerable selectedCategoryIds = Array.Empty(); - private string targetsText = ""; - private string dictionaryText = ""; + private string targetsText = string.Empty; + private string dictionaryText = string.Empty; private int delayMinMs = 15; private int delayMaxMs = 45; - private int maxMessages = 0; + private int maxMessages; private string accountMode = UserChatActiveTaskModes.Random; private string targetMode = UserChatActiveTaskModes.Queue; @@ -118,17 +131,19 @@ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToList(); - if (!selectedCategoryIds.Any()) - { - var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; - selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); - } + textDictionaryNames = (await DictionaryService.GetAllAsync()) + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Where(x => x.Items.Any(i => i.IsEnabled && !string.IsNullOrWhiteSpace(i.TextValue))) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); } finally { loading = false; - await PushDraftAsync(); } + + await PushDraftAsync(); } private void ApplyInitialConfigIfNeeded() @@ -148,10 +163,7 @@ if (cfg == null) return; - var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (categoryIds.Count > 0) - selectedCategoryIds = categoryIds; - + selectedCategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); targetsText = string.Join(Environment.NewLine, cfg.Targets ?? new List()); dictionaryText = string.Join(Environment.NewLine, cfg.Dictionary ?? new List()); delayMinMs = ConvertMillisecondsToSeconds(cfg.DelayMinMs); @@ -215,6 +227,19 @@ return; } + foreach (var template in dictionary) + { + try + { + await TemplateRendering.ValidateTextTemplateAsync(template); + } + catch (Exception ex) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"词典消息模板无效:{ex.Message}")); + return; + } + } + if (delayMinMs < 0 || delayMaxMs < 0) { await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs index 12532fe..ca976a8 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -21,6 +21,7 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var taskManagement = host.Services.GetRequiredService(); var accountManagement = host.Services.GetRequiredService(); var accountTools = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); var config = DeserializeConfig(host.Config); ValidateAndNormalizeConfig(config); @@ -116,7 +117,7 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var targetSlot = accountSlot.Targets[targetIdx]; var messageIdx = SelectIndex(config.MessageMode, config.Dictionary.Count, ref messageQueueIndex); - var text = config.Dictionary[messageIdx]; + var textTemplate = config.Dictionary[messageIdx]; if (!await host.IsStillRunningAsync(cancellationToken)) { @@ -124,6 +125,65 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler break; } + string text; + try + { + text = (await templateRendering.RenderTextTemplateAsync(textTemplate, cancellationToken)).Trim(); + } + catch (Exception ex) + { + completed++; + failed++; + var hadTemplateFailure = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, $"词典模板解析失败:{ex.Message}"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (ShouldPersistProgress(completed, hadTemplateFailure, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var templateFailDelayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (templateFailDelayMs > 0 && !await DelayWithPauseCheckAsync(host, templateFailDelayMs, cancellationToken)) + { + config.Canceled = true; + break; + } + + continue; + } + + if (text.Length == 0) + { + completed++; + failed++; + var hadEmptyMessageFailure = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, "词典模板解析结果为空,无法发送"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (ShouldPersistProgress(completed, hadEmptyMessageFailure, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var emptyDelayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (emptyDelayMs > 0 && !await DelayWithPauseCheckAsync(host, emptyDelayMs, cancellationToken)) + { + config.Canceled = true; + break; + } + + continue; + } + var send = await accountTools.SendMessageToResolvedChatAsync( accountSlot.Account.Id, targetSlot.Resolved,