在 MiGu 侧落地日志定时清理与磁盘空间告警。
系统配置可开关清理策略;日志管理页支持查看/保存清理选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
|
||||
/// <summary>
|
||||
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
||||
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
|
||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。
|
||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3。
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
@@ -99,6 +99,58 @@ public sealed class ConfigStore
|
||||
return Put("deployment", el);
|
||||
}
|
||||
|
||||
public LogCleanupOptions GetLogCleanup()
|
||||
{
|
||||
var env = Get("system");
|
||||
return env.Payload switch
|
||||
{
|
||||
SystemConfig sc => (sc.LogCleanup ?? new LogCleanupOptions()).Clamp(),
|
||||
JsonElement el => ParseLogCleanup(el),
|
||||
_ => LogCleanupOptions.CreateDefaults()
|
||||
};
|
||||
}
|
||||
|
||||
public Envelope PutLogCleanup(LogCleanupOptions options)
|
||||
{
|
||||
var opt = (options ?? new LogCleanupOptions()).Clamp();
|
||||
Dictionary<string, JsonElement> dict;
|
||||
try
|
||||
{
|
||||
var current = Get("system").Payload;
|
||||
var el = current is JsonElement je
|
||||
? je
|
||||
: JsonSerializer.SerializeToElement(current, _jsonOpts);
|
||||
dict = el.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(el.GetRawText()) ?? new()
|
||||
: new Dictionary<string, JsonElement>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "读取 system 配置失败,按空对象合并 logCleanup");
|
||||
dict = new Dictionary<string, JsonElement>();
|
||||
}
|
||||
|
||||
dict["logCleanup"] = JsonSerializer.SerializeToElement(opt, _jsonOpts);
|
||||
var merged = JsonSerializer.SerializeToElement(dict, _jsonOpts);
|
||||
return Put("system", merged);
|
||||
}
|
||||
|
||||
private LogCleanupOptions ParseLogCleanup(JsonElement el)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("logCleanup", out var nested))
|
||||
return (nested.Deserialize<LogCleanupOptions>(_jsonOpts) ?? new LogCleanupOptions()).Clamp();
|
||||
var sc = el.Deserialize<SystemConfig>(_jsonOpts);
|
||||
return (sc?.LogCleanup ?? new LogCleanupOptions()).Clamp();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "反序列化 logCleanup 失败,回退默认值");
|
||||
return LogCleanupOptions.CreateDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
|
||||
{
|
||||
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 日志清理与磁盘告警(system.logCleanup)。字段对齐 Simple3 <c>LogCleanupOptions</c>。
|
||||
/// </summary>
|
||||
public sealed class LogCleanupOptions
|
||||
{
|
||||
/// <summary>是否启用后台自动清理。关闭后仅「立即清理」可手动触发。默认 true。</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>保留天数:删除 log/ 下 LastWriteTime 早于「现在 − N 天」的 *.log。默认 30,最小 1。</summary>
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
|
||||
/// <summary>后台清理间隔(小时)。默认 24;<= 0 归一为 1。</summary>
|
||||
public int CheckIntervalHours { get; set; } = 24;
|
||||
|
||||
/// <summary>启动时先执行一次清理。默认 true。</summary>
|
||||
public bool RunOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>是否启用磁盘剩余空间不足告警。默认 true。</summary>
|
||||
public bool DiskAlertEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>日志所在盘剩余低于该值(GB)时告警。默认 5。</summary>
|
||||
public double DiskFreeAlertGB { get; set; } = 5;
|
||||
|
||||
/// <summary>磁盘检测间隔(分钟)。默认 30;<= 0 归一为 30。</summary>
|
||||
public int DiskCheckIntervalMinutes { get; set; } = 30;
|
||||
|
||||
public static LogCleanupOptions CreateDefaults() => new();
|
||||
|
||||
public LogCleanupOptions Clamp()
|
||||
{
|
||||
RetentionDays = Math.Clamp(RetentionDays, 1, 3650);
|
||||
CheckIntervalHours = Math.Max(1, CheckIntervalHours);
|
||||
if (DiskFreeAlertGB <= 0) DiskFreeAlertGB = 5;
|
||||
DiskCheckIntervalMinutes = DiskCheckIntervalMinutes <= 0 ? 30 : DiskCheckIntervalMinutes;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
|
||||
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
|
||||
|
||||
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
|
||||
public record SystemConfig
|
||||
{
|
||||
public static SystemConfig Default() => new(
|
||||
DispatchLoopHz: 50,
|
||||
Log: new LogPolicy("info", 7, 256),
|
||||
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
||||
public LogCleanupOptions LogCleanup { get; init; } = new();
|
||||
|
||||
public static SystemConfig Default() => new();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Logs;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly LogCleanupService _cleanup;
|
||||
|
||||
public ConfigController(ConfigStore store)
|
||||
public ConfigController(ConfigStore store, LogCleanupService cleanup)
|
||||
{
|
||||
_store = store;
|
||||
_cleanup = cleanup;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
|
||||
}
|
||||
|
||||
var env = _store.Put(section, payload);
|
||||
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
|
||||
_cleanup.ApplySchedule();
|
||||
return Ok(new
|
||||
{
|
||||
section = env.Section,
|
||||
|
||||
@@ -2,18 +2,20 @@ using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.Logs;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
|
||||
/// 平台「日志管理」后端:把 Simple3 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
|
||||
/// (<c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
|
||||
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
|
||||
/// platform-vue 的配置中心「日志管理」页查看。对齐 Simple3 桌面端 <c>Simple3/UI/LogViewer.cs</c>
|
||||
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
|
||||
///
|
||||
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>,
|
||||
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||||
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>,
|
||||
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
|
||||
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
|
||||
///
|
||||
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>,
|
||||
@@ -59,15 +61,21 @@ public sealed class LogsController : ControllerBase
|
||||
private static readonly Regex NumericFieldRegex =
|
||||
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
|
||||
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<LogsController> _log;
|
||||
private readonly LogCleanupService _cleanup;
|
||||
|
||||
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
|
||||
public LogsController(
|
||||
Simple3Launcher launcher,
|
||||
IConfiguration config,
|
||||
ILogger<LogsController> log,
|
||||
LogCleanupService cleanup)
|
||||
{
|
||||
_launcher = launcher;
|
||||
_config = config;
|
||||
_log = log;
|
||||
_cleanup = cleanup;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 概览 ──
|
||||
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
|
||||
{
|
||||
var (root, workdir, error) = ResolveLogRoot();
|
||||
if (root == null)
|
||||
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
|
||||
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error, disk = DiskDto() });
|
||||
|
||||
if (!Directory.Exists(root))
|
||||
return Ok(new
|
||||
{
|
||||
exists = false, root, workingDirectory = workdir,
|
||||
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
|
||||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
||||
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
|
||||
disk = DiskDto()
|
||||
});
|
||||
|
||||
var files = EnumerateLogFiles(root);
|
||||
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
|
||||
totalFiles = files.Count,
|
||||
totalBytes = files.Sum(f => f.Bytes),
|
||||
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
|
||||
days
|
||||
days,
|
||||
disk = DiskDto()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>日志清理配置(对齐 Simple3 logCleanup)。</summary>
|
||||
[HttpGet("cleanup-config")]
|
||||
public IActionResult GetCleanupConfig() => Ok(_cleanup.GetOptions());
|
||||
|
||||
/// <summary>保存日志清理配置并立即重排后台定时器。</summary>
|
||||
[HttpPut("cleanup-config")]
|
||||
public IActionResult PutCleanupConfig([FromBody] LogCleanupOptions body)
|
||||
=> Ok(_cleanup.SaveOptions(body ?? new LogCleanupOptions()));
|
||||
|
||||
/// <summary>按当前保留天数立即清理一次过期 *.log。</summary>
|
||||
[HttpPost("cleanup-now")]
|
||||
public IActionResult CleanupNow()
|
||||
{
|
||||
var r = _cleanup.CleanNow();
|
||||
if (r.Error != null)
|
||||
return Problem(r.Error, statusCode: 500);
|
||||
return Ok(new
|
||||
{
|
||||
deletedFiles = r.DeletedFiles,
|
||||
freedBytes = r.FreedBytes,
|
||||
freedMB = Math.Round(r.FreedMB, 1)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,7 +185,7 @@ public sealed class LogsController : ControllerBase
|
||||
root = normRoot, exists = false, path = "", parent = (string?)null,
|
||||
dirCount = 0, fileCount = 0,
|
||||
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
|
||||
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
|
||||
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
|
||||
});
|
||||
|
||||
var target = SafeResolveDir(normRoot, path);
|
||||
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
|
||||
return PhysicalFile(full, "application/octet-stream", downloadName);
|
||||
}
|
||||
|
||||
private object DiskDto()
|
||||
{
|
||||
var d = _cleanup.GetDiskStatus();
|
||||
return new
|
||||
{
|
||||
known = d.Known,
|
||||
drive = d.Drive,
|
||||
freeGB = Math.Round(d.FreeGB, 1),
|
||||
alertEnabled = d.AlertEnabled,
|
||||
alertGB = d.AlertGB,
|
||||
belowThreshold = d.BelowThreshold
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────── helpers ──
|
||||
|
||||
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
|
||||
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 Simple3 工作目录下的 <c>log</c>。</summary>
|
||||
private (string? root, string? workdir, string? error) ResolveLogRoot()
|
||||
{
|
||||
var overrideRoot = _config["Logs:Root"];
|
||||
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
|
||||
var wd = _launcher.ResolveWorkingDirectory();
|
||||
if (string.IsNullOrWhiteSpace(wd))
|
||||
return (null, null,
|
||||
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory," +
|
||||
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory," +
|
||||
"或显式设置 Logs:Root 指向日志根目录。");
|
||||
|
||||
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// 对齐 Simple3 LogCleaner:定时删除 log/ 下过期 *.log,并检测日志盘剩余空间(边沿告警,写日志不弹窗)。
|
||||
/// </summary>
|
||||
public sealed class LogCleanupService : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
private readonly Simple3Launcher _launcher;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<LogCleanupService> _log;
|
||||
|
||||
private readonly object _cleanLock = new();
|
||||
private readonly object _scheduleLock = new();
|
||||
private Timer? _cleanTimer;
|
||||
private Timer? _diskTimer;
|
||||
private bool _diskBelow;
|
||||
|
||||
public LogCleanupService(
|
||||
ConfigStore store,
|
||||
Simple3Launcher launcher,
|
||||
IConfiguration config,
|
||||
ILogger<LogCleanupService> log)
|
||||
{
|
||||
_store = store;
|
||||
_launcher = launcher;
|
||||
_config = config;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var opt = _store.GetLogCleanup();
|
||||
if (opt.Enabled && opt.RunOnStartup)
|
||||
RunCleanSafely(opt, "启动清理");
|
||||
ApplySchedule();
|
||||
CheckDisk(opt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "LogCleanupService 启动失败");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_scheduleLock)
|
||||
{
|
||||
_cleanTimer?.Dispose();
|
||||
_cleanTimer = null;
|
||||
_diskTimer?.Dispose();
|
||||
_diskTimer = null;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanTimer?.Dispose();
|
||||
_diskTimer?.Dispose();
|
||||
}
|
||||
|
||||
public LogCleanupOptions GetOptions() => _store.GetLogCleanup();
|
||||
|
||||
public LogCleanupOptions SaveOptions(LogCleanupOptions options)
|
||||
{
|
||||
var saved = _store.PutLogCleanup(options);
|
||||
_ = saved;
|
||||
var opt = _store.GetLogCleanup();
|
||||
ApplySchedule();
|
||||
CheckDisk(opt, forceReeval: true);
|
||||
return opt;
|
||||
}
|
||||
|
||||
public void ApplySchedule()
|
||||
{
|
||||
var opt = _store.GetLogCleanup();
|
||||
lock (_scheduleLock)
|
||||
{
|
||||
_cleanTimer?.Dispose();
|
||||
_cleanTimer = null;
|
||||
if (opt.Enabled)
|
||||
{
|
||||
var hours = opt.CheckIntervalHours <= 0 ? 1 : opt.CheckIntervalHours;
|
||||
var period = TimeSpan.FromHours(hours);
|
||||
_cleanTimer = new Timer(_ => RunCleanSafely(_store.GetLogCleanup(), "定时清理"),
|
||||
null, period, period);
|
||||
}
|
||||
|
||||
_diskTimer?.Dispose();
|
||||
var mins = opt.DiskCheckIntervalMinutes <= 0 ? 30 : opt.DiskCheckIntervalMinutes;
|
||||
var diskPeriod = TimeSpan.FromMinutes(mins);
|
||||
_diskTimer = new Timer(_ => CheckDisk(_store.GetLogCleanup()),
|
||||
null, diskPeriod, diskPeriod);
|
||||
}
|
||||
}
|
||||
|
||||
public CleanResult CleanNow()
|
||||
{
|
||||
var opt = _store.GetLogCleanup();
|
||||
var r = CleanOnce(opt);
|
||||
if (r.Error != null)
|
||||
_log.LogWarning("立即清理失败:{Error}", r.Error);
|
||||
else if (r.DeletedFiles > 0)
|
||||
_log.LogInformation("立即清理删除 {Count} 个日志文件,释放 {Mb:F1} MB", r.DeletedFiles, r.FreedMB);
|
||||
else
|
||||
_log.LogInformation("立即清理完成:没有过期日志");
|
||||
return r;
|
||||
}
|
||||
|
||||
public DiskStatus GetDiskStatus()
|
||||
{
|
||||
var opt = _store.GetLogCleanup();
|
||||
var known = TryGetFreeGB(out var freeGB, out var drive);
|
||||
var below = known && opt.DiskAlertEnabled && freeGB < opt.DiskFreeAlertGB;
|
||||
return new DiskStatus(known, drive, freeGB, opt.DiskAlertEnabled, opt.DiskFreeAlertGB, below);
|
||||
}
|
||||
|
||||
private void RunCleanSafely(LogCleanupOptions opt, string reason)
|
||||
{
|
||||
if (!opt.Enabled && reason != "立即清理") return;
|
||||
var r = CleanOnce(opt);
|
||||
if (r.Error != null)
|
||||
_log.LogWarning("[{Reason}] 失败:{Error}", reason, r.Error);
|
||||
else if (r.DeletedFiles > 0)
|
||||
_log.LogInformation("[{Reason}] 删除 {Count} 个日志文件,释放 {Mb:F1} MB", reason, r.DeletedFiles, r.FreedMB);
|
||||
}
|
||||
|
||||
private CleanResult CleanOnce(LogCleanupOptions opt)
|
||||
{
|
||||
opt.Clamp();
|
||||
lock (_cleanLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (root, _, error) = ResolveLogRoot();
|
||||
if (root == null)
|
||||
return new CleanResult(0, 0, error ?? "无法定位日志目录");
|
||||
if (!Directory.Exists(root))
|
||||
return new CleanResult(0, 0, null);
|
||||
|
||||
var cutoff = DateTime.Now.AddDays(-opt.RetentionDays);
|
||||
var deleted = 0;
|
||||
long freed = 0;
|
||||
foreach (var f in Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new FileInfo(f);
|
||||
if (fi.LastWriteTime >= cutoff) continue;
|
||||
var len = fi.Length;
|
||||
fi.Delete();
|
||||
deleted++;
|
||||
freed += len;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* 占用/权限:跳过 */
|
||||
}
|
||||
}
|
||||
TryRemoveEmptyDirs(root);
|
||||
return new CleanResult(deleted, freed, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CleanResult(0, 0, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryRemoveEmptyDirs(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
|
||||
.OrderByDescending(d => d.Length))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(dir).Any())
|
||||
Directory.Delete(dir, false);
|
||||
}
|
||||
catch { /* 忽略单目录失败 */ }
|
||||
}
|
||||
}
|
||||
catch { /* 忽略枚举失败 */ }
|
||||
}
|
||||
|
||||
private void CheckDisk(LogCleanupOptions opt, bool forceReeval = false)
|
||||
{
|
||||
if (forceReeval) _diskBelow = false;
|
||||
if (!opt.DiskAlertEnabled)
|
||||
{
|
||||
_diskBelow = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetFreeGB(out var freeGB, out var drive))
|
||||
return;
|
||||
|
||||
if (freeGB < opt.DiskFreeAlertGB)
|
||||
{
|
||||
if (_diskBelow) return;
|
||||
_diskBelow = true;
|
||||
_log.LogWarning(
|
||||
"磁盘剩余空间不足:{Drive} 仅剩 {Free:F1} GB(低于阈值 {Threshold:F0} GB)。请及时清理磁盘,或在系统配置中调低日志保留天数 / 立即清理。",
|
||||
drive, freeGB, opt.DiskFreeAlertGB);
|
||||
}
|
||||
else
|
||||
{
|
||||
_diskBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetFreeGB(out double freeGB, out string driveName)
|
||||
{
|
||||
freeGB = 0;
|
||||
driveName = "";
|
||||
var (root, _, _) = ResolveLogRoot();
|
||||
if (root == null) return false;
|
||||
try
|
||||
{
|
||||
var driveRoot = Path.GetPathRoot(root);
|
||||
if (string.IsNullOrEmpty(driveRoot)) return false;
|
||||
var di = new DriveInfo(driveRoot);
|
||||
driveName = di.Name;
|
||||
freeGB = di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private (string? root, string? workdir, string? error) ResolveLogRoot()
|
||||
{
|
||||
var overrideRoot = _config["Logs:Root"];
|
||||
if (!string.IsNullOrWhiteSpace(overrideRoot))
|
||||
{
|
||||
var r = Path.GetFullPath(overrideRoot);
|
||||
return (r, Path.GetDirectoryName(r), null);
|
||||
}
|
||||
|
||||
var wd = _launcher.ResolveWorkingDirectory();
|
||||
if (string.IsNullOrWhiteSpace(wd))
|
||||
return (null, null, "未能定位 Simple3 工作目录");
|
||||
|
||||
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
|
||||
}
|
||||
|
||||
public readonly record struct CleanResult(int DeletedFiles, long FreedBytes, string? Error)
|
||||
{
|
||||
public double FreedMB => FreedBytes / 1024.0 / 1024.0;
|
||||
}
|
||||
|
||||
public readonly record struct DiskStatus(
|
||||
bool Known,
|
||||
string Drive,
|
||||
double FreeGB,
|
||||
bool AlertEnabled,
|
||||
double AlertGB,
|
||||
bool BelowThreshold);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"section": "system",
|
||||
"version": 1,
|
||||
"updatedAt": "2026-06-08T09:43:37.4470202+00:00",
|
||||
"version": 2,
|
||||
"updatedAt": "2026-08-21T08:06:13.7109039+00:00",
|
||||
"payload": {
|
||||
"dispatchLoopHz": 50,
|
||||
"log": {
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"security": {
|
||||
"jwtExpireMin": 1440,
|
||||
"enableSwagger": false,
|
||||
"enableSwagger": true,
|
||||
"corsWhitelist": [
|
||||
"http://localhost:5173"
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import http from './http'
|
||||
/**
|
||||
* 「日志管理」前端胶水:对应 MiGu.Server `LogsController`(`/api/logs/*`)。
|
||||
*
|
||||
* 数据是 SimpleLite 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
|
||||
* 数据是 Simple3 内核 `Diagnosis.Post / Diagnosis.Log` 写到工作目录 `log/{日期}/xxx.log` 的落盘日志
|
||||
* (俗称 DLog)。后端直接读文件并解析为结构化条目,提供:概览 / 文件列表 / 条目分页 /
|
||||
* 「按标签合订」/ 原文 / 下载。
|
||||
*
|
||||
@@ -14,10 +14,10 @@ import http from './http'
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
|
||||
/** SimpleLite projection 投影 API 统一信封(同 reflection.ts)。 */
|
||||
/** Simple3 projection 投影 API 统一信封(同 reflection.ts)。 */
|
||||
interface SlEnvelope<T> { success: boolean; code: number; data: T | null; message: string }
|
||||
|
||||
/** 经 YARP 反代到 SimpleLite EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
|
||||
/** 经 YARP 反代到 Simple3 EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
|
||||
const SL_DIAG = '/sl/projection/diagnosis'
|
||||
|
||||
export interface LogEntry {
|
||||
@@ -47,6 +47,31 @@ export interface LogDayStat {
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface LogDiskStatus {
|
||||
known: boolean
|
||||
drive: string
|
||||
freeGB: number
|
||||
alertEnabled: boolean
|
||||
alertGB: number
|
||||
belowThreshold: boolean
|
||||
}
|
||||
|
||||
export interface LogCleanupConfig {
|
||||
enabled: boolean
|
||||
retentionDays: number
|
||||
checkIntervalHours: number
|
||||
runOnStartup: boolean
|
||||
diskAlertEnabled: boolean
|
||||
diskFreeAlertGB: number
|
||||
diskCheckIntervalMinutes: number
|
||||
}
|
||||
|
||||
export interface LogCleanupResult {
|
||||
deletedFiles: number
|
||||
freedBytes: number
|
||||
freedMB: number
|
||||
}
|
||||
|
||||
export interface LogOverview {
|
||||
exists: boolean
|
||||
root: string | null
|
||||
@@ -56,6 +81,7 @@ export interface LogOverview {
|
||||
latestFileTime?: string | null
|
||||
days?: LogDayStat[]
|
||||
message?: string
|
||||
disk?: LogDiskStatus
|
||||
}
|
||||
|
||||
export interface LogFilesResult {
|
||||
@@ -116,7 +142,7 @@ export interface DigestQuery {
|
||||
maxPerTag?: number
|
||||
}
|
||||
|
||||
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
|
||||
/** 实时诊断单条:对应 Simple3 内核 Diagnosis 的内存态 Post/Toast。 */
|
||||
export interface LiveDiagItem {
|
||||
index: number
|
||||
time: string
|
||||
@@ -225,12 +251,25 @@ export interface AnalyzeQuery {
|
||||
function mockOverview(): LogOverview {
|
||||
return {
|
||||
exists: true,
|
||||
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
|
||||
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
|
||||
root: 'E:\\...\\Simple3\\bin\\Debug\\log',
|
||||
workingDirectory: 'E:\\...\\Simple3\\bin\\Debug',
|
||||
totalFiles: 3,
|
||||
totalBytes: 5_233_649,
|
||||
latestFileTime: new Date().toISOString(),
|
||||
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }]
|
||||
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }],
|
||||
disk: { known: true, drive: 'E:\\', freeGB: 118.7, alertEnabled: true, alertGB: 5, belowThreshold: false }
|
||||
}
|
||||
}
|
||||
|
||||
function mockCleanupConfig(): LogCleanupConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
retentionDays: 30,
|
||||
checkIntervalHours: 24,
|
||||
runOnStartup: true,
|
||||
diskAlertEnabled: true,
|
||||
diskFreeAlertGB: 5,
|
||||
diskCheckIntervalMinutes: 30
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +324,7 @@ function mockLiveDiagnosis(): LiveDiagnosis {
|
||||
{ index: 0, time: iso(2), tag: 'Persistence', tagged: true, content: 'flush 4 entities ok' },
|
||||
{ index: 1, time: iso(4), tag: 'Dispatch', tagged: true, content: 'dispatch loop 50Hz online' },
|
||||
{ index: 2, time: iso(6), tag: 'InternalAuth', tagged: true, content: '仅放行本机回环(无 internal token 配置)' },
|
||||
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[SimpleLite] Projection API listening on http://127.0.0.1:8222/projection/' },
|
||||
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[Simple3] Projection API listening on http://127.0.0.1:8222/projection/' },
|
||||
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
|
||||
]
|
||||
const taggedCount = items.filter((i) => i.tagged).length
|
||||
@@ -373,7 +412,7 @@ export const logsApi = {
|
||||
? Promise.resolve(mockDigest())
|
||||
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
|
||||
|
||||
/** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */
|
||||
/** 实时内存诊断(Simple3 Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 Simple3 在运行,否则 502。 */
|
||||
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
|
||||
? Promise.resolve(mockLiveDiagnosis())
|
||||
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
|
||||
@@ -398,5 +437,17 @@ export const logsApi = {
|
||||
|
||||
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
|
||||
downloadUrl: (file: string): string =>
|
||||
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`
|
||||
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`,
|
||||
|
||||
cleanupConfig: (): Promise<LogCleanupConfig> => MOCK
|
||||
? Promise.resolve(mockCleanupConfig())
|
||||
: http.get<LogCleanupConfig>('/logs/cleanup-config').then((r) => r.data),
|
||||
|
||||
saveCleanupConfig: (body: LogCleanupConfig): Promise<LogCleanupConfig> => MOCK
|
||||
? Promise.resolve({ ...body })
|
||||
: http.put<LogCleanupConfig>('/logs/cleanup-config', body).then((r) => r.data),
|
||||
|
||||
cleanupNow: (): Promise<LogCleanupResult> => MOCK
|
||||
? Promise.resolve({ deletedFiles: 0, freedBytes: 0, freedMB: 0 })
|
||||
: http.post<LogCleanupResult>('/logs/cleanup-now').then((r) => r.data)
|
||||
}
|
||||
|
||||
@@ -2,22 +2,7 @@
|
||||
* 配置中心 13+1 维度强类型(与 Platform.Server/Configs 一一对齐,字段命名追随 ARCHITECTURE.md §9)。
|
||||
*/
|
||||
|
||||
export interface LogPolicy {
|
||||
level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
|
||||
rollDays: number
|
||||
maxSizeMB: number
|
||||
}
|
||||
|
||||
export interface SecurityPolicy {
|
||||
jwtExpireMin: number
|
||||
enableSwagger: boolean
|
||||
corsWhitelist: string[]
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
dispatchLoopHz: number
|
||||
log: LogPolicy
|
||||
security: SecurityPolicy
|
||||
}
|
||||
|
||||
export interface MesEndpoint { id: string; name: string; url: string; enabled: boolean }
|
||||
|
||||
@@ -9,17 +9,32 @@
|
||||
<span>日志管理</span>
|
||||
<el-tag size="small" effect="plain" type="info">Diagnosis · Post / Toast / DLog</el-tag>
|
||||
</div>
|
||||
<el-button size="small" :icon="Refresh" :loading="overviewLoading" @click="refreshAll">刷新</el-button>
|
||||
<div class="ov-actions">
|
||||
<el-button v-if="canOpenCleanup" size="small" :icon="Setting" @click="goCleanup">日志清理配置</el-button>
|
||||
<el-button size="small" :loading="cleaning" @click="cleanNow">立即清理</el-button>
|
||||
<el-button size="small" :icon="Refresh" :loading="overviewLoading" @click="refreshAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="ov-desc">
|
||||
<b>实时诊断</b>直连 SimpleLite 内核 <code>Diagnosis</code> 的内存态 <code>Post</code> / <code>Toast</code>
|
||||
<b>实时诊断</b>直连 Simple3 内核 <code>Diagnosis</code> 的内存态 <code>Post</code> / <code>Toast</code>
|
||||
(有标签按标签合订、无标签滚动记录);<b>日志文件</b>浏览工作目录 <code>log/</code> 下的落盘日志(DLog)。
|
||||
</p>
|
||||
<el-alert
|
||||
v-if="overview?.disk?.belowThreshold"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="diskAlertTitle"
|
||||
style="margin: 0 0 12px" />
|
||||
<div v-if="overview" class="ov-stats">
|
||||
<div class="ov-stat"><span class="k">日志根目录</span><span class="v path" :title="overview.root ?? ''">{{ overview.root ?? '—' }}</span></div>
|
||||
<div class="ov-stat"><span class="k">文件数</span><span class="v">{{ overview.totalFiles ?? 0 }}</span></div>
|
||||
<div class="ov-stat"><span class="k">总大小</span><span class="v">{{ formatBytes(overview.totalBytes ?? 0) }}</span></div>
|
||||
<div class="ov-stat"><span class="k">最近写入</span><span class="v">{{ overview.latestFileTime ? formatTime(overview.latestFileTime) : '—' }}</span></div>
|
||||
<div class="ov-stat">
|
||||
<span class="k">日志所在盘剩余</span>
|
||||
<span class="v" :class="{ warn: overview.disk?.belowThreshold }">{{ diskText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -74,7 +89,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="muted">{{ liveError ? 'SimpleLite 未连接' : '暂无诊断消息' }}</span>
|
||||
<span class="muted">{{ liveError ? 'Simple3 未连接' : '暂无诊断消息' }}</span>
|
||||
</template>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
@@ -303,10 +318,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Refresh, Document, Search, View, Download, CopyDocument,
|
||||
Folder, FolderOpened, Back, DataLine, TrendCharts
|
||||
Folder, FolderOpened, Back, DataLine, TrendCharts, Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
// 按需引入 echarts:仅 bar/line 图 + grid/tooltip/legend/dataZoom 组件 + canvas 渲染器,
|
||||
// 避免全量 echarts(~1MB) 打进 bundle。
|
||||
@@ -315,6 +331,7 @@ import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import PermissionGuard from '@/components/PermissionGuard.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
logsApi,
|
||||
type LogOverview, type LiveDiagnosis, type LiveDiagItem,
|
||||
@@ -341,6 +358,46 @@ const activeTab = ref<Tab>('live')
|
||||
|
||||
const overview = ref<LogOverview | null>(null)
|
||||
const overviewLoading = ref(false)
|
||||
const cleaning = ref(false)
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canOpenCleanup = computed(() => auth.hasPage('admin-config-system'))
|
||||
const diskText = computed(() => {
|
||||
const d = overview.value?.disk
|
||||
if (!d?.known) return '未知'
|
||||
const th = d.alertEnabled ? `(阈值 ${d.alertGB}G)` : ''
|
||||
const warn = d.belowThreshold ? ' 低于阈值' : ''
|
||||
return `${d.drive} 剩余 ${d.freeGB.toFixed(1)} GB${th}${warn}`
|
||||
})
|
||||
const diskAlertTitle = computed(() => {
|
||||
const d = overview.value?.disk
|
||||
if (!d?.known) return ''
|
||||
return `磁盘剩余空间不足:${d.drive} 仅剩 ${d.freeGB.toFixed(1)} GB(低于阈值 ${d.alertGB} GB)`
|
||||
})
|
||||
function goCleanup() {
|
||||
void router.push({ name: 'admin-config-system', query: { tab: 'logs' } })
|
||||
}
|
||||
async function cleanNow() {
|
||||
try {
|
||||
await ElMessageBox.confirm('将按当前保留天数删除过期日志文件,是否继续?', '立即清理', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '清理',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
cleaning.value = true
|
||||
try {
|
||||
const r = await logsApi.cleanupNow()
|
||||
ElMessage.success(`已清理 ${r.deletedFiles} 个文件,释放 ${r.freedMB} MB`)
|
||||
await loadOverview()
|
||||
} catch (e) {
|
||||
ElMessage.error(`立即清理失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
cleaning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 实时诊断 ──
|
||||
const live = ref<LiveDiagnosis | null>(null)
|
||||
@@ -740,17 +797,20 @@ onUnmounted(() => {
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
.ov-card :deep(.el-card__body) { padding: 16px 18px; }
|
||||
.ov-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ov-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.ov-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.ov-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; color: var(--mg-text-light); }
|
||||
.ov-desc { color: var(--mg-text-muted); font-size: 12.5px; line-height: 1.6; margin: 10px 0 12px; }
|
||||
.ov-desc code { font-family: var(--mg-font-mono, monospace); color: var(--mg-accent); }
|
||||
.ov-stats { display: grid; grid-template-columns: 2.2fr 0.8fr 0.8fr 1.4fr; gap: 12px; }
|
||||
.ov-stats { display: grid; grid-template-columns: 1.8fr 0.6fr 0.7fr 1.1fr 1.6fr; gap: 12px; }
|
||||
.ov-stat { display: flex; flex-direction: column; gap: 3px; }
|
||||
.ov-stat .k { font-size: 11px; color: var(--mg-text-muted); }
|
||||
.ov-stat .v { font-size: 13px; color: var(--mg-text-light); font-variant-numeric: tabular-nums; }
|
||||
.ov-stat .v.path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--mg-font-mono, monospace); }
|
||||
.ov-stat .v.warn { color: var(--el-color-danger); }
|
||||
|
||||
.body-card { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.body-card :deep(.el-card__body) { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
<ConfigPageBase
|
||||
section="ops"
|
||||
title="运营维护"
|
||||
description="调度回放、日志管理、版本维护(OpsConfig)"
|
||||
description="回放保留、日志、版本,以及地图监控里显示哪些动作。页面权限不在这里配。"
|
||||
:defaults="DEFAULT_OPS"
|
||||
:normalize-payload="normalizeOpsConfig"
|
||||
:after-load="onOpsConfigLoaded"
|
||||
:before-save="onBeforePlatformSave">
|
||||
<template #default="{ payload, update }">
|
||||
<el-form label-width="200px" :model="payload">
|
||||
<el-alert class="acl-hint" type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
本页只配回放、日志和地图监控动作显示。谁能打开哪些页面,到
|
||||
<router-link class="acl-link" to="/admin/config/auth">权限与角色</router-link>
|
||||
给角色勾选。
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-divider content-position="left">调度回放</el-divider>
|
||||
<el-form-item label="保留时长 (天)">
|
||||
<el-input-number :model-value="payload.playback.retentionDays" :min="1" :max="3650" @update:model-value="(v: number | undefined) => update({ ...payload, playback: { ...payload.playback, retentionDays: v ?? 0 } })" />
|
||||
@@ -59,7 +66,7 @@
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<p v-if="!monitorSectionLoading && !carTypesLoading && !carTypeActions.length" class="muted empty-hint">
|
||||
暂未扫描到带 MethodMember 的车型(请确认 SimpleLite 已连接并已加载插件)。
|
||||
暂未扫描到带 MethodMember 的车型(请确认 Simple3 已连接并已加载插件)。
|
||||
</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="站点动作显示项">
|
||||
@@ -329,4 +336,11 @@ function updateCarTypeActions(
|
||||
.muted {
|
||||
color: var(--mg-text-muted);
|
||||
}
|
||||
.acl-hint { margin-bottom: 14px; }
|
||||
.acl-link {
|
||||
color: var(--mg-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.acl-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,144 @@
|
||||
<template>
|
||||
<ConfigPageBase
|
||||
section="system"
|
||||
title="系统级配置"
|
||||
description="运行参数、日志策略、安全策略(对齐 SystemConfig)"
|
||||
:defaults="DEFAULT_SYSTEM">
|
||||
<template #default="{ payload, update }">
|
||||
<el-form label-width="160px" :model="payload">
|
||||
<el-form-item label="调度循环 (Hz)">
|
||||
<el-input-number :model-value="payload.dispatchLoopHz" :min="1" :max="200" @update:model-value="(v: number | undefined) => update({ ...payload, dispatchLoopHz: v ?? 0 })" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">日志策略</el-divider>
|
||||
<el-form-item label="日志级别">
|
||||
<el-select :model-value="payload.log.level" @update:model-value="(v: any) => update({ ...payload, log: { ...payload.log, level: v } })">
|
||||
<el-option v-for="lv in ['trace','debug','info','warn','error']" :key="lv" :label="lv" :value="lv" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日志保留 (天)">
|
||||
<el-input-number :model-value="payload.log.rollDays" :min="1" :max="365" @update:model-value="(v: number | undefined) => update({ ...payload, log: { ...payload.log, rollDays: v ?? 0 } })" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单文件上限 (MB)">
|
||||
<el-input-number :model-value="payload.log.maxSizeMB" :min="1" :max="4096" @update:model-value="(v: number | undefined) => update({ ...payload, log: { ...payload.log, maxSizeMB: v ?? 0 } })" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">安全策略</el-divider>
|
||||
<el-form-item label="JWT 过期 (分钟)">
|
||||
<el-input-number :model-value="payload.security.jwtExpireMin" :min="5" :max="43200" @update:model-value="(v: number | undefined) => update({ ...payload, security: { ...payload.security, jwtExpireMin: v ?? 0 } })" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用 Swagger">
|
||||
<el-switch :model-value="payload.security.enableSwagger" @update:model-value="(v: string | number | boolean) => update({ ...payload, security: { ...payload.security, enableSwagger: Boolean(v) } })" />
|
||||
</el-form-item>
|
||||
<el-form-item label="CORS 白名单">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:model-value="payload.security.corsWhitelist.join('\n')"
|
||||
@update:model-value="(v: string) => update({ ...payload, security: { ...payload.security, corsWhitelist: v.split(/\s+/).filter(Boolean) } })" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">界面主题配色</el-divider>
|
||||
<el-form-item label="主题取色盘">
|
||||
<ThemeCustomizer />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</ConfigPageBase>
|
||||
<PermissionGuard widget-id="ConfigCenter">
|
||||
<div class="sys-page">
|
||||
<el-card shadow="never" class="sys-card">
|
||||
<template #header>
|
||||
<div class="sys-header">
|
||||
<div class="sys-title-wrap">
|
||||
<span class="sys-title">系统配置</span>
|
||||
<span class="sys-desc">{{ headerDesc }}</span>
|
||||
</div>
|
||||
<div class="sys-actions">
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-card class="ai-service-card" shadow="never">
|
||||
<template #header>
|
||||
<span>AI 服务(地图编辑器「AI 生图」依赖)</span>
|
||||
<el-tag v-if="aiCfg.configured" type="success" size="small" effect="dark" class="ml-8">已配置</el-tag>
|
||||
<el-tag v-else type="warning" size="small" effect="dark" class="ml-8">未配置</el-tag>
|
||||
</template>
|
||||
<el-form label-width="140px" size="default">
|
||||
<el-form-item label="Endpoint">
|
||||
<el-input v-model="aiCfg.endpoint" placeholder="https://api.openai.com/v1/chat/completions" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="aiCfg.apiKey" type="password" show-password placeholder="sk-..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型">
|
||||
<el-input v-model="aiCfg.model" placeholder="gpt-4o-mini" />
|
||||
</el-form-item>
|
||||
<el-form-item label="System Prompt">
|
||||
<el-input v-model="aiCfg.systemPrompt" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Temperature">
|
||||
<el-input-number v-model="aiCfg.temperature" :min="0" :max="2" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Max Tokens">
|
||||
<el-input-number v-model="aiCfg.maxTokens" :min="64" :max="32000" :step="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="超时 (秒)">
|
||||
<el-input-number v-model="aiCfg.timeoutSec" :min="5" :max="600" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="aiSaving" @click="onSaveAi">保存 AI 配置</el-button>
|
||||
<el-button :loading="aiTesting" @click="onTestAi">测试连通</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-tabs v-model="group" class="sys-tabs">
|
||||
<el-tab-pane label="界面主题" name="theme">
|
||||
<p class="tab-hint">为各主题单独调色,修改后立即预览。当前主题会同步到全站。</p>
|
||||
<ThemeCustomizer />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="ai">
|
||||
<template #label>
|
||||
<span>AI 服务</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="aiCfg.configured ? 'success' : 'warning'"
|
||||
effect="plain"
|
||||
class="ai-tab-tag">
|
||||
{{ aiCfg.configured ? '已配置' : '未配置' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<p class="tab-hint">地图编辑器「AI 生图」所依赖的对话接口。</p>
|
||||
<div class="sys-fields">
|
||||
<label class="sys-field sys-field--wide">
|
||||
<span>Endpoint</span>
|
||||
<el-input v-model="aiCfg.endpoint" placeholder="https://api.openai.com/v1/chat/completions" />
|
||||
</label>
|
||||
<label class="sys-field sys-field--wide">
|
||||
<span>API Key</span>
|
||||
<el-input v-model="aiCfg.apiKey" type="password" show-password placeholder="sk-..." />
|
||||
</label>
|
||||
<label class="sys-field sys-field--wide">
|
||||
<span>模型</span>
|
||||
<el-input v-model="aiCfg.model" placeholder="gpt-4o-mini" />
|
||||
</label>
|
||||
<label class="sys-field sys-field--wide">
|
||||
<span>System Prompt</span>
|
||||
<el-input v-model="aiCfg.systemPrompt" type="textarea" :rows="4" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>Temperature</span>
|
||||
<el-input-number v-model="aiCfg.temperature" :min="0" :max="2" :step="0.1" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>Max Tokens</span>
|
||||
<el-input-number v-model="aiCfg.maxTokens" :min="64" :max="32000" :step="64" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>超时 (秒)</span>
|
||||
<el-input-number v-model="aiCfg.timeoutSec" :min="5" :max="600" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="tab-toolbar">
|
||||
<el-button :loading="aiTesting" @click="onTestAi">测试连通</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="日志清理" name="logs">
|
||||
<p class="tab-hint">清理 Simple3 工作目录 log/ 下过期的 *.log,并监测日志所在盘剩余空间。保存后立即生效。</p>
|
||||
<div class="sys-fields">
|
||||
<label class="sys-field">
|
||||
<span>启用自动清理</span>
|
||||
<el-switch v-model="logCfg.enabled" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>启动时先清一次</span>
|
||||
<el-switch v-model="logCfg.runOnStartup" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>保留天数</span>
|
||||
<el-input-number v-model="logCfg.retentionDays" :min="1" :max="3650" :step="1" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>清理间隔(小时)</span>
|
||||
<el-input-number v-model="logCfg.checkIntervalHours" :min="1" :max="168" :step="1" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>启用磁盘告警</span>
|
||||
<el-switch v-model="logCfg.diskAlertEnabled" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>告警阈值(GB)</span>
|
||||
<el-input-number v-model="logCfg.diskFreeAlertGB" :min="0.5" :max="1024" :step="0.5" />
|
||||
</label>
|
||||
<label class="sys-field">
|
||||
<span>磁盘检测间隔(分钟)</span>
|
||||
<el-input-number v-model="logCfg.diskCheckIntervalMinutes" :min="1" :max="1440" :step="1" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="tab-toolbar">
|
||||
<el-button :loading="logCleaning" @click="onCleanNow">立即清理</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConfigPageBase from '@/components/ConfigPageBase.vue'
|
||||
import PermissionGuard from '@/components/PermissionGuard.vue'
|
||||
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
|
||||
import { DEFAULT_SYSTEM } from '@/mock/data/configs'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { aiConfigApi, mapEditApi, type AiConfig } from '@/api/mapEdit'
|
||||
import { logsApi, type LogCleanupConfig } from '@/api/logs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
type GroupId = 'theme' | 'ai' | 'logs'
|
||||
const TAB_NAMES: GroupId[] = ['theme', 'ai', 'logs']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function readGroup(): GroupId {
|
||||
const q = route.query.tab
|
||||
if (q === 'log') return 'logs'
|
||||
return typeof q === 'string' && (TAB_NAMES as string[]).includes(q) ? (q as GroupId) : 'theme'
|
||||
}
|
||||
|
||||
const group = ref<GroupId>(readGroup())
|
||||
const headerDesc = computed(() => {
|
||||
if (group.value === 'ai') return 'AI 服务需点保存后写入。'
|
||||
if (group.value === 'logs') return '日志清理保存后立即重排后台任务。'
|
||||
return '界面主题立即生效。'
|
||||
})
|
||||
const saving = computed(() => aiSaving.value || logSaving.value)
|
||||
|
||||
const aiCfg = reactive<AiConfig>({
|
||||
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: '',
|
||||
@@ -98,31 +150,82 @@ const aiCfg = reactive<AiConfig>({
|
||||
configured: false
|
||||
})
|
||||
|
||||
const logCfg = reactive<LogCleanupConfig>({
|
||||
enabled: true,
|
||||
retentionDays: 30,
|
||||
checkIntervalHours: 24,
|
||||
runOnStartup: true,
|
||||
diskAlertEnabled: true,
|
||||
diskFreeAlertGB: 5,
|
||||
diskCheckIntervalMinutes: 30
|
||||
})
|
||||
|
||||
const aiSaving = ref(false)
|
||||
const aiTesting = ref(false)
|
||||
const logSaving = ref(false)
|
||||
const logCleaning = ref(false)
|
||||
|
||||
watch(group, (t) => {
|
||||
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
|
||||
})
|
||||
watch(() => route.query.tab, () => { group.value = readGroup() })
|
||||
|
||||
onMounted(async () => {
|
||||
group.value = readGroup()
|
||||
await Promise.all([loadAi(), loadLogs()])
|
||||
})
|
||||
|
||||
async function loadAi() {
|
||||
try {
|
||||
const r = await aiConfigApi.get()
|
||||
Object.assign(aiCfg, r)
|
||||
} catch (err) {
|
||||
ElMessage.warning(`无法读取 AI 配置:${(err as Error).message}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onSaveAi() {
|
||||
async function loadLogs() {
|
||||
try {
|
||||
aiSaving.value = true
|
||||
await aiConfigApi.save({ ...aiCfg })
|
||||
ElMessage.success('AI 配置已保存')
|
||||
aiCfg.configured = !!aiCfg.apiKey
|
||||
Object.assign(logCfg, await logsApi.cleanupConfig())
|
||||
} catch (err) {
|
||||
ElMessage.error(`保存失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
aiSaving.value = false
|
||||
ElMessage.warning(`无法读取日志清理配置:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (group.value === 'theme') {
|
||||
ElMessage.info('主题已即时生效,无需保存')
|
||||
return
|
||||
}
|
||||
if (group.value === 'ai') {
|
||||
try {
|
||||
aiSaving.value = true
|
||||
await aiConfigApi.save({ ...aiCfg })
|
||||
aiCfg.configured = !!aiCfg.apiKey
|
||||
ElMessage.success('已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error(`AI 配置保存失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
aiSaving.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
logSaving.value = true
|
||||
Object.assign(logCfg, await logsApi.saveCleanupConfig({ ...logCfg }))
|
||||
ElMessage.success('日志清理配置已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error(`日志清理配置保存失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
logSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReset() {
|
||||
if (group.value === 'logs') await loadLogs()
|
||||
else await loadAi()
|
||||
}
|
||||
|
||||
async function onTestAi() {
|
||||
try {
|
||||
aiTesting.value = true
|
||||
@@ -138,9 +241,92 @@ async function onTestAi() {
|
||||
aiTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCleanNow() {
|
||||
try {
|
||||
logCleaning.value = true
|
||||
const r = await logsApi.cleanupNow()
|
||||
ElMessage.success(`已清理 ${r.deletedFiles} 个文件,释放 ${r.freedMB} MB`)
|
||||
} catch (err) {
|
||||
ElMessage.error(`立即清理失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
logCleaning.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-service-card { margin: 12px 0; }
|
||||
.ml-8 { margin-left: 8px; }
|
||||
.sys-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.sys-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.sys-title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.sys-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.sys-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--mg-text-muted);
|
||||
}
|
||||
.sys-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--mg-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tab-toolbar {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ai-tab-tag {
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sys-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 280px));
|
||||
gap: 16px 28px;
|
||||
}
|
||||
.sys-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
.sys-field > span {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--mg-text-muted);
|
||||
}
|
||||
.sys-field--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.sys-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sys-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user