Files
Telegram-Panel/src/TelegramPanel.Web/Components/Pages/Settings.razor
T
2026-03-10 05:30:26 +08:00

867 lines
38 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/settings"
@inject IConfiguration Configuration
@inject AccountManagementService AccountManagement
@inject ChannelManagementService ChannelManagement
@inject GroupManagementService GroupManagement
@inject IChannelService ChannelService
@inject IGroupService GroupService
@inject ITelegramClientPool TelegramClientPool
@inject TelegramPanel.Web.Services.DataSyncService DataSync
@inject BatchTaskManagementService TaskManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IWebHostEnvironment HostEnvironment
@inject PanelTimeZoneService TimeZone
@inject NavigationManager Navigation
@inject CloudMailClient CloudMail
@inject TelegramPanelAiService PanelAiService
@using TelegramPanel.Web.Services
@using TelegramPanel.Core.Utils
<PageTitle>系统设置 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">系统设置</MudText>
<MudGrid>
<MudItem xs="12" md="6">
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">Telegram API 配置</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="apiId" Label="默认 API ID" Variant="Variant.Outlined"
HelperText="从 https://my.telegram.org 获取" />
<MudTextField @bind-Value="apiHash" Label="默认 API Hash" Variant="Variant.Outlined" Class="mt-3"
HelperText="用于新账号登录和导入(32 位十六进制字符串)" />
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mt-3">
<MudText Typo="Typo.body2"><strong>写入位置:</strong>@LocalConfigPath</MudText>
<MudText Typo="Typo.body2" Class="mt-1"><strong>文件存在:</strong>@(LocalConfigExists ? "是" : "否")</MudText>
<MudText Typo="Typo.body2" Class="mt-1"><strong>当前生效 ApiId:</strong>@(Configuration["Telegram:ApiId"] ?? "(未配置)")</MudText>
</MudAlert>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveApiConfig())">
保存配置
</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-4">
<MudCardHeader>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.h6">Cloud Mail 对接</MudText>
<MudLink Href="https://github.com/maillab/cloud-mail" Target="_blank" Underline="Underline.Always">
GitHub
</MudLink>
</MudStack>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="cloudMailBaseUrl" Label="Cloud Mail URL" Variant="Variant.Outlined"
HelperText="例如:https://mail.example.com(无需填写 /api)" />
<MudTextField @bind-Value="cloudMailDomain" Label="邮箱域名" Variant="Variant.Outlined" Class="mt-3"
HelperText="例如:xx.com(将生成 手机号数字@xx.com)" />
<MudTextField @bind-Value="cloudMailToken" Label="Authorization Token" Variant="Variant.Outlined" Class="mt-3"
HelperText="Cloud Mail 的全局 Token(生成新 Token 会使旧 Token 失效)" />
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2">生成 Token(可选)</MudText>
<MudTextField @bind-Value="cloudMailAdminEmail" Label="管理员邮箱" Variant="Variant.Outlined" Class="mt-2" />
<MudTextField @bind-Value="cloudMailAdminPassword" Label="管理员密码" Variant="Variant.Outlined" Class="mt-3"
InputType="InputType.Password" />
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveCloudMailConfig())">
保存配置
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info"
Disabled="@cloudMailGeneratingToken"
OnClick="@(async (MouseEventArgs _) => await GenerateCloudMailToken())">
生成并填入 Token
</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">AI 设置</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="aiEndpoint" Label="OpenAI 自定义端点" Variant="Variant.Outlined"
HelperText="只填写 https://xxx.com/v1,程序会自动拼接 /chat/completions" />
<MudTextField @bind-Value="aiApiKey" Label="API Key" Variant="Variant.Outlined" Class="mt-3"
InputType="InputType.Password"
HelperText="后续任务和模块会复用这套全局 AI 配置。" />
<MudTextField @bind-Value="aiDefaultModel" Label="默认模型" Variant="Variant.Outlined" Class="mt-3"
HelperText="任务里未单独选择模型时,回退使用这里的默认模型。" />
<MudTextField @bind-Value="aiPresetModelsText" Label="预设模型(每行一个)" Variant="Variant.Outlined" Class="mt-3"
Lines="4"
HelperText="例如:gpt-4o-mini、qwen-vl-plus;任务编辑器会直接复用这里的预设。" />
<MudNumericField @bind-Value="aiRetryCount" Label="AI 失败重试次数" Variant="Variant.Outlined" Class="mt-3"
Min="0" Max="5"
HelperText="0 表示不重试;实际最多请求次数 = 1 + 重试次数。建议 2。" />
<MudText Typo="Typo.caption" Class="mt-2 mud-text-secondary">
测试连通会直接使用当前表单里的端点、Key、模型和重试次数,不必先保存。
</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveAiConfig())">
保存 AI 配置
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info"
Disabled="@aiTesting"
OnClick="@(async (MouseEventArgs _) => await TestAiConnectionAsync())">
@if (aiTesting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>测试中...</span>
}
else
{
<span>测试连通</span>
}
</MudButton>
</MudCardActions>
</MudCard>
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">批量操作设置</MudText>
</MudCardHeader>
<MudCardContent>
<MudNumericField @bind-Value="defaultDelay" Label="默认操作间隔 (毫秒)" Variant="Variant.Outlined"
Min="1000" Max="10000" HelperText="建议2000-5000ms" />
<MudNumericField @bind-Value="maxConcurrent" Label="最大并发任务数" Variant="Variant.Outlined" Class="mt-3"
Min="1" Max="10" HelperText="同时执行的任务数量" />
<MudNumericField @bind-Value="historyRetentionLimit" Label="历史任务保留上限" Variant="Variant.Outlined" Class="mt-3"
Min="0" Max="5000" HelperText="仅保留最新的已完成/失败任务,0 表示不限制" />
<MudSwitch @bind-Value="autoRetry" Label="失败自动重试" Color="Color.Primary" Class="mt-3" />
@if (autoRetry)
{
<MudNumericField @bind-Value="maxRetries" Label="最大重试次数" Variant="Variant.Outlined" Class="mt-3"
Min="1" Max="5" />
}
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveBatchConfig())">
保存配置
</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">本地化</MudText>
</MudCardHeader>
<MudCardContent>
<MudSelect @bind-Value="timeZoneId" Label="时区(用于面板显示)" Variant="Variant.Outlined" Dense="true">
@foreach (var item in KnownTimeZones)
{
<MudSelectItem Value="@item.Id">@item.Label</MudSelectItem>
}
</MudSelect>
<MudText Typo="Typo.caption" Class="mt-2 mud-text-secondary">
当前生效:@EffectiveTimeZoneHint
</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mt-3">
<MudText Typo="Typo.body2">
本项目数据库中的时间统一按 UTC 存储;该设置仅影响面板显示的时区转换。
</MudText>
</MudAlert>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveTimeZoneConfig())">
保存时区
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
<MudItem xs="12" md="6">
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">数据同步</MudText>
</MudCardHeader>
<MudCardContent>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mb-3">
<MudText Typo="Typo.body2">
<strong>同步说明:</strong>将每个活跃账号“当前可见的频道 + 当前可见的群组”拉取并写入本地数据库,用于频道/群组列表展示,以及批量任务自动选择执行账号。
<br />
<strong>任务中心:</strong>自动同步与“立即同步”都会记录到【任务中心】,可查看是否正在运行、已同步到第几个账号以及失败数量。
<br />
<strong>补充:</strong>同步过程中若遇到连接异常,会顺带执行一次轻量 Telegram 状态刷新(不做创建频道深度探测)。
<br />
<strong>不包含:</strong>深度 Telegram 状态检测(创建频道探测)与验证码收取。
</MudText>
</MudAlert>
<MudSwitch @bind-Value="autoSync" Label="自动同步频道/群组数据(后台)" Color="Color.Primary" />
@if (autoSync)
{
<MudNumericField @bind-Value="syncInterval" Label="同步间隔 (小时)" Variant="Variant.Outlined" Class="mt-3"
Min="1" Max="24" />
}
<MudStack Row="true" Spacing="2" Class="mt-3" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Class="tp-input-full-xs"
Style="flex: 1; min-width: 220px;"
OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())">
保存同步设置
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Info" Class="tp-input-full-xs"
Style="flex: 1; min-width: 220px;"
Disabled="@syncing"
OnClick="@(async (MouseEventArgs _) => await SyncNow())">
@(syncing ? "同步中..." : "立即同步频道/群组")
</MudButton>
</MudStack>
<MudDivider Class="my-4" />
<MudText Typo="Typo.subtitle2">Bot 频道秒级更新</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
说明:开启后后台会持续轮询 Bot API(getUpdates)并应用 my_chat_member 更新,用于 Bot 被加入/撤权频道后自动出现在“Bot 频道”列表。
</MudText>
<MudSwitch @bind-Value="botAutoSyncEnabled" Label="自动同步 Bot 频道(后台)" Color="Color.Primary" />
@if (botAutoSyncEnabled)
{
<MudNumericField @bind-Value="botAutoSyncIntervalSeconds" Label="轮询间隔 (秒)" Variant="Variant.Outlined" Class="mt-3"
Min="2" Max="60" HelperText="建议 2-10 秒;过低可能更容易触发 Telegram 限流。" />
}
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true" Class="mt-3"
OnClick="@(async (MouseEventArgs _) => await SaveBotAutoSyncConfig())">
保存 Bot 自动同步设置
</MudButton>
</MudCardContent>
</MudCard>
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">安全</MudText>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2">
修改后台登录密码。
</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Password"
OnClick="@(async (MouseEventArgs _) => await OpenChangePassword())">
修改密码
</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">日志设置</MudText>
</MudCardHeader>
<MudCardContent>
<MudSwitch T="bool" @bind-Value="logEnabled" Color="Color.Primary" Class="mb-2">
启用日志输出
</MudSwitch>
<MudText Typo="Typo.caption" Color="Color.Secondary">
说明:关闭后仅输出 Error/Fatal(控制台),并停止写入日志文件。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。
</MudText>
<MudSelect @bind-Value="logLevel" Label="日志级别" Variant="Variant.Outlined" Disabled="@(!logEnabled)"
Class="mt-3">
<MudSelectItem Value='@("Debug")'>Debug</MudSelectItem>
<MudSelectItem Value='@("Information")'>Information</MudSelectItem>
<MudSelectItem Value='@("Warning")'>Warning</MudSelectItem>
<MudSelectItem Value='@("Error")'>Error</MudSelectItem>
</MudSelect>
<MudNumericField @bind-Value="logRetentionDays" Label="日志保留天数" Variant="Variant.Outlined" Class="mt-3"
Disabled="@(!logEnabled)"
Min="1" Max="90" />
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveLogConfig())">
保存配置
</MudButton>
</MudCardActions>
</MudCard>
<MudCard>
<MudCardHeader>
<MudText Typo="Typo.h6">系统信息</MudText>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2"><strong>版本:</strong> @VersionService.Version</MudText>
<MudText Typo="Typo.body2" Class="mt-2"><strong>运行时:</strong> .NET 8.0</MudText>
<MudText Typo="Typo.body2" Class="mt-2"><strong>数据库:</strong> SQLite</MudText>
<MudDivider Class="my-3" />
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" FullWidth="true"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(async (MouseEventArgs _) => await ClearCache())">
清除缓存
</MudButton>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
@code {
private string apiId = "";
private string apiHash = "";
private int defaultDelay = 2000;
private int maxConcurrent = 1;
private int historyRetentionLimit = 0;
private bool autoRetry = false;
private int maxRetries = 3;
private bool autoSync = true;
private int syncInterval = 6;
private bool botAutoSyncEnabled = false;
private int botAutoSyncIntervalSeconds = 2;
private bool logEnabled = false;
private string logLevel = "Information";
private int logRetentionDays = 30;
private bool syncing = false;
private string timeZoneId = "";
private string cloudMailBaseUrl = "";
private string cloudMailDomain = "";
private string cloudMailToken = "";
private string cloudMailAdminEmail = "";
private string cloudMailAdminPassword = "";
private bool cloudMailGeneratingToken = false;
private string aiEndpoint = "";
private string aiApiKey = "";
private string aiDefaultModel = "";
private string aiPresetModelsText = "";
private int aiRetryCount = 2;
private bool aiTesting = false;
private sealed record TimeZoneChoice(string Id, string Label);
private static readonly IReadOnlyList<TimeZoneChoice> KnownTimeZones = new List<TimeZoneChoice>
{
new("Asia/Shanghai", "北京时间(Asia/Shanghai)"),
new("UTC", "UTC"),
new("China Standard Time", "北京时间(Windows: China Standard Time)")
};
private string EffectiveTimeZoneHint
{
get
{
var tz = TimeZone.Current;
var sign = tz.BaseUtcOffset >= TimeSpan.Zero ? "+" : "-";
var offset = tz.BaseUtcOffset.Duration().ToString(@"hh\:mm");
var now = TimeZone.Format(DateTime.UtcNow, "yyyy-MM-dd HH:mm:ss");
return $"{tz.Id}(UTC{sign}{offset}),现在:{now}";
}
}
private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, HostEnvironment);
private bool LocalConfigExists => System.IO.File.Exists(LocalConfigPath);
protected override void OnInitialized()
{
// 从配置文件加载默认设置
apiId = Configuration["Telegram:ApiId"] ?? "";
apiHash = Configuration["Telegram:ApiHash"] ?? "";
defaultDelay = Configuration.GetValue("Telegram:DefaultDelayMs", 2000);
maxConcurrent = Configuration.GetValue("BatchTasks:MaxConcurrent", 1);
historyRetentionLimit = Configuration.GetValue("BatchTasks:HistoryRetentionLimit", 0);
maxRetries = Configuration.GetValue("Telegram:MaxRetries", 0);
autoRetry = maxRetries > 0;
if (maxRetries <= 0) maxRetries = 3;
autoSync = Configuration.GetValue("Sync:AutoSyncEnabled", false);
syncInterval = Configuration.GetValue("Sync:IntervalHours", 6);
botAutoSyncEnabled = Configuration.GetValue("Telegram:BotAutoSyncEnabled", false);
botAutoSyncIntervalSeconds = Configuration.GetValue("Telegram:BotAutoSyncIntervalSeconds", 2);
logEnabled = Configuration.GetValue("Serilog:Enabled", false);
logLevel = (Configuration["Serilog:MinimumLevel:Default"] ?? "Information").Trim();
logRetentionDays = Configuration.GetValue("Serilog:RetainedFileCountLimit", 30);
timeZoneId = Configuration["System:TimeZoneId"] ?? "";
cloudMailBaseUrl = Configuration["CloudMail:BaseUrl"] ?? "";
cloudMailDomain = Configuration["CloudMail:Domain"] ?? "";
cloudMailToken = Configuration["CloudMail:Token"] ?? "";
aiEndpoint = Configuration["AI:OpenAI:Endpoint"] ?? "";
aiApiKey = Configuration["AI:OpenAI:ApiKey"] ?? "";
aiDefaultModel = Configuration["AI:OpenAI:DefaultModel"] ?? "";
aiPresetModelsText = string.Join(System.Environment.NewLine, Configuration.GetSection("AI:OpenAI:PresetModels").GetChildren().Select(x => x.Value).Where(x => !string.IsNullOrWhiteSpace(x)));
aiRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(Configuration.GetValue("AI:OpenAI:RetryCount", 2));
}
private async Task SaveApiConfig()
{
if (!int.TryParse(apiId, out int parsedApiId) || parsedApiId <= 0)
{
Snackbar.Add("默认 API ID 格式错误", Severity.Error);
return;
}
if (string.IsNullOrWhiteSpace(apiHash))
{
Snackbar.Add("默认 API Hash 不能为空", Severity.Error);
return;
}
if (!TelegramApiConfigValidator.TryNormalizeApiHash(apiHash, out var normalizedApiHash, out var apiHashReason))
{
Snackbar.Add($"默认 API Hash 无效:{apiHashReason}", Severity.Error);
return;
}
try
{
var localConfigPath = LocalConfigPath;
var root = await LoadOrCreateLocalConfigAsync();
var telegram = EnsureObject(root, "Telegram");
telegram["ApiId"] = parsedApiId;
telegram["ApiHash"] = normalizedApiHash;
await SaveLocalConfigAsync(root);
// 配置变更后清理客户端缓存,确保后续操作使用新 ApiId/ApiHash
await TelegramClientPool.RemoveAllClientsAsync();
string sizeHint;
try
{
var fileInfo = new System.IO.FileInfo(localConfigPath);
sizeHint = $"({fileInfo.Length} bytes)";
}
catch
{
sizeHint = "";
}
Snackbar.Add($"API 配置已保存:{localConfigPath}{sizeHint}。已清理 Telegram 客户端缓存,后续操作将使用新配置。", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task SaveSyncConfig()
{
if (autoSync && (syncInterval < 1 || syncInterval > 24))
{
Snackbar.Add("同步间隔范围应为 1-24 小时", Severity.Error);
return;
}
try
{
var root = await LoadOrCreateLocalConfigAsync();
var sync = EnsureObject(root, "Sync");
sync["AutoSyncEnabled"] = autoSync;
sync["IntervalHours"] = syncInterval;
await SaveLocalConfigAsync(root);
Snackbar.Add("同步设置已保存(重启/自动重载后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task SaveCloudMailConfig()
{
try
{
var root = await LoadOrCreateLocalConfigAsync();
var cloud = EnsureObject(root, "CloudMail");
cloud["BaseUrl"] = (cloudMailBaseUrl ?? string.Empty).Trim();
cloud["Domain"] = (cloudMailDomain ?? string.Empty).Trim().TrimStart('@');
cloud["Token"] = (cloudMailToken ?? string.Empty).Trim();
await SaveLocalConfigAsync(root);
Snackbar.Add("Cloud Mail 配置已保存(配置热重载后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task SaveAiConfig()
{
if (aiRetryCount < 0 || aiRetryCount > 5)
{
Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error);
return;
}
try
{
var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint);
var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey);
var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel);
var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel);
var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount);
var root = await LoadOrCreateLocalConfigAsync();
var ai = EnsureObject(root, "AI");
var openAi = EnsureObject(ai, "OpenAI");
openAi["Endpoint"] = normalizedEndpoint ?? string.Empty;
openAi["ApiKey"] = normalizedApiKey ?? string.Empty;
openAi["DefaultModel"] = normalizedDefaultModel ?? string.Empty;
openAi["RetryCount"] = normalizedRetryCount;
var modelsArray = new System.Text.Json.Nodes.JsonArray();
foreach (var model in presetModels)
modelsArray.Add(model);
openAi["PresetModels"] = modelsArray;
await SaveLocalConfigAsync(root);
aiEndpoint = normalizedEndpoint ?? string.Empty;
aiApiKey = normalizedApiKey ?? string.Empty;
aiDefaultModel = normalizedDefaultModel ?? string.Empty;
aiPresetModelsText = string.Join(System.Environment.NewLine, presetModels);
aiRetryCount = normalizedRetryCount;
Snackbar.Add("AI 配置已保存(配置热重载后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task TestAiConnectionAsync()
{
if (aiTesting)
return;
if (aiRetryCount < 0 || aiRetryCount > 5)
{
Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error);
return;
}
aiTesting = true;
try
{
var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint);
var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey);
var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel);
var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel);
var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount);
var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels, normalizedRetryCount);
var testModel = ResolveAiTestModel(snapshot);
if (!snapshot.TryValidateForTask(testModel, out var validateError))
{
Snackbar.Add($"AI 连通测试失败:{validateError}", Severity.Warning);
return;
}
var result = await PanelAiService.TestConnectionAsync(snapshot, testModel, CancellationToken.None);
if (!result.Success)
{
Snackbar.Add($"AI 连通测试失败:{result.Error}", Severity.Error);
return;
}
var modelName = result.Model ?? snapshot.ResolveModel(testModel) ?? "(未知模型)";
var preview = string.IsNullOrWhiteSpace(result.ResponseText) ? "(空响应)" : result.ResponseText;
Snackbar.Add($"AI 连通成功:模型 {modelName},返回 {preview}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"AI 连通测试失败:{ex.Message}", Severity.Error);
}
finally
{
aiTesting = false;
StateHasChanged();
}
}
private string? ResolveAiTestModel(AiOpenAiSettingsSnapshot snapshot)
{
var defaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel);
if (!string.IsNullOrWhiteSpace(defaultModel))
return defaultModel;
return snapshot.PresetModels.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
}
private async Task GenerateCloudMailToken()
{
if (cloudMailGeneratingToken)
return;
var baseUrl = (cloudMailBaseUrl ?? string.Empty).Trim();
var email = (cloudMailAdminEmail ?? string.Empty).Trim();
var password = cloudMailAdminPassword ?? string.Empty;
if (string.IsNullOrWhiteSpace(baseUrl))
{
Snackbar.Add("请先填写 Cloud Mail URL", Severity.Warning);
return;
}
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
{
Snackbar.Add("请填写管理员邮箱和密码", Severity.Warning);
return;
}
cloudMailGeneratingToken = true;
try
{
var token = await CloudMail.GenerateTokenAsync(baseUrl, email, password, CancellationToken.None);
cloudMailToken = token;
Snackbar.Add("Token 已生成并填入(记得点击“保存配置”)", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"生成 Token 失败:{ex.Message}", Severity.Error);
}
finally
{
cloudMailGeneratingToken = false;
}
}
private async Task SaveBotAutoSyncConfig()
{
if (botAutoSyncEnabled && (botAutoSyncIntervalSeconds < 2 || botAutoSyncIntervalSeconds > 60))
{
Snackbar.Add("Bot 自动同步轮询间隔范围应为 2-60 秒", Severity.Error);
return;
}
try
{
var root = await LoadOrCreateLocalConfigAsync();
var tg = EnsureObject(root, "Telegram");
tg["BotAutoSyncEnabled"] = botAutoSyncEnabled;
tg["BotAutoSyncIntervalSeconds"] = botAutoSyncIntervalSeconds;
await SaveLocalConfigAsync(root);
Snackbar.Add("Bot 自动同步设置已保存(重启/自动重载后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task SaveTimeZoneConfig()
{
try
{
var id = (timeZoneId ?? string.Empty).Trim();
var root = await LoadOrCreateLocalConfigAsync();
var system = EnsureObject(root, "System");
system["TimeZoneId"] = id;
await SaveLocalConfigAsync(root);
Snackbar.Add("时区设置已保存(配置热重载后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private Task OpenChangePassword()
{
Navigation.NavigateTo("/admin/password");
return Task.CompletedTask;
}
private async Task<System.Text.Json.Nodes.JsonObject> LoadOrCreateLocalConfigAsync()
{
var path = LocalConfigPath;
if (!System.IO.File.Exists(path))
return new System.Text.Json.Nodes.JsonObject();
var json = await System.IO.File.ReadAllTextAsync(path);
if (string.IsNullOrWhiteSpace(json))
return new System.Text.Json.Nodes.JsonObject();
return System.Text.Json.Nodes.JsonNode.Parse(json)?.AsObject()
?? new System.Text.Json.Nodes.JsonObject();
}
private async Task SaveLocalConfigAsync(System.Text.Json.Nodes.JsonObject root)
{
var json = LocalConfigFile.ToIndentedJson(root);
var path = LocalConfigPath;
await LocalConfigFile.EnsureExistsAsync(path);
await LocalConfigFile.WriteJsonAtomicallyAsync(path, json);
}
private static System.Text.Json.Nodes.JsonObject EnsureObject(System.Text.Json.Nodes.JsonObject root, string key)
{
if (root[key] is System.Text.Json.Nodes.JsonObject obj)
return obj;
var created = new System.Text.Json.Nodes.JsonObject();
root[key] = created;
return created;
}
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 => x.Length > 0)
.ToList();
}
private async Task SaveBatchConfig()
{
if (defaultDelay < 1000 || defaultDelay > 10000)
{
Snackbar.Add("默认操作间隔范围应为 1000-10000ms", Severity.Error);
return;
}
if (maxConcurrent < 1 || maxConcurrent > 10)
{
Snackbar.Add("最大并发任务数范围应为 1-10", Severity.Error);
return;
}
if (autoRetry && (maxRetries < 1 || maxRetries > 5))
{
Snackbar.Add("最大重试次数范围应为 1-5", Severity.Error);
return;
}
if (historyRetentionLimit < 0 || historyRetentionLimit > 5000)
{
Snackbar.Add("历史任务保留上限范围应为 0-5000", Severity.Error);
return;
}
try
{
var root = await LoadOrCreateLocalConfigAsync();
var telegram = EnsureObject(root, "Telegram");
telegram["DefaultDelayMs"] = defaultDelay;
telegram["MaxRetries"] = autoRetry ? maxRetries : 0;
var batchTasks = EnsureObject(root, "BatchTasks");
batchTasks["MaxConcurrent"] = maxConcurrent;
batchTasks["HistoryRetentionLimit"] = historyRetentionLimit;
await SaveLocalConfigAsync(root);
var deletedCount = historyRetentionLimit > 0
? await TaskManagement.TrimHistoryTasksAsync(historyRetentionLimit)
: 0;
var message = deletedCount > 0
? $"批量操作配置已保存,并已清理 {deletedCount} 条超出上限的历史任务"
: "批量操作配置已保存(配置热重载后生效)";
Snackbar.Add(message, Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private Task SaveLogConfig()
{
return SaveLogConfigAsync();
}
private async Task SaveLogConfigAsync()
{
var level = (logLevel ?? "Information").Trim();
if (level is not ("Debug" or "Information" or "Warning" or "Error"))
{
Snackbar.Add("日志级别无效", Severity.Error);
return;
}
if (logRetentionDays < 1 || logRetentionDays > 90)
{
Snackbar.Add("日志保留天数范围应为 1-90", Severity.Error);
return;
}
try
{
var root = await LoadOrCreateLocalConfigAsync();
var serilog = EnsureObject(root, "Serilog");
serilog["Enabled"] = logEnabled;
var minimumLevel = EnsureObject(serilog, "MinimumLevel");
minimumLevel["Default"] = level;
// 程序使用按天滚动的日志文件,因此“保留天数”可以映射到“保留文件数”。
serilog["RetainedFileCountLimit"] = logRetentionDays;
await SaveLocalConfigAsync(root);
Snackbar.Add("日志配置已保存(重启后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task SyncNow()
{
if (syncing)
{
Snackbar.Add("正在同步中,请稍候...", Severity.Warning);
return;
}
syncing = true;
Snackbar.Add("正在后台创建同步任务...", Severity.Info);
try
{
var taskId = await DataSync.StartAllActiveAccountsTrackedInBackgroundAsync("manual", CancellationToken.None);
Snackbar.Add($"已启动后台同步任务 #{taskId},请到任务中心查看进度", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"同步启动失败:{ex.Message}", Severity.Error);
}
finally
{
syncing = false;
}
}
private async Task ClearCache()
{
bool? result = await DialogService.ShowMessageBox(
"确认清除",
"确定要清除缓存吗?这将清除所有临时数据。",
yesText: "清除", cancelText: "取消");
if (result == true)
{
// TODO: 实现缓存清除逻辑
Snackbar.Add("缓存已清除", Severity.Success);
}
}
}