From f4f4cd1d1bd85aae488f99b0b3a1c1f4ab7de9b6 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Sat, 25 Jul 2026 18:44:10 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E5=BF=BD=E7=95=A5=20OTA=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=95=B0=E6=8D=AE=E4=B8=8E=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=EF=BC=8C=E5=81=9C=E6=AD=A2=E8=B7=9F=E8=B8=AA?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=20db/json=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这些文件本就属于本机运行态,继续入库会把环境数据带进仓库。 Co-authored-by: Cursor --- .gitignore | 7 +++++++ MiGu.Server/data/config-deployment.json | 20 -------------------- MiGu.Server/data/platform.db | Bin 462848 -> 0 bytes 3 files changed, 7 insertions(+), 20 deletions(-) delete mode 100644 MiGu.Server/data/config-deployment.json delete mode 100644 MiGu.Server/data/platform.db diff --git a/.gitignore b/.gitignore index dcc3755..751bd87 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ MiGu.Server/data/*.db MiGu.Server/data/*.db-shm MiGu.Server/data/*.db-wal MiGu.Server/data/.internal-token +# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录,*.json 上面那行不递归) +MiGu.Server/data/ota/ # 前端构建产物 MiGu.Server/wwwroot/ @@ -32,3 +34,8 @@ Thumbs.db Desktop.ini /.cursor/rules /MiGu.Server/wwwroot +/MiGu.Server/data/platform.db + +# 临时脚本 / 工具产物(不应入库) +.codex-temp/ +frontends/apps/simple-platform-vue/imgui.ini diff --git a/MiGu.Server/data/config-deployment.json b/MiGu.Server/data/config-deployment.json deleted file mode 100644 index 51ffa1e..0000000 --- a/MiGu.Server/data/config-deployment.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "section": "deployment", - "version": 7, - "updatedAt": "2026-06-09T01:43:46.9419888+00:00", - "payload": { - "configured": true, - "platformType": "standard", - "modules": [ - "wms" - ], - "navigationKinds": [ - "qrcode", - "laser" - ], - "scenarios": [ - "tpl-p2p" - ], - "updatedBy": "admin" - } -} \ No newline at end of file diff --git a/MiGu.Server/data/platform.db b/MiGu.Server/data/platform.db deleted file mode 100644 index ad4309c895d49bdc893896477aa8da8ca124431d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462848 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

lOq?d7DFCNiA?Mz)ETddu^f{&lAwVPs(sAjo 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 Date: Sat, 25 Jul 2026 18:44:26 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20OTA=20WatchDog=20?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E4=B8=8E=E8=BD=A6=E9=98=9F=E5=81=A5=E5=BA=B7?= =?UTF-8?q?/=E6=8A=A5=E8=AD=A6=E5=90=8E=E7=AB=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor --- MiGu.Server/Auth/PageCatalog.cs | 2 + MiGu.Server/Controllers/FleetController.cs | 123 +++++ MiGu.Server/Controllers/OtaController.cs | 515 ++++++++++++++++++ .../Controllers/OtaReceiveController.cs | 108 ++++ MiGu.Server/Fleet/AlarmCollector.cs | 251 +++++++++ MiGu.Server/Fleet/CdmTaskRecord.cs | 33 ++ MiGu.Server/Fleet/CdmTaskSync.cs | 205 +++++++ MiGu.Server/Fleet/FleetHealthModels.cs | 21 + MiGu.Server/Fleet/FleetHealthService.cs | 169 ++++++ MiGu.Server/Fleet/VehicleAlarmRecord.cs | 29 + MiGu.Server/Ota/OtaHash.cs | 26 + MiGu.Server/Ota/OtaJobRunner.cs | 433 +++++++++++++++ MiGu.Server/Ota/OtaModels.cs | 144 +++++ MiGu.Server/Ota/OtaOptions.cs | 24 + MiGu.Server/Ota/OtaPathMap.cs | 53 ++ MiGu.Server/Ota/OtaStore.cs | 368 +++++++++++++ MiGu.Server/Ota/OtaVehicleSource.cs | 141 +++++ MiGu.Server/Ota/WatchDogClient.cs | 289 ++++++++++ MiGu.Server/Persistence/PlatformDbContext.cs | 63 +++ .../Persistence/PlatformPersistence.cs | 102 ++++ MiGu.Server/Program.cs | 64 ++- MiGu.Server/Properties/launchSettings.json | 2 +- MiGu.Server/appsettings.json | 18 + .../plans/2026-07-19-migu-ota-watchdog.md | 117 ++++ .../2026-07-19-migu-ota-watchdog-design.md | 258 +++++++++ 25 files changed, 3552 insertions(+), 6 deletions(-) create mode 100644 MiGu.Server/Controllers/FleetController.cs create mode 100644 MiGu.Server/Controllers/OtaController.cs create mode 100644 MiGu.Server/Controllers/OtaReceiveController.cs create mode 100644 MiGu.Server/Fleet/AlarmCollector.cs create mode 100644 MiGu.Server/Fleet/CdmTaskRecord.cs create mode 100644 MiGu.Server/Fleet/CdmTaskSync.cs create mode 100644 MiGu.Server/Fleet/FleetHealthModels.cs create mode 100644 MiGu.Server/Fleet/FleetHealthService.cs create mode 100644 MiGu.Server/Fleet/VehicleAlarmRecord.cs create mode 100644 MiGu.Server/Ota/OtaHash.cs create mode 100644 MiGu.Server/Ota/OtaJobRunner.cs create mode 100644 MiGu.Server/Ota/OtaModels.cs create mode 100644 MiGu.Server/Ota/OtaOptions.cs create mode 100644 MiGu.Server/Ota/OtaPathMap.cs create mode 100644 MiGu.Server/Ota/OtaStore.cs create mode 100644 MiGu.Server/Ota/OtaVehicleSource.cs create mode 100644 MiGu.Server/Ota/WatchDogClient.cs create mode 100644 docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md create mode 100644 docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index 41eaeb6..e37191f 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -30,6 +30,8 @@ public static class PageCatalog // ── 管理端 / Platform:概览 ── new("admin-dashboard", "总览", "概览", ScopePlatform), new("admin-map-monitor", "地图监控", "概览", ScopePlatform), + new("admin-tasks", "任务管理", "概览", ScopePlatform), + new("admin-alarms", "报警管理", "概览", ScopePlatform), // ── 管理端 / Platform:设计与编排 ── new("admin-maps", "地图管理", "设计与编排", ScopePlatform), diff --git a/MiGu.Server/Controllers/FleetController.cs b/MiGu.Server/Controllers/FleetController.cs new file mode 100644 index 0000000..cea75a9 --- /dev/null +++ b/MiGu.Server/Controllers/FleetController.cs @@ -0,0 +1,123 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MiGu.Server.Fleet; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Controllers; + +/// 车队运维健康探针(延迟走 WatchDog TCP)+ CDM 任务平台侧快照读取。 +[ApiController] +[Authorize] +[Route("api/fleet")] +public sealed class FleetController : ControllerBase +{ + private readonly FleetHealthService _health; + private readonly CdmTaskSyncer _cdmSyncer; + private readonly AlarmCollector _alarmCollector; + private readonly PlatformDbContext _db; + + public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, PlatformDbContext db) + { + _health = health; + _cdmSyncer = cdmSyncer; + _alarmCollector = alarmCollector; + _db = db; + } + + [HttpGet("health")] + public async Task>> Health(CancellationToken ct) + => Ok(await _health.GetAsync(ct)); + + /// + /// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新, + /// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。 + /// + [HttpGet("tasks")] + public async Task> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default) + { + await _cdmSyncer.SyncOnceAsync(ct); + + var take = Math.Clamp(limit, 1, 5000); + var tasks = await _db.CdmTasks.AsNoTracking() + .OrderByDescending(t => t.CreateTime) + .Take(take) + .Select(t => new + { + id = t.Id, + taskId = t.TaskId, + missionId = t.MissionId, + missionName = t.MissionName, + missionTypeName = t.MissionTypeName, + srcSiteId = t.SrcSiteId, + srcLabel = t.SrcLabel, + dstSiteId = t.DstSiteId, + dstLabel = t.DstLabel, + status = t.Status, + statusCode = t.StatusCode, + carId = t.CarId, + carName = t.CarName, + priority = t.Priority, + createTime = t.CreateTime, + startTime = t.StartTime, + finishTime = t.FinishTime, + stuckReason = t.StuckReason, + overdue = t.Overdue + }) + .ToListAsync(ct); + + return Ok(new + { + online = _cdmSyncer.Online, + lastSyncAt = _cdmSyncer.LastSyncAt, + count = tasks.Count, + tasks + }); + } + + /// + /// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录, + /// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。 + /// + [HttpGet("alarms")] + public async Task> Alarms( + [FromQuery] int limit = 2000, + [FromQuery] bool activeOnly = false, + CancellationToken ct = default) + { + // 在线时先即时对帐一次,保证打开页面能拿到最新活跃报警。 + await _alarmCollector.SyncOnceAsync(ct); + + var take = Math.Clamp(limit, 1, 10000); + var query = _db.VehicleAlarms.AsNoTracking().AsQueryable(); + if (activeOnly) query = query.Where(a => a.Status == "active"); + + var alarms = await query + .OrderByDescending(a => a.Status == "active") + .ThenByDescending(a => a.LastAt) + .Take(take) + .Select(a => new + { + id = a.Id, + carId = a.CarId, + carName = a.CarName, + info = a.Info, + level = a.Level, + status = a.Status, + firstAt = a.FirstAt, + lastAt = a.LastAt, + resolvedAt = a.ResolvedAt, + durationSecs = a.DurationSecs, + acknowledged = a.Acknowledged + }) + .ToListAsync(ct); + + return Ok(new + { + online = _alarmCollector.Online, + lastSyncAt = _alarmCollector.LastSyncAt, + count = alarms.Count, + alarms + }); + } +} diff --git a/MiGu.Server/Controllers/OtaController.cs b/MiGu.Server/Controllers/OtaController.cs new file mode 100644 index 0000000..f2f4185 --- /dev/null +++ b/MiGu.Server/Controllers/OtaController.cs @@ -0,0 +1,515 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using MiGu.Server.Configs; +using MiGu.Server.Ota; + +namespace MiGu.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/ota")] +public class OtaController : ControllerBase +{ + private readonly OtaStore _store; + private readonly WatchDogClient _wd; + private readonly OtaVehicleSource _vehicles; + private readonly OtaJobRunner _jobs; + private readonly OpsAuditStore _audits; + private readonly OtaOptions _opt; + + public OtaController( + OtaStore store, + WatchDogClient wd, + OtaVehicleSource vehicles, + OtaJobRunner jobs, + OpsAuditStore audits, + IOptions opt) + { + _store = store; + _wd = wd; + _vehicles = vehicles; + _jobs = jobs; + _audits = audits; + _opt = opt.Value; + } + + private string UserName => + User.FindFirst("unique_name")?.Value + ?? User.Identity?.Name + ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? "unknown"; + + private string Scope => User.FindFirst("scope")?.Value ?? ""; + + private bool CanWrite() + { + if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true; + var ops = User.FindFirst("ops")?.Value ?? ""; + var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return set.Contains("*") || set.Any(o => o.StartsWith("ops.ota", StringComparison.OrdinalIgnoreCase)); + } + + private bool DenyWrite(out ActionResult denied) + { + if (CanWrite()) { denied = null!; return false; } + denied = StatusCode(StatusCodes.Status403Forbidden, new { message = "需要 Platform 或 ops.ota.* 权限" }); + return true; + } + + private void Audit(string op, string target, string result, string? msg = null) => + _audits.Append(UserName, Scope, op, target, result, msg); + + [HttpGet("settings")] + public ActionResult GetSettings() => _store.GetSettings(); + + [HttpPut("settings")] + public ActionResult PutSettings([FromBody] OtaSettings settings) + { + if (DenyWrite(out var denied)) return denied; + var saved = _store.SaveSettings(settings); + Audit("ops.ota.settings", "settings", "ok"); + return saved; + } + + [HttpGet("target")] + public ActionResult GetTarget() + { + var t = _store.GetTarget(); + if (t == null) return Ok(new { target = (OtaTarget?)null }); + return Ok(new + { + target = t, + summary = t.Components.ToDictionary( + kv => kv.Key, + kv => OtaHash.Short(kv.Value.Hash)) + }); + } + + [HttpGet("packages")] + public ActionResult> ListPackages() => _store.ListPackages(); + + [HttpGet("packages/{id}")] + public ActionResult GetPackage(string id) + { + try { return _store.ScanPackage(id); } + catch (DirectoryNotFoundException) { return NotFound(); } + catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } + } + + [HttpPost("packages/{id}/activate")] + public ActionResult Activate(string id, [FromQuery] string? name = null) + { + if (DenyWrite(out var denied)) return denied; + try + { + var t = _store.ActivatePackage(id, name); + Audit("ops.ota.activate", id, "ok"); + return t; + } + catch (Exception ex) + { + Audit("ops.ota.activate", id, "fail", ex.Message); + return BadRequest(new { message = ex.Message }); + } + } + + [HttpDelete("packages/{id}")] + public IActionResult DeletePackage(string id) + { + if (DenyWrite(out var denied)) return denied; + try + { + _store.DeletePackage(id); + Audit("ops.ota.package.delete", id, "ok"); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("packages/pull")] + public async Task> Pull([FromBody] PullPackageRequest req, CancellationToken ct) + { + if (DenyWrite(out var denied)) return denied; + var cars = await _vehicles.ListCarsAsync(ct); + var car = cars.FirstOrDefault(c => string.Equals(c.Id, req.CarId, StringComparison.OrdinalIgnoreCase)); + if (car == null || string.IsNullOrEmpty(car.Ip)) + return BadRequest(new { message = "车辆不存在或无 IP" }); + + var pkgId = _store.BeginPullPackage(car.Ip); + try + { + var baseUrl = ResolvePublicBase(); + var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive"; + var time = DateTime.Now.ToString("yyyyMMddHHmmss"); + await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct); + // 等待文件落盘 + await Task.Delay(1500, ct); + for (var i = 0; i < 40; i++) + { + var info = _store.ScanPackage(pkgId); + if (info.Components.Count > 0) break; + await Task.Delay(500, ct); + } + _store.ClearActivePull(car.Ip); + var result = _store.ScanPackage(pkgId); + if (result.Components.Count == 0) + { + Audit("ops.ota.package.pull", req.CarId, "empty", pkgId); + return BadRequest(new + { + message = + $"未收到任何组件文件(包 {pkgId} 为 0 B)。" + + $"WatchDog 会固定 POST 到 http://{{serverIP}}:{_opt.ReceivePort}/upload-mdcs/{{组件}}," + + "请把该车 watch_dog.json 的 serverIP 设为本机局域网 IP,并确认本机已监听该端口;" + + "同时确认车上已配置 Medulla/Detour/Clumsy 路径。", + packageId = pkgId, + receivePort = _opt.ReceivePort + }); + } + Audit("ops.ota.package.pull", req.CarId, "ok", pkgId); + return Ok(result); + } + catch (Exception ex) + { + _store.ClearActivePull(car.Ip); + Audit("ops.ota.package.pull", req.CarId, "fail", ex.Message); + return BadRequest(new { message = ex.Message, packageId = pkgId }); + } + } + + [HttpPost("packages/upload")] + [RequestSizeLimit(512_000_000)] + public async Task> Upload(IFormFile? file, CancellationToken ct) + { + if (DenyWrite(out var denied)) return denied; + if (file == null || file.Length == 0) + return BadRequest(new { message = "请上传文件(zip 或单文件)" }); + + var pkgId = _store.BeginPullPackage("upload"); + var dir = _store.PackageDir(pkgId); + try + { + var name = Path.GetFileName(file.FileName); + if (string.IsNullOrWhiteSpace(name)) + return BadRequest(new { message = "文件名无效" }); + if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + var zipPath = Path.Combine(dir, name); + await using (var fs = System.IO.File.Create(zipPath)) + await file.CopyToAsync(fs, ct); + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, dir, true); + System.IO.File.Delete(zipPath); + NormalizeUploadLayout(dir); + } + else + { + // 单文件:按扩展名猜放到 M/D/C + var dest = GuessDest(dir, name); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + await using var fs = System.IO.File.Create(dest); + await file.CopyToAsync(fs, ct); + } + _store.ClearActivePull("upload"); + var info = _store.ScanPackage(pkgId); + Audit("ops.ota.package.upload", pkgId, "ok"); + return info; + } + catch (Exception ex) + { + _store.ClearActivePull("upload"); + return BadRequest(new { message = ex.Message }); + } + } + + [HttpGet("vehicles")] + public async Task>> Vehicles([FromQuery] bool? latency, CancellationToken ct) + { + var settings = _store.GetSettings(); + var doLatency = latency ?? settings.LatencyEnabled; + var target = _store.GetTarget(); + var cars = await _vehicles.ListCarsAsync(ct); + + await Parallel.ForEachAsync(cars, new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, async (car, token) => + { + if (string.IsNullOrEmpty(car.Ip)) + { + car.Reachable = false; + return; + } + if (doLatency) + car.RttMs = await _wd.MeasureRttMsAsync(car.Ip, token); + + var (ok, m, d, c, _) = await _wd.GetMdcInfoAsync(car.Ip, token); + car.Reachable = ok; + car.Medulla = m; + car.Detour = d; + car.Clumsy = c; + if (target != null) + car.Match = BuildMatch(target, m, d, c); + }); + + return cars; + } + + [HttpGet("latency")] + public async Task> Latency(CancellationToken ct) + { + var settings = _store.GetSettings(); + if (!settings.LatencyEnabled) + return Ok(new { enabled = false, items = Array.Empty() }); + var cars = await _vehicles.ListCarsAsync(ct); + var items = new List(); + foreach (var car in cars) + { + if (string.IsNullOrEmpty(car.Ip)) continue; + var rtt = await _wd.MeasureRttMsAsync(car.Ip, ct); + items.Add(new { car.Id, car.Ip, rttMs = rtt, over = rtt == null || rtt > settings.RttThresholdMs }); + } + return Ok(new { enabled = true, thresholdMs = settings.RttThresholdMs, items }); + } + + [HttpGet("jobs")] + public ActionResult> ListJobs([FromQuery] int take = 100) => _store.ListJobs(take); + + [HttpGet("jobs/{id}")] + public ActionResult GetJob(string id) + { + try + { + var j = _store.GetJob(id); + return j == null ? NotFound() : j; + } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("jobs")] + public ActionResult CreateJob([FromBody] CreateSyncJobRequest req) + { + if (DenyWrite(out var denied)) return denied; + try + { + var job = _jobs.EnqueueSync(req, UserName); + Audit("ops.ota.job.create", job.Id, "ok", $"cars={req.CarIds.Count}"); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("jobs/{id}/cancel")] + public IActionResult Cancel(string id) + { + if (DenyWrite(out var denied)) return denied; + var ok = _jobs.Cancel(id); + Audit("ops.ota.job.cancel", id, ok ? "ok" : "noop"); + return ok ? Ok(new { ok = true }) : BadRequest(new { message = "无法取消" }); + } + + [HttpPost("jobs/{id}/retry")] + public ActionResult Retry(string id) + { + if (DenyWrite(out var denied)) return denied; + try + { + var job = _jobs.RetryFailed(id, UserName); + if (job == null) return NotFound(); + Audit("ops.ota.job.retry", id, "ok", job.Id); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpGet("config/{carId}/{app}")] + public async Task> GetConfig(string carId, string app, CancellationToken ct) + { + var cars = await _vehicles.ListCarsAsync(ct); + var car = cars.FirstOrDefault(c => string.Equals(c.Id, carId, StringComparison.OrdinalIgnoreCase)); + if (car?.Ip == null) return BadRequest(new { message = "车辆无 IP" }); + try + { + var json = await _wd.GetJsonAsync(car.Ip, app, ct); + return Ok(new { carId, app, json }); + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("config/push")] + public ActionResult ConfigPush([FromBody] CreateConfigPushRequest req) + { + if (DenyWrite(out var denied)) return denied; + try + { + var job = _jobs.EnqueueConfigPush(req, UserName); + Audit("ops.ota.config.push", job.Id, "ok", req.App); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("custom-file")] + [RequestSizeLimit(512_000_000)] + public async Task> CustomFile( + [FromForm] string carIds, + [FromForm] string remotePath, + [FromForm] string? restartOps, + [FromForm] int? restartOp, + [FromForm] List? files, + IFormFile? file, + CancellationToken ct) + { + if (DenyWrite(out var denied)) return denied; + var uploadFiles = new List(); + if (files is { Count: > 0 }) uploadFiles.AddRange(files.Where(f => f.Length > 0)); + if (file is { Length: > 0 }) uploadFiles.Add(file); + if (uploadFiles.Count == 0) + return BadRequest(new { message = "请至少选择一个文件" }); + + var ids = ParseCarIds(carIds); + var ops = new List(); + if (!string.IsNullOrWhiteSpace(restartOps)) + { + try + { + ops = System.Text.Json.JsonSerializer.Deserialize>(restartOps) ?? new(); + } + catch { /* ignore */ } + } + if (ops.Count == 0 && restartOp.HasValue) ops.Add(restartOp.Value); + if (ops.Count == 0) ops.Add(-1); + + var tmpDir = Path.Combine(_store.Root, "uploads", $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tmpDir); + var items = new List(); + foreach (var f in uploadFiles) + { + var safe = Path.GetFileName(f.FileName); + var local = Path.Combine(tmpDir, $"{items.Count}_{safe}"); + await using (var fs = System.IO.File.Create(local)) + await f.CopyToAsync(fs, ct); + items.Add(new OtaCustomFileItem { LocalPath = local, FileName = safe }); + } + + try + { + var job = _jobs.EnqueueCustomFile(new CreateCustomFileJobRequest + { + CarIds = ids, + RemotePath = remotePath, + RestartOps = ops + }, items, UserName); + Audit("ops.ota.customFile", job.Id, "ok", string.Join(",", items.Select(i => i.FileName))); + return job; + } + catch (Exception ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + private string ResolvePublicBase() + { + if (!string.IsNullOrWhiteSpace(_opt.PublicBaseUrl)) + return _opt.PublicBaseUrl.TrimEnd('/'); + return $"{Request.Scheme}://{Request.Host}"; + } + + private static List ParseCarIds(string carIds) + { + var trimmed = carIds.Trim(); + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + var ids = System.Text.Json.JsonSerializer.Deserialize>(trimmed); + if (ids != null) + return ids.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).ToList(); + } + catch + { + // Fall back to comma-separated form data below. + } + } + + return trimmed + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + } + + private static Dictionary BuildMatch(OtaTarget target, OtaAppVersions? m, OtaAppVersions? d, OtaAppVersions? c) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + void One(string key, string? vehicleHash) + { + if (!target.Components.TryGetValue(key, out var art)) + { + map[key] = "missing-target"; + return; + } + if (string.IsNullOrEmpty(vehicleHash)) { map[key] = "unknown"; return; } + map[key] = string.Equals(vehicleHash, art.Hash, StringComparison.Ordinal) ? "match" : "mismatch"; + } + One("M.exe", m?.Exe?.Version); + One("M.dll", m?.Dll?.Version); + One("M.pdb", m?.Pdb?.Version); + One("D.exe", d?.Exe?.Version); + One("C.exe", c?.Exe?.Version); + One("C.dll", c?.Dll?.Version); + One("C.pdb", c?.Pdb?.Version); + return map; + } + + private static void NormalizeUploadLayout(string dir) + { + // 若 zip 根下直接是 M/D/C 或 Medulla.exe,尽量归位 + var medulla = Directory.GetFiles(dir, "Medulla.exe", SearchOption.AllDirectories).FirstOrDefault(); + if (medulla != null) + { + var dest = Path.Combine(dir, "M", "Medulla.exe"); + if (!string.Equals(medulla, dest, StringComparison.OrdinalIgnoreCase)) + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + System.IO.File.Copy(medulla, dest, true); + } + } + var detour = Directory.GetFiles(dir, "Detour.exe", SearchOption.AllDirectories).FirstOrDefault(); + if (detour != null) + { + var dest = Path.Combine(dir, "D", "Detour.exe"); + if (!string.Equals(detour, dest, StringComparison.OrdinalIgnoreCase)) + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + System.IO.File.Copy(detour, dest, true); + } + } + } + + private static string GuessDest(string dir, string fileName) + { + var n = fileName.ToLowerInvariant(); + if (n.Contains("medulla") && n.EndsWith(".exe")) return Path.Combine(dir, "M", "Medulla.exe"); + if (n.Contains("cartactivator") && n.EndsWith(".dll")) return Path.Combine(dir, "M", "plugins", "CartActivator.dll"); + if (n.Contains("detour")) return Path.Combine(dir, "D", "Detour.exe"); + if (n.Contains("clumsy") && n.EndsWith(".exe")) return Path.Combine(dir, "C", "ClumsyConsole.exe"); + return Path.Combine(dir, "M", Path.GetFileName(fileName)); + } +} diff --git a/MiGu.Server/Controllers/OtaReceiveController.cs b/MiGu.Server/Controllers/OtaReceiveController.cs new file mode 100644 index 0000000..17a1d43 --- /dev/null +++ b/MiGu.Server/Controllers/OtaReceiveController.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MiGu.Server.Ota; + +namespace MiGu.Server.Controllers; + +/// +/// WatchDog 回传包接收端。 +/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}, +/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。 +/// +[ApiController] +[AllowAnonymous] +public class OtaReceiveController : ControllerBase +{ + private readonly OtaStore _store; + private readonly ILogger _log; + + public OtaReceiveController(OtaStore store, ILogger log) + { + _store = store; + _log = log; + } + + [HttpGet("/hello")] + [HttpGet("/api/ota/receive/hello")] + public IActionResult Hello() => Ok("ok"); + + /// WatchDog 官方路径:/upload-mdcs/{Medullaexe|...} + [HttpPost("/upload-mdcs/{routeKey}")] + [HttpPost("/api/ota/receive/upload-mdcs/{routeKey}")] + [HttpPost("/api/ota/receive/upload-mdcs{routeKey}")] + [RequestSizeLimit(512_000_000)] + public Task UploadMdcs(string routeKey, CancellationToken ct) + => SaveAsync(routeKey, ct); + + [HttpPost("/upload-history/{routeKey}")] + [HttpPost("/api/ota/receive/upload-history/{routeKey}")] + [HttpPost("/api/ota/receive/upload-history{routeKey}")] + [RequestSizeLimit(512_000_000)] + public async Task UploadHistory(string routeKey, CancellationToken ct) + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!_store.TryGetActivePullId(ip, out _)) + { + _log.LogWarning("OTA history rejected without active pull session from {Ip}", ip); + return BadRequest("no active pull session"); + } + + var day = DateTime.Now.ToString("yyyy-MM-dd"); + var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip, "unknown")); + Directory.CreateDirectory(dir); + var file = await ReadFirstFileAsync(ct); + if (file == null || file.Length == 0) return BadRequest("empty"); + // 净化文件名:routeKey / FileName 都可能含路径分隔符,必须 GetFileName 防穿越。 + var rawName = string.IsNullOrWhiteSpace(file.FileName) ? routeKey : file.FileName; + var safeName = SafeFileName(rawName, "unnamed"); + var path = Path.Combine(dir, safeName); + await using var fs = System.IO.File.Create(path); + await file.CopyToAsync(fs, ct); + _log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length); + return Ok(new { ok = true }); + } + + private async Task SaveAsync(string routeKey, CancellationToken ct) + { + try + { + var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + if (!_store.TryGetActivePullId(clientIp, out _)) + { + _log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown"); + return BadRequest("no active pull session"); + } + + var file = await ReadFirstFileAsync(ct); + if (file == null || file.Length == 0) return BadRequest("empty file"); + + var dest = _store.ResolveReceivePath(routeKey, clientIp); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + await using (var fs = System.IO.File.Create(dest)) + await file.CopyToAsync(fs, ct); + _log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length); + return Ok(new { ok = true, path = dest }); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OTA receive failed {Route}", routeKey); + return BadRequest(ex.Message); + } + } + + private async Task ReadFirstFileAsync(CancellationToken ct) + { + if (!Request.HasFormContentType) return null; + var form = await Request.ReadFormAsync(ct); + return form.Files.FirstOrDefault(); + } + + private static string SafeFileName(string raw, string fallback) + { + var safe = Path.GetFileName(raw); + if (string.IsNullOrWhiteSpace(safe)) safe = fallback; + foreach (var ch in Path.GetInvalidFileNameChars()) + safe = safe.Replace(ch, '_'); + return string.IsNullOrWhiteSpace(safe) ? fallback : safe; + } +} diff --git a/MiGu.Server/Fleet/AlarmCollector.cs b/MiGu.Server/Fleet/AlarmCollector.cs new file mode 100644 index 0000000..6d527fa --- /dev/null +++ b/MiGu.Server/Fleet/AlarmCollector.cs @@ -0,0 +1,251 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using MiGu.Server.Auth; +using MiGu.Server.Launcher; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Fleet; + +/// +/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。 +/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。 +/// +public sealed class AlarmCollector +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStore _token; + private readonly ILogger _log; + private readonly SemaphoreSlim _gate = new(1, 1); + + private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true }; + + public volatile bool Online; + public DateTimeOffset? LastSyncAt { get; private set; } + + public AlarmCollector( + IServiceScopeFactory scopeFactory, + IHttpClientFactory httpFactory, + IOptions sl, + InternalTokenStore token, + ILogger log) + { + _scopeFactory = scopeFactory; + _httpFactory = httpFactory; + _sl = sl.Value; + _token = token; + _log = log; + } + + private sealed class CurrentAlarm + { + public int CarId; + public string CarName = ""; + public string Info = ""; + public int Level; + } + + public async Task SyncOnceAsync(CancellationToken ct) + { + if (!await _gate.WaitAsync(0, ct)) return Online; + try + { + var cars = await FetchCarsAsync(ct); + if (cars == null) + { + Online = false; + return false; + } + + var current = new Dictionary(); + await Parallel.ForEachAsync( + cars, + new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, + async (car, token) => + { + var (info, level) = await FetchCarAlarmAsync(car.Id, token); + if (string.IsNullOrWhiteSpace(info)) return; + lock (current) + { + current[car.Id] = new CurrentAlarm { CarId = car.Id, CarName = car.Name, Info = info, Level = level }; + } + }); + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await ReconcileAsync(db, current, ct); + } + catch (Exception ex) + { + _log.LogDebug(ex, "alarm reconcile failed"); + } + + Online = true; + LastSyncAt = DateTimeOffset.UtcNow; + return true; + } + finally + { + _gate.Release(); + } + } + + private sealed class CarRow + { + public int Id; + public string Name = ""; + } + + private async Task?> FetchCarsAsync(CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + try + { + using var client = CreateClient(); + using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/cars"), ct); + if (!resp.IsSuccessStatusCode) return null; + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); + var list = new List(); + foreach (var el in doc.RootElement.EnumerateArray()) + { + var id = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var n) ? n : 0; + if (id <= 0) continue; + var name = el.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : ""; + list.Add(new CarRow { Id = id, Name = name }); + } + return list; + } + catch (Exception ex) + { + _log.LogDebug(ex, "alarm fetch cars failed"); + return null; + } + } + + private async Task<(string info, int level)> FetchCarAlarmAsync(int carId, CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + try + { + using var client = CreateClient(); + using var resp = await client.SendAsync(Req($"http://127.0.0.1:{port}/projection/reflection/status/car/{carId}"), ct); + if (!resp.IsSuccessStatusCode) return ("", 0); + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) + return ("", 0); + + string info = ""; + var level = 0; + foreach (var kv in data.EnumerateArray()) + { + var key = kv.TryGetProperty("key", out var k) ? k.GetString() : null; + var val = kv.TryGetProperty("value", out var v) ? v.GetString() : null; + if (key == "车体_AlarmInfo" || key == "AlarmInfo") info = val ?? ""; + else if (key == "车体_AlarmLevel" || key == "AlarmLevel") int.TryParse(val, out level); + } + + info = info.Trim(); + if (info is "0" or "/" or "-") info = ""; + return (info, level); + } + catch + { + return ("", 0); + } + } + + private static async Task ReconcileAsync(PlatformDbContext db, Dictionary current, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct); + var activeByCar = new Dictionary(); + foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active + + // 出现 / 更新 + foreach (var cur in current.Values) + { + if (activeByCar.TryGetValue(cur.CarId, out var rec)) + { + rec.Info = cur.Info; + rec.Level = cur.Level; + rec.CarName = cur.CarName; + rec.LastAt = now; + } + else + { + db.VehicleAlarms.Add(new VehicleAlarmRecord + { + CarId = cur.CarId, + CarName = cur.CarName, + Info = cur.Info, + Level = cur.Level, + Status = "active", + FirstAt = now, + LastAt = now + }); + } + } + + // 消失 → 恢复 + foreach (var rec in active) + { + if (current.ContainsKey(rec.CarId)) continue; + rec.Status = "cleared"; + rec.ResolvedAt = now; + rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds); + } + + await db.SaveChangesAsync(ct); + } + + private HttpClient CreateClient() + { + var c = _httpFactory.CreateClient(); + c.Timeout = TimeSpan.FromSeconds(10); + return c; + } + + private HttpRequestMessage Req(string url) + { + var req = new HttpRequestMessage(HttpMethod.Get, url); + var token = _token.Token; + if (!string.IsNullOrEmpty(token)) + req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); + return req; + } +} + +/// 后台循环:定时采集车辆报警到 platform.db。 +public sealed class AlarmCollectorService : BackgroundService +{ + private readonly AlarmCollector _collector; + private readonly ILogger _log; + + public AlarmCollectorService(AlarmCollector collector, ILogger log) + { + _collector = collector; + _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); } + catch { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try { await _collector.SyncOnceAsync(stoppingToken); } + catch (Exception ex) { _log.LogDebug(ex, "alarm collector loop error"); } + + try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); } + catch { break; } + } + } +} diff --git a/MiGu.Server/Fleet/CdmTaskRecord.cs b/MiGu.Server/Fleet/CdmTaskRecord.cs new file mode 100644 index 0000000..69a5973 --- /dev/null +++ b/MiGu.Server/Fleet/CdmTaskRecord.cs @@ -0,0 +1,33 @@ +namespace MiGu.Server.Fleet; + +/// +/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。 +/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史, +/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。 +/// +public sealed class CdmTaskRecord +{ + public string Id { get; set; } = ""; + public string? TaskId { get; set; } + public int MissionId { get; set; } + public string MissionName { get; set; } = ""; + public string MissionTypeName { get; set; } = ""; + public int SrcSiteId { get; set; } + public string SrcLabel { get; set; } = ""; + public int DstSiteId { get; set; } + public string DstLabel { get; set; } = ""; + public string Status { get; set; } = ""; + public string StatusCode { get; set; } = ""; + public int? CarId { get; set; } + public string? CarName { get; set; } + public int Priority { get; set; } + /// 下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。 + public string? CreateTime { get; set; } + public string? StartTime { get; set; } + public string? FinishTime { get; set; } + public string? StuckReason { get; set; } + public bool Overdue { get; set; } + /// 平台首次/最近一次同步到该任务的时间。 + public DateTimeOffset FirstSeenAt { get; set; } + public DateTimeOffset LastSeenAt { get; set; } +} diff --git a/MiGu.Server/Fleet/CdmTaskSync.cs b/MiGu.Server/Fleet/CdmTaskSync.cs new file mode 100644 index 0000000..1609426 --- /dev/null +++ b/MiGu.Server/Fleet/CdmTaskSync.cs @@ -0,0 +1,205 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using MiGu.Server.Auth; +using MiGu.Server.Launcher; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Fleet; + +/// 投影 /projection/deliveries 返回的单行(camelCase)。 +public sealed class CdmTaskDto +{ + public string id { get; set; } = ""; + public string? taskId { get; set; } + public int missionId { get; set; } + public string missionName { get; set; } = ""; + public string missionTypeName { get; set; } = ""; + public int srcSiteId { get; set; } + public string srcLabel { get; set; } = ""; + public int dstSiteId { get; set; } + public string dstLabel { get; set; } = ""; + public string status { get; set; } = ""; + public string statusCode { get; set; } = ""; + public int? carId { get; set; } + public string? carName { get; set; } + public int priority { get; set; } + public string? createTime { get; set; } + public string? startTime { get; set; } + public string? finishTime { get; set; } + public string? stuckReason { get; set; } + public bool overdue { get; set; } +} + +/// +/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。 +/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。 +/// +public sealed class CdmTaskSyncer +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStore _token; + private readonly ILogger _log; + private readonly SemaphoreSlim _gate = new(1, 1); + + private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true }; + + public volatile bool Online; + public DateTimeOffset? LastSyncAt { get; private set; } + public int LastCount { get; private set; } + + public CdmTaskSyncer( + IServiceScopeFactory scopeFactory, + IHttpClientFactory httpFactory, + IOptions sl, + InternalTokenStore token, + ILogger log) + { + _scopeFactory = scopeFactory; + _httpFactory = httpFactory; + _sl = sl.Value; + _token = token; + _log = log; + } + + /// 拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。 + public async Task SyncOnceAsync(CancellationToken ct) + { + if (!await _gate.WaitAsync(0, ct)) return Online; + try + { + var dtos = await FetchAsync(ct); + if (dtos == null) + { + Online = false; + return false; + } + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await UpsertAsync(db, dtos, ct); + } + catch (Exception ex) + { + // 拉取成功即视为在线;落库失败只记日志,不影响在线判定 + _log.LogDebug(ex, "cdm upsert failed"); + } + + Online = true; + LastSyncAt = DateTimeOffset.UtcNow; + LastCount = dtos.Count; + return true; + } + finally + { + _gate.Release(); + } + } + + private async Task?> FetchAsync(CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + using var req = new HttpRequestMessage( + HttpMethod.Get, + $"http://127.0.0.1:{port}/projection/deliveries?includeFinished=true&includeAborted=true"); + var token = _token.Token; + if (!string.IsNullOrEmpty(token)) + req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); + + using var resp = await client.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) return null; + var text = await resp.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize>(text, JsonOpt) ?? new List(); + } + catch (Exception ex) + { + _log.LogDebug(ex, "cdm fetch failed"); + return null; + } + } + + private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList dtos, CancellationToken ct) + { + var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList(); + if (valid.Count == 0) return; + + var now = DateTimeOffset.UtcNow; + var ids = valid.Select(d => d.id).ToList(); + var existing = await db.CdmTasks.Where(t => ids.Contains(t.Id)).ToDictionaryAsync(t => t.Id, ct); + + foreach (var d in valid) + { + if (existing.TryGetValue(d.id, out var rec)) + { + Map(d, rec); + rec.LastSeenAt = now; + } + else + { + var created = new CdmTaskRecord { Id = d.id, FirstSeenAt = now, LastSeenAt = now }; + Map(d, created); + db.CdmTasks.Add(created); + } + } + + await db.SaveChangesAsync(ct); + } + + private static void Map(CdmTaskDto d, CdmTaskRecord rec) + { + rec.TaskId = string.IsNullOrWhiteSpace(d.taskId) ? null : d.taskId; + rec.MissionId = d.missionId; + rec.MissionName = d.missionName ?? ""; + rec.MissionTypeName = d.missionTypeName ?? ""; + rec.SrcSiteId = d.srcSiteId; + rec.SrcLabel = d.srcLabel ?? ""; + rec.DstSiteId = d.dstSiteId; + rec.DstLabel = d.dstLabel ?? ""; + rec.Status = d.status ?? ""; + rec.StatusCode = d.statusCode ?? ""; + rec.CarId = d.carId; + rec.CarName = d.carName; + rec.Priority = d.priority; + rec.CreateTime = d.createTime; + rec.StartTime = d.startTime; + rec.FinishTime = d.finishTime; + rec.StuckReason = d.stuckReason; + rec.Overdue = d.overdue; + } +} + +/// 后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。 +public sealed class CdmTaskSyncService : BackgroundService +{ + private readonly CdmTaskSyncer _syncer; + private readonly ILogger _log; + + public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger log) + { + _syncer = syncer; + _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); } + catch { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try { await _syncer.SyncOnceAsync(stoppingToken); } + catch (Exception ex) { _log.LogDebug(ex, "cdm sync loop error"); } + + try { await Task.Delay(TimeSpan.FromSeconds(8), stoppingToken); } + catch { break; } + } + } +} diff --git a/MiGu.Server/Fleet/FleetHealthModels.cs b/MiGu.Server/Fleet/FleetHealthModels.cs new file mode 100644 index 0000000..0a229db --- /dev/null +++ b/MiGu.Server/Fleet/FleetHealthModels.cs @@ -0,0 +1,21 @@ +namespace MiGu.Server.Fleet; + +/// 与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。 +public sealed class FleetHealthRowDto +{ + public int CarId { get; set; } + public string? CarName { get; set; } + public string? Ip { get; set; } + public string? OnboardUrl { get; set; } + public int? LatencyMs { get; set; } + public bool? Reachable { get; set; } + public string? ProbedAt { get; set; } + public double? UptimeSecs { get; set; } + public double? AlarmActiveSecs { get; set; } + public double? FaultRatePercent { get; set; } + public bool? IsAlarmActive { get; set; } + public double? CpuPercent { get; set; } + public double? MemPercent { get; set; } + /// latency 探测通道:watchdog | onboard | none + public string? LatencySource { get; set; } +} diff --git a/MiGu.Server/Fleet/FleetHealthService.cs b/MiGu.Server/Fleet/FleetHealthService.cs new file mode 100644 index 0000000..4eab899 --- /dev/null +++ b/MiGu.Server/Fleet/FleetHealthService.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using MiGu.Server.Auth; +using MiGu.Server.Launcher; +using MiGu.Server.Ota; + +namespace MiGu.Server.Fleet; + +/// +/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。 +/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。 +/// +public sealed class FleetHealthService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStore _token; + private readonly WatchDogClient _wd; + private readonly ILogger _log; + + private static readonly JsonSerializerOptions JsonOpt = new() + { + PropertyNameCaseInsensitive = true + }; + + public FleetHealthService( + IHttpClientFactory httpFactory, + IOptions sl, + InternalTokenStore token, + WatchDogClient wd, + ILogger log) + { + _httpFactory = httpFactory; + _sl = sl.Value; + _token = token; + _wd = wd; + _log = log; + } + + public async Task> GetAsync(CancellationToken ct) + { + var rows = await FetchSimpleLiteHealthAsync(ct); + if (rows.Count == 0) + rows = await BuildRowsFromCarsAsync(ct); + + await Parallel.ForEachAsync( + rows, + new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct }, + async (row, token) => + { + if (string.IsNullOrWhiteSpace(row.Ip)) + { + // 无 IP 时保留 SimpleLite 原探测结果 + row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none"; + return; + } + + // 取两次 TCP 连接的较小值,降低偶发握手抖动 + var a = await _wd.MeasureRttMsAsync(row.Ip, token); + var b = await _wd.MeasureRttMsAsync(row.Ip, token); + int? rtt = (a, b) switch + { + (null, null) => null, + (int x, null) => x, + (null, int y) => y, + (int x, int y) => Math.Min(x, y) + }; + + if (rtt != null) + { + row.LatencyMs = rtt; + row.Reachable = true; + row.LatencySource = "watchdog"; + row.ProbedAt = DateTimeOffset.UtcNow.ToString("O"); + } + else + { + // WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达 + if (row.Reachable == true && row.LatencyMs is > 0 and < 2000) + { + row.LatencySource = "onboard"; + } + else + { + row.Reachable = false; + row.LatencyMs = null; + row.LatencySource = "watchdog"; + row.ProbedAt = DateTimeOffset.UtcNow.ToString("O"); + } + } + }); + + return rows; + } + + private async Task> FetchSimpleLiteHealthAsync(CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(20); + using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/fleet/health"); + var token = _token.Token; + if (!string.IsNullOrEmpty(token)) + req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); + + using var resp = await client.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) return new(); + + var text = await resp.Content.ReadAsStringAsync(ct); + var list = JsonSerializer.Deserialize>(text, JsonOpt); + return list ?? new(); + } + catch (Exception ex) + { + _log.LogDebug(ex, "fleet/health from SimpleLite failed"); + return new(); + } + } + + private async Task> BuildRowsFromCarsAsync(CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars"); + var token = _token.Token; + if (!string.IsNullOrEmpty(token)) + req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); + + using var resp = await client.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) return new(); + + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); + + var list = new List(); + foreach (var el in doc.RootElement.EnumerateArray()) + { + var rawId = el.TryGetProperty("rawId", out var rid) && rid.TryGetInt32(out var id) + ? id + : 0; + if (rawId <= 0) continue; + var ip = el.TryGetProperty("ip", out var ipEl) ? ipEl.GetString() : null; + var name = el.TryGetProperty("name", out var nEl) ? nEl.GetString() : null; + var onboard = el.TryGetProperty("onboardUrl", out var oEl) ? oEl.GetString() : null; + if (string.IsNullOrEmpty(onboard) && !string.IsNullOrEmpty(ip)) + onboard = $"http://{ip}:8081"; + list.Add(new FleetHealthRowDto + { + CarId = rawId, + CarName = name, + Ip = ip, + OnboardUrl = onboard + }); + } + return list; + } + catch (Exception ex) + { + _log.LogDebug(ex, "projection/cars fallback for fleet health failed"); + return new(); + } + } +} diff --git a/MiGu.Server/Fleet/VehicleAlarmRecord.cs b/MiGu.Server/Fleet/VehicleAlarmRecord.cs new file mode 100644 index 0000000..a24ad3f --- /dev/null +++ b/MiGu.Server/Fleet/VehicleAlarmRecord.cs @@ -0,0 +1,29 @@ +namespace MiGu.Server.Fleet; + +/// +/// 车辆报警的平台侧记录(表 vehicle_alarms)。 +/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐: +/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。 +/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。 +/// +public sealed class VehicleAlarmRecord +{ + public string Id { get; set; } = Guid.NewGuid().ToString("D"); + public int CarId { get; set; } + public string CarName { get; set; } = ""; + /// 报警文案(车体_AlarmInfo)。 + public string Info { get; set; } = ""; + /// 报警级别(车体_AlarmLevel,未知为 0)。 + public int Level { get; set; } + /// active | cleared + public string Status { get; set; } = "active"; + public DateTimeOffset FirstAt { get; set; } + public DateTimeOffset LastAt { get; set; } + public DateTimeOffset? ResolvedAt { get; set; } + /// 持续时长(秒),恢复后写入。 + public long? DurationSecs { get; set; } + /// 预留:平台侧确认(不代表车端消警)。 + public bool Acknowledged { get; set; } + public DateTimeOffset? AcknowledgedAt { get; set; } + public string? AcknowledgedBy { get; set; } +} diff --git a/MiGu.Server/Ota/OtaHash.cs b/MiGu.Server/Ota/OtaHash.cs new file mode 100644 index 0000000..cf75a6f --- /dev/null +++ b/MiGu.Server/Ota/OtaHash.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; + +namespace MiGu.Server.Ota; + +public static class OtaHash +{ + /// 与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。 + public static string OfFile(string path) + { + using var fs = File.OpenRead(path); + var hash = MD5.HashData(fs); + return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal); + } + + public static string OfBytes(ReadOnlySpan bytes) + { + var hash = MD5.HashData(bytes); + return Convert.ToBase64String(hash).Replace("-", "", StringComparison.Ordinal); + } + + public static string Short(string? hash, int len = 8) + { + if (string.IsNullOrEmpty(hash)) return "—"; + return hash.Length <= len ? hash : hash[..len]; + } +} diff --git a/MiGu.Server/Ota/OtaJobRunner.cs b/MiGu.Server/Ota/OtaJobRunner.cs new file mode 100644 index 0000000..32fbffd --- /dev/null +++ b/MiGu.Server/Ota/OtaJobRunner.cs @@ -0,0 +1,433 @@ +using System.Collections.Concurrent; + +namespace MiGu.Server.Ota; + +public sealed class OtaJobRunner +{ + private readonly OtaStore _store; + private readonly WatchDogClient _wd; + private readonly OtaVehicleSource _vehicles; + private readonly ILogger _log; + private readonly ConcurrentDictionary _running = new(); + + public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger log) + { + _store = store; + _wd = wd; + _vehicles = vehicles; + _log = log; + } + + public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user) + { + var target = _store.GetTarget() ?? throw new InvalidOperationException("未设置目标版本,请先在版本库激活"); + var components = (req.Components is { Count: > 0 } ? req.Components : OtaPathMap.ComponentKeys.ToList()) + .Where(c => target.Components.ContainsKey(c)) + .ToList(); + if (components.Count == 0) throw new InvalidOperationException("目标版本中无选定组件"); + if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆"); + + var settings = _store.GetSettings(); + var job = new OtaJob + { + Id = _store.NextJobId(), + Kind = "sync", + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = user, + PackageId = target.PackageId, + CarIds = req.CarIds.ToList(), + Components = components, + RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled + }; + foreach (var carId in job.CarIds) + foreach (var comp in components) + job.Steps.Add(new OtaJobStep { CarId = carId, Component = comp, Status = "pending" }); + job.TotalSteps = job.Steps.Count; + _store.SaveJob(job); + _ = Task.Run(() => RunAsync(job.Id)); + return job; + } + + public OtaJob EnqueueCustomFile(CreateCustomFileJobRequest req, IReadOnlyList files, string? user) + { + if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆"); + if (files.Count == 0) throw new InvalidOperationException("请至少添加一个本地文件"); + if (string.IsNullOrWhiteSpace(req.RemotePath)) throw new InvalidOperationException("请填写小车内目标路径"); + var settings = _store.GetSettings(); + var ops = req.RestartOps is { Count: > 0 } + ? req.RestartOps.Distinct().ToList() + : new List { req.RestartOp }; + if (ops.Count == 0) ops.Add(-1); + + var job = new OtaJob + { + Id = _store.NextJobId(), + Kind = "customFile", + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = user, + CarIds = req.CarIds.ToList(), + CustomFileName = files[0].FileName, + CustomRemotePath = req.RemotePath, + CustomRestartOp = ops[0], + CustomLocalPath = files[0].LocalPath, + CustomFiles = files.ToList(), + CustomRestartOps = ops, + RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled, + Components = new List { "custom" } + }; + foreach (var carId in job.CarIds) + job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"custom×{files.Count}", Status = "pending" }); + job.TotalSteps = job.Steps.Count; + _store.SaveJob(job); + _ = Task.Run(() => RunAsync(job.Id)); + return job; + } + + public OtaJob EnqueueConfigPush(CreateConfigPushRequest req, string? user) + { + if (req.CarIds.Count == 0) throw new InvalidOperationException("请选择车辆"); + var app = req.App.Trim().ToLowerInvariant(); + if (app is not ("medulla" or "detour" or "clumsy")) + throw new InvalidOperationException("app 须为 medulla|detour|clumsy"); + var settings = _store.GetSettings(); + var job = new OtaJob + { + Id = _store.NextJobId(), + Kind = "configPush", + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = user, + CarIds = req.CarIds.ToList(), + ConfigApp = app, + ConfigJson = req.Json, + RequireLatencyCheck = req.RequireLatencyCheck ?? settings.LatencyEnabled, + Components = new List { $"config:{app}" } + }; + foreach (var carId in job.CarIds) + job.Steps.Add(new OtaJobStep { CarId = carId, Component = $"config:{app}", Status = "pending" }); + job.TotalSteps = job.Steps.Count; + _store.SaveJob(job); + _ = Task.Run(() => RunAsync(job.Id)); + return job; + } + + public bool Cancel(string jobId) + { + var job = _store.GetJob(jobId); + if (job == null) return false; + if (job.Status is "succeeded" or "failed" or "partial" or "cancelled") return false; + // 正在运行:只发取消信号,由 RunAsync 统一收尾,避免与运行线程并发写同一 job 文件。 + if (_running.TryGetValue(jobId, out var cts)) + { + cts.Cancel(); + return true; + } + // 尚未开始(Task.Run 排队中):直接落盘取消;RunAsync 启动时有 status 守卫会跳过执行。 + foreach (var step in job.Steps.Where(s => s.Status == "pending")) + { + step.Status = "skipped"; + step.Error = "已取消"; + } + job.Status = "cancelled"; + job.FinishedAt = DateTimeOffset.UtcNow; + job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped"); + _store.SaveJob(job); + return true; + } + + public OtaJob? RetryFailed(string jobId, string? user) + { + var old = _store.GetJob(jobId); + if (old == null) return null; + var failedCars = old.Steps.Where(s => s.Status == "failed").Select(s => s.CarId).Distinct().ToList(); + if (failedCars.Count == 0) throw new InvalidOperationException("没有失败项可重试"); + + return old.Kind switch + { + "sync" => EnqueueSync(new CreateSyncJobRequest + { + CarIds = failedCars, + Components = old.Components, + RequireLatencyCheck = old.RequireLatencyCheck + }, user), + "customFile" when ResolveCustomFiles(old).Count > 0 => + EnqueueCustomFile(new CreateCustomFileJobRequest + { + CarIds = failedCars, + RemotePath = old.CustomRemotePath ?? "", + RestartOps = old.CustomRestartOps is { Count: > 0 } + ? old.CustomRestartOps + : new List { old.CustomRestartOp ?? -1 }, + RequireLatencyCheck = old.RequireLatencyCheck + }, ResolveCustomFiles(old), user), + "configPush" => EnqueueConfigPush(new CreateConfigPushRequest + { + CarIds = failedCars, + App = old.ConfigApp ?? "", + Json = old.ConfigJson ?? "", + RequireLatencyCheck = old.RequireLatencyCheck + }, user), + _ => throw new InvalidOperationException("无法重试该任务类型或文件已丢失") + }; + } + + private async Task RunAsync(string jobId) + { + var cts = new CancellationTokenSource(); + if (!_running.TryAdd(jobId, cts)) return; + // 单一内存实例贯穿全程;所有「改状态 + 落盘」都在 jobLock 下串行,杜绝并发车批次的丢更新。 + var jobLock = new object(); + try + { + var job = _store.GetJob(jobId); + if (job == null) return; + // 守卫:排队期间被取消 / 已终结的任务不再执行。 + if (job.Status is "cancelled" or "succeeded" or "failed" or "partial") return; + var settings = _store.GetSettings(); + var cars = await _vehicles.ListCarsAsync(cts.Token); + var byId = cars.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); + + job.Status = job.RequireLatencyCheck ? "probing" : "running"; + _store.SaveJob(job); + + // 解析 IP + foreach (var step in job.Steps) + { + if (byId.TryGetValue(step.CarId, out var car)) + step.Ip = car.Ip; + } + + if (job.RequireLatencyCheck) + { + var threshold = settings.RttThresholdMs; + var overMode = settings.OverThreshold; + foreach (var carId in job.CarIds.Distinct()) + { + var ip = job.Steps.FirstOrDefault(s => s.CarId == carId)?.Ip; + if (string.IsNullOrEmpty(ip)) + { + SkipCar(job, carId, "无 IP"); + continue; + } + var rtt = await _wd.MeasureRttMsAsync(ip, cts.Token); + if (rtt == null || rtt > threshold) + { + if (string.Equals(overMode, "skip", StringComparison.OrdinalIgnoreCase) || rtt == null) + SkipCar(job, carId, rtt == null ? "延迟探测失败" : $"RTT {rtt}ms > {threshold}ms"); + } + } + _store.SaveJob(job); + } + + job.Status = "running"; + _store.SaveJob(job); + + var maxCar = Math.Max(1, settings.MaxCar); + var carGroups = job.CarIds.Distinct() + .Where(id => job.Steps.Any(s => s.CarId == id && s.Status == "pending")) + .Chunk(maxCar); + + foreach (var batch in carGroups) + { + if (cts.IsCancellationRequested) break; + var tasks = batch.Select(carId => RunCarAsync(job, jobLock, carId, settings.BandwidthKbps, cts.Token)); + await Task.WhenAll(tasks); + } + + lock (jobLock) + { + if (cts.IsCancellationRequested) + { + foreach (var s in job.Steps.Where(s => s.Status is "pending" or "running")) + { + s.Status = "skipped"; + s.Error ??= "已取消"; + } + job.Status = "cancelled"; + job.FinishedAt = DateTimeOffset.UtcNow; + Recalc(job); + } + else + { + Finalize(job); + } + _store.SaveJob(job); + } + } + catch (Exception ex) + { + _log.LogError(ex, "OTA job {Id} crashed", jobId); + var job = _store.GetJob(jobId); + if (job != null) + { + job.Status = "failed"; + job.Message = ex.Message; + job.FinishedAt = DateTimeOffset.UtcNow; + _store.SaveJob(job); + } + } + finally + { + _running.TryRemove(jobId, out _); + cts.Dispose(); + } + } + + private static void SkipCar(OtaJob job, string carId, string reason) + { + foreach (var step in job.Steps.Where(s => s.CarId == carId && s.Status == "pending")) + { + step.Status = "skipped"; + step.Error = reason; + } + } + + private static List ResolveCustomFiles(OtaJob job) + { + if (job.CustomFiles is { Count: > 0 }) + return job.CustomFiles.Where(f => File.Exists(f.LocalPath)).ToList(); + if (!string.IsNullOrEmpty(job.CustomLocalPath) && File.Exists(job.CustomLocalPath)) + { + return new List + { + new() + { + LocalPath = job.CustomLocalPath, + FileName = job.CustomFileName ?? Path.GetFileName(job.CustomLocalPath) + } + }; + } + return new(); + } + + private async Task UploadCustomFilesForCarAsync(OtaJob job, string ip, int bandwidth, CancellationToken ct) + { + var files = ResolveCustomFiles(job); + if (files.Count == 0) throw new InvalidOperationException("自定义文件已丢失"); + var remote = job.CustomRemotePath ?? ""; + var ops = job.CustomRestartOps is { Count: > 0 } + ? job.CustomRestartOps + : new List { job.CustomRestartOp ?? -1 }; + + // 非末文件一律 -1;末文件按所选重启项依次再传(对齐 CarOTA.App) + for (var i = 0; i < files.Count; i++) + { + var f = files[i]; + var isLast = i == files.Count - 1; + if (!isLast) + { + await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct); + continue; + } + + if (ops.Count == 1 && ops[0] == -1) + { + await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, -1, bandwidth, ct); + } + else + { + foreach (var op in ops.Where(o => o != -1).DefaultIfEmpty(-1)) + await _wd.UploadCustomFileAsync(ip, f.LocalPath, f.FileName, remote, op, bandwidth, ct); + } + } + } + + private async Task RunCarAsync(OtaJob job, object jobLock, string carId, int bandwidth, CancellationToken ct) + { + // 只处理本车 step;同批其他车任务并行修改各自 step,共享同一 job 实例,写盘统一在 jobLock 下串行。 + var steps = job.Steps.Where(s => s.CarId == carId && s.Status == "pending").ToList(); + if (steps.Count == 0) return; + var ip = steps[0].Ip; + if (string.IsNullOrEmpty(ip)) + { + lock (jobLock) + { + foreach (var s in steps) { s.Status = "failed"; s.Error = "无 IP"; } + Recalc(job); + _store.SaveJob(job); + } + return; + } + + foreach (var step in steps) + { + if (ct.IsCancellationRequested) + { + lock (jobLock) + { + step.Status = "skipped"; + step.Error = "已取消"; + Recalc(job); + _store.SaveJob(job); + } + continue; + } + lock (jobLock) + { + step.Status = "running"; + Recalc(job); + _store.SaveJob(job); + } + try + { + switch (job.Kind) + { + case "sync": + { + var target = _store.GetTarget() ?? throw new InvalidOperationException("目标版本丢失"); + if (!target.Components.TryGetValue(step.Component, out var art)) + throw new InvalidOperationException($"组件 {step.Component} 不在目标中"); + await _wd.UploadComponentAsync(ip, step.Component, art.Path, art.FileName, bandwidth, ct); + break; + } + case "customFile": + await UploadCustomFilesForCarAsync(job, ip, bandwidth, ct); + break; + case "configPush": + await _wd.PutJsonAsync(ip, job.ConfigApp!, job.ConfigJson!, ct); + break; + } + lock (jobLock) + { + step.Status = "succeeded"; + step.Error = null; + Recalc(job); + _store.SaveJob(job); + } + } + catch (Exception ex) + { + lock (jobLock) + { + step.Status = ct.IsCancellationRequested ? "skipped" : "failed"; + step.Error = ct.IsCancellationRequested ? "已取消" : ex.Message; + Recalc(job); + _store.SaveJob(job); + } + _log.LogWarning(ex, "OTA step fail {Job} {Car} {Comp}", job.Id, carId, step.Component); + } + } + } + + private static void Recalc(OtaJob job) + { + job.DoneSteps = job.Steps.Count(s => s.Status is "succeeded" or "failed" or "skipped"); + } + + private static void Finalize(OtaJob job) + { + Recalc(job); + job.FinishedAt = DateTimeOffset.UtcNow; + var anyFail = job.Steps.Any(s => s.Status == "failed"); + var anyOk = job.Steps.Any(s => s.Status == "succeeded"); + var anyPending = job.Steps.Any(s => s.Status is "pending" or "running"); + if (job.Status == "cancelled") return; + if (anyPending) job.Status = "partial"; + else if (anyFail && anyOk) job.Status = "partial"; + else if (anyFail) job.Status = "failed"; + else if (anyOk) job.Status = "succeeded"; + else job.Status = "cancelled"; + } +} diff --git a/MiGu.Server/Ota/OtaModels.cs b/MiGu.Server/Ota/OtaModels.cs new file mode 100644 index 0000000..24fdc4f --- /dev/null +++ b/MiGu.Server/Ota/OtaModels.cs @@ -0,0 +1,144 @@ +namespace MiGu.Server.Ota; + +public sealed class OtaSettings +{ + public int BandwidthKbps { get; set; } + public int MaxCar { get; set; } = 2; + public bool LatencyEnabled { get; set; } + public int RttThresholdMs { get; set; } = 200; + /// skip | confirm + public string OverThreshold { get; set; } = "skip"; + public int BackupPeriodMinutes { get; set; } = 60; + public bool BackupExe { get; set; } + public string? NewVersionName { get; set; } +} + +public sealed class OtaFileArtifact +{ + public string Hash { get; set; } = ""; + public string Path { get; set; } = ""; + public string FileName { get; set; } = ""; + public string? Time { get; set; } + public long Size { get; set; } +} + +public sealed class OtaTarget +{ + public string PackageId { get; set; } = ""; + public string? Name { get; set; } + public DateTimeOffset ActivatedAt { get; set; } + public Dictionary Components { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class OtaPackageInfo +{ + public string Id { get; set; } = ""; + public string? SourceIp { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public long TotalBytes { get; set; } + public bool IsTarget { get; set; } + public Dictionary Components { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class OtaComponentVersion +{ + public string? Version { get; set; } + public string? Time { get; set; } +} + +public sealed class OtaAppVersions +{ + public OtaComponentVersion? Exe { get; set; } + public OtaComponentVersion? Dll { get; set; } + public OtaComponentVersion? Pdb { get; set; } +} + +public sealed class OtaVehicleRow +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string? Ip { get; set; } + public string? State { get; set; } + public string? Group { get; set; } + public bool Reachable { get; set; } + public int? RttMs { get; set; } + public OtaAppVersions? Medulla { get; set; } + public OtaAppVersions? Detour { get; set; } + public OtaAppVersions? Clumsy { get; set; } + public Dictionary? Match { get; set; } +} + +public sealed class OtaJobStep +{ + public string CarId { get; set; } = ""; + public string? Ip { get; set; } + public string Component { get; set; } = ""; + public string Status { get; set; } = "pending"; + public string? Error { get; set; } +} + +public sealed class OtaJob +{ + public string Id { get; set; } = ""; + /// sync | customFile | configPush + public string Kind { get; set; } = "sync"; + public string Status { get; set; } = "pending"; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? FinishedAt { get; set; } + public string? CreatedBy { get; set; } + public string? PackageId { get; set; } + public List CarIds { get; set; } = new(); + public List Components { get; set; } = new(); + public bool RequireLatencyCheck { get; set; } + public int DoneSteps { get; set; } + public int TotalSteps { get; set; } + public List Steps { get; set; } = new(); + public string? Message { get; set; } + // customFile + public string? CustomFileName { get; set; } + public string? CustomRemotePath { get; set; } + public int? CustomRestartOp { get; set; } + public string? CustomLocalPath { get; set; } + public List CustomFiles { get; set; } = new(); + public List CustomRestartOps { get; set; } = new() { -1 }; + // configPush + public string? ConfigApp { get; set; } + public string? ConfigJson { get; set; } +} + +public sealed class CreateSyncJobRequest +{ + public List CarIds { get; set; } = new(); + public List? Components { get; set; } + public bool? RequireLatencyCheck { get; set; } +} + +public sealed class PullPackageRequest +{ + public string CarId { get; set; } = ""; +} + +public sealed class CreateCustomFileJobRequest +{ + public List CarIds { get; set; } = new(); + public string RemotePath { get; set; } = ""; + /// 兼容旧单值;优先用 RestartOps。 + public int RestartOp { get; set; } = -1; + /// -1 不重启;0 Medulla;1 Clumsy;2 Detour;3 WatchDog。可多选。 + public List? RestartOps { get; set; } + public bool? RequireLatencyCheck { get; set; } +} + +public sealed class OtaCustomFileItem +{ + public string LocalPath { get; set; } = ""; + public string FileName { get; set; } = ""; +} + +public sealed class CreateConfigPushRequest +{ + public List CarIds { get; set; } = new(); + public string App { get; set; } = ""; + public string Json { get; set; } = ""; + public bool? RequireLatencyCheck { get; set; } +} diff --git a/MiGu.Server/Ota/OtaOptions.cs b/MiGu.Server/Ota/OtaOptions.cs new file mode 100644 index 0000000..abea524 --- /dev/null +++ b/MiGu.Server/Ota/OtaOptions.cs @@ -0,0 +1,24 @@ +namespace MiGu.Server.Ota; + +public sealed class OtaOptions +{ + /// 相对 ContentRoot 或绝对路径;默认 data/ota + public string DataRoot { get; set; } = "data/ota"; + + public int WatchDogPort { get; set; } = 9776; + + public int RequestTimeoutMs { get; set; } = 60_000; + + public int UploadTimeoutMs { get; set; } = 600_000; + + /// + /// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。 + /// + public int ReceivePort { get; set; } = 8000; + + /// + /// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。 + /// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。 + /// + public string? PublicBaseUrl { get; set; } +} diff --git a/MiGu.Server/Ota/OtaPathMap.cs b/MiGu.Server/Ota/OtaPathMap.cs new file mode 100644 index 0000000..5c0e6eb --- /dev/null +++ b/MiGu.Server/Ota/OtaPathMap.cs @@ -0,0 +1,53 @@ +namespace MiGu.Server.Ota; + +/// MDC 组件文件映射(对齐参考工具 MDCPath.json)。 +public static class OtaPathMap +{ + public static readonly IReadOnlyDictionary Default = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["M"] = new[] { "Medulla.exe", "plugins\\CartActivator.dll", "plugins\\CartActivator.pdb" }, + ["D"] = new[] { "Detour.exe" }, + ["C"] = new[] { "ClumsyConsole.exe", "FG2305014_C.dll", "FG2305014_C.pdb" } + }; + + public static readonly string[] ComponentKeys = + { + "M.exe", "M.dll", "M.pdb", "D.exe", "C.exe", "C.dll", "C.pdb" + }; + + public static string? RelPathFor(string componentKey) + { + return componentKey switch + { + "M.exe" => "M/Medulla.exe", + "M.dll" => "M/plugins/CartActivator.dll", + "M.pdb" => "M/plugins/CartActivator.pdb", + "D.exe" => "D/Detour.exe", + "C.exe" => "C/ClumsyConsole.exe", + "C.dll" => "C/FG2305014_C.dll", + "C.pdb" => "C/FG2305014_C.pdb", + _ => null + }; + } + + public static string? WatchDogUpdatePath(string componentKey) => componentKey switch + { + "M.exe" => "updateMedullaExecutable", + "M.dll" => "updateMedullaDll", + "M.pdb" => "UpdateMedullaPdb", + "D.exe" => "updateDetourExecutable", + "C.exe" => "updateClumsyExecutable", + "C.dll" => "updateClumsyDll", + "C.pdb" => "UpdateClumsyPdb", + _ => null + }; + + public static string AppFolder(string componentKey) => componentKey.StartsWith('M') ? "M" + : componentKey.StartsWith('D') ? "D" : "C"; + + public static string ExtKey(string componentKey) + { + var i = componentKey.IndexOf('.'); + return i >= 0 ? componentKey[(i + 1)..] : componentKey; + } +} diff --git a/MiGu.Server/Ota/OtaStore.cs b/MiGu.Server/Ota/OtaStore.cs new file mode 100644 index 0000000..55b22eb --- /dev/null +++ b/MiGu.Server/Ota/OtaStore.cs @@ -0,0 +1,368 @@ +using System.Text.Json; +using MiGu.Server.Infra; +using Microsoft.Extensions.Options; + +namespace MiGu.Server.Ota; + +public sealed class OtaStore +{ + private readonly object _gate = new(); + private readonly ILogger _log; + private readonly JsonSerializerOptions _json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public string Root { get; } + public string PackagesDir { get; } + public string JobsDir { get; } + public string HistoryDir { get; } + public string TargetFile { get; } + public string SettingsFile { get; } + + private string? _lastPullId; + private readonly System.Collections.Concurrent.ConcurrentDictionary _pullByIp = + new(StringComparer.OrdinalIgnoreCase); + private long _jobSeq; + + public OtaStore(IWebHostEnvironment env, IOptions options, ILogger log) + { + _log = log; + var cfg = options.Value.DataRoot; + Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg); + PackagesDir = Path.Combine(Root, "packages"); + JobsDir = Path.Combine(Root, "jobs"); + HistoryDir = Path.Combine(Root, "history"); + TargetFile = Path.Combine(Root, "target.json"); + SettingsFile = Path.Combine(Root, "settings.json"); + Directory.CreateDirectory(PackagesDir); + Directory.CreateDirectory(JobsDir); + Directory.CreateDirectory(HistoryDir); + } + + public OtaSettings GetSettings() + { + lock (_gate) + { + if (!File.Exists(SettingsFile)) return new OtaSettings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings(); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OTA settings load failed"); + return new OtaSettings(); + } + } + } + + public OtaSettings SaveSettings(OtaSettings settings) + { + lock (_gate) + { + AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json)); + return settings; + } + } + + public OtaTarget? GetTarget() + { + lock (_gate) + { + if (!File.Exists(TargetFile)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(TargetFile), _json); + } + catch (Exception ex) + { + _log.LogWarning(ex, "OTA target load failed"); + return null; + } + } + } + + public void SetTarget(OtaTarget target) + { + lock (_gate) + { + AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json)); + var settings = GetSettingsUnlocked(); + if (!string.IsNullOrWhiteSpace(target.Name)) + { + settings.NewVersionName = target.Name; + AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json)); + } + } + } + + private OtaSettings GetSettingsUnlocked() + { + if (!File.Exists(SettingsFile)) return new OtaSettings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings(); + } + catch + { + return new OtaSettings(); + } + } + + public string BeginPullPackage(string? sourceIp) + { + lock (_gate) + { + var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})"; + var id = baseId; + var dir = Path.Combine(PackagesDir, id); + for (var i = 1; Directory.Exists(dir); i++) + { + id = $"{baseId}-{i:D2}"; + dir = Path.Combine(PackagesDir, id); + } + Directory.CreateDirectory(dir); + foreach (var app in new[] { "M", "D", "C" }) + Directory.CreateDirectory(Path.Combine(dir, app)); + Directory.CreateDirectory(Path.Combine(dir, "M", "plugins")); + var ip = NormalizeIp(sourceIp); + if (ip != null) _pullByIp[ip] = id; + _lastPullId = id; + return id; + } + } + + public string? ActivePullId + { + get { lock (_gate) return _lastPullId; } + } + + public bool TryGetActivePullId(string? sourceIp, out string? id) + { + lock (_gate) + { + var ip = NormalizeIp(sourceIp); + if (ip != null && _pullByIp.TryGetValue(ip, out var byIp)) + { + id = byIp; + return true; + } + + if (ip == null && _lastPullId != null) + { + id = _lastPullId; + return true; + } + + id = null; + return false; + } + } + + public void ClearActivePull(string? sourceIp = null) + { + lock (_gate) + { + var ip = NormalizeIp(sourceIp); + if (ip != null) _pullByIp.TryRemove(ip, out _); + if (_pullByIp.IsEmpty) _lastPullId = null; + } + } + + /// 把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。 + private static string? NormalizeIp(string? ip) + { + if (string.IsNullOrWhiteSpace(ip)) return null; + if (System.Net.IPAddress.TryParse(ip, out var addr)) + return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString(); + return ip.Trim(); + } + + public string ResolveReceivePath(string routeKey, string? clientIp = null) + { + // routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll + // Do not fall back to the latest session for a known-but-unregistered client. + if (!TryGetActivePullId(clientIp, out var id) || id == null) + throw new InvalidOperationException("无进行中的拉取会话"); + var dir = Path.Combine(PackagesDir, id); + return routeKey.ToLowerInvariant() switch + { + "medullaexe" => Path.Combine(dir, "M", "Medulla.exe"), + "medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"), + "medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"), + "detourexe" => Path.Combine(dir, "D", "Detour.exe"), + "clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"), + "clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"), + "clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"), + _ => throw new ArgumentException($"未知接收路由: {routeKey}") + }; + } + + public OtaPackageInfo ScanPackage(string id) + { + var dir = ResolveUnder(PackagesDir, id); + if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id); + var info = new OtaPackageInfo + { + Id = id, + CreatedAt = Directory.GetCreationTimeUtc(dir), + SourceIp = ExtractSourceIp(id) + }; + long total = 0; + foreach (var key in OtaPathMap.ComponentKeys) + { + var rel = OtaPathMap.RelPathFor(key); + if (rel == null) continue; + var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(path)) continue; + var fi = new FileInfo(path); + total += fi.Length; + info.Components[key] = new OtaFileArtifact + { + Hash = OtaHash.OfFile(path), + Path = path, + FileName = Path.GetFileName(path), + Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"), + Size = fi.Length + }; + } + info.TotalBytes = total; + var target = GetTarget(); + info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal); + return info; + } + + public List ListPackages() + { + if (!Directory.Exists(PackagesDir)) return new(); + var list = new List(); + foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d)) + { + try { list.Add(ScanPackage(Path.GetFileName(dir))); } + catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); } + } + return list; + } + + public void DeletePackage(string id) + { + lock (_gate) + { + var target = GetTarget(); + if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal)) + throw new InvalidOperationException("不能删除当前目标版本"); + var dir = ResolveUnder(PackagesDir, id); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + } + } + + public OtaTarget ActivatePackage(string id, string? name) + { + var pkg = ScanPackage(id); + if (pkg.Components.Count == 0) + throw new InvalidOperationException("包内无有效组件文件"); + var safeId = RequireSafeId(id); + var target = new OtaTarget + { + PackageId = safeId, + Name = name ?? safeId, + ActivatedAt = DateTimeOffset.UtcNow, + Components = pkg.Components + }; + SetTarget(target); + return target; + } + + public string PackageDir(string id) => ResolveUnder(PackagesDir, id); + + public void SaveJob(OtaJob job) + { + lock (_gate) + { + var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json"); + AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json)); + } + } + + public OtaJob? GetJob(string id) + { + var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json"); + if (!File.Exists(path)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), _json); + } + catch (Exception ex) + { + _log.LogWarning(ex, "job load failed {Id}", id); + return null; + } + } + + public List ListJobs(int take = 100) + { + if (!Directory.Exists(JobsDir)) return new(); + return Directory.GetFiles(JobsDir, "*.json") + .Select(f => + { + try { return JsonSerializer.Deserialize(File.ReadAllText(f), _json); } + catch { return null; } + }) + .Where(j => j != null) + .Cast() + .OrderByDescending(j => j.CreatedAt) + .Take(take) + .ToList(); + } + + public string NextJobId() + { + // 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。 + var seq = System.Threading.Interlocked.Increment(ref _jobSeq); + return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}"; + } + + private static string? ExtractSourceIp(string id) + { + var open = id.IndexOf('('); + var close = id.IndexOf(')'); + if (open >= 0 && close > open) return id[(open + 1)..close]; + return null; + } + + private static string SafeIdPart(string raw) + { + var safe = raw.Trim(); + foreach (var ch in Path.GetInvalidFileNameChars()) + safe = safe.Replace(ch, '_'); + return string.IsNullOrWhiteSpace(safe) ? "upload" : safe; + } + + /// 拒绝路径段(含 .. / 分隔符),只允许单层文件名。 + private static string RequireSafeId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("无效标识"); + var trimmed = id.Trim(); + if (trimmed is "." or ".." + || trimmed.Contains('/') || trimmed.Contains('\\') + || trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("无效标识"); + return trimmed; + } + + private string ResolveUnder(string root, string id) + { + var safe = RequireSafeId(id); + var fullRoot = Path.GetFullPath(root); + var full = Path.GetFullPath(Path.Combine(fullRoot, safe)); + var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("无效标识"); + return full; + } +} diff --git a/MiGu.Server/Ota/OtaVehicleSource.cs b/MiGu.Server/Ota/OtaVehicleSource.cs new file mode 100644 index 0000000..bc50cbf --- /dev/null +++ b/MiGu.Server/Ota/OtaVehicleSource.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using MiGu.Server.Launcher; + +namespace MiGu.Server.Ota; + +public sealed class OtaVehicleSource +{ + private readonly IHttpClientFactory _httpFactory; + private readonly SimpleLiteOptions _sl; + private readonly InternalTokenStoreAccessor _token; + private readonly ILogger _log; + + public OtaVehicleSource( + IHttpClientFactory httpFactory, + IOptions sl, + InternalTokenStoreAccessor token, + ILogger log) + { + _httpFactory = httpFactory; + _sl = sl.Value; + _token = token; + _log = log; + } + + public async Task> ListCarsAsync(CancellationToken ct) + { + var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222; + var cars = await TryProjectionAsync(port, ct); + if (cars.Count == 0) + cars = await TryAgvListAsync(port, ct); + return cars; + } + + private async Task> TryProjectionAsync(int port, CancellationToken ct) + { + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(8); + using var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{port}/projection/cars"); + var token = _token.Token; + if (!string.IsNullOrEmpty(token)) + req.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", token); + using var resp = await client.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) return new(); + var text = await resp.Content.ReadAsStringAsync(ct); + return ParseCars(text); + } + catch (Exception ex) + { + _log.LogDebug(ex, "projection/cars failed"); + return new(); + } + } + + private async Task> TryAgvListAsync(int port, CancellationToken ct) + { + try + { + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(8); + using var resp = await client.GetAsync($"http://127.0.0.1:{port}/api/agv/list", ct); + if (!resp.IsSuccessStatusCode) return new(); + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + var arr = root.ValueKind == JsonValueKind.Array ? root + : root.TryGetProperty("data", out var d) ? d + : root.TryGetProperty("items", out var i) ? i + : default; + if (arr.ValueKind != JsonValueKind.Array) return new(); + var list = new List(); + foreach (var el in arr.EnumerateArray()) + { + var id = GetStr(el, "agv_id", "id", "carId") ?? ""; + var name = GetStr(el, "agv_name", "name") ?? id; + var ip = GetStr(el, "agv_ip", "ip"); + var state = GetStr(el, "status", "state"); + if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(ip)) continue; + list.Add(new OtaVehicleRow + { + Id = string.IsNullOrEmpty(id) ? ip! : id, + Name = name, + Ip = ip, + State = state + }); + } + return list; + } + catch (Exception ex) + { + _log.LogDebug(ex, "agv/list failed"); + return new(); + } + } + + private static List ParseCars(string text) + { + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + var arr = root.ValueKind == JsonValueKind.Array ? root + : root.TryGetProperty("cars", out var c) ? c + : root.TryGetProperty("items", out var i) ? i + : root.TryGetProperty("data", out var d) ? d + : default; + if (arr.ValueKind != JsonValueKind.Array) return new(); + var list = new List(); + foreach (var el in arr.EnumerateArray()) + { + var id = GetStr(el, "id", "carId", "rawId") ?? ""; + var name = GetStr(el, "name") ?? id; + var ip = GetStr(el, "ip"); + var state = GetStr(el, "state", "status"); + var group = GetStr(el, "group"); + if (string.IsNullOrEmpty(id)) continue; + list.Add(new OtaVehicleRow { Id = id, Name = name, Ip = ip, State = state, Group = group }); + } + return list; + } + + private static string? GetStr(JsonElement el, params string[] names) + { + foreach (var n in names) + { + if (el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.String) + return p.GetString(); + if (el.TryGetProperty(n, out p) && p.ValueKind is JsonValueKind.Number) + return p.ToString(); + } + return null; + } +} + +/// 避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。 +public sealed class InternalTokenStoreAccessor +{ + private readonly Auth.InternalTokenStore _store; + public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store; + public string Token => _store.Token; +} diff --git a/MiGu.Server/Ota/WatchDogClient.cs b/MiGu.Server/Ota/WatchDogClient.cs new file mode 100644 index 0000000..3c523d4 --- /dev/null +++ b/MiGu.Server/Ota/WatchDogClient.cs @@ -0,0 +1,289 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace MiGu.Server.Ota; + +public sealed class WatchDogClient +{ + private readonly IHttpClientFactory _httpFactory; + private readonly OtaOptions _opt; + private readonly ILogger _log; + private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true }; + + public WatchDogClient(IHttpClientFactory httpFactory, IOptions opt, ILogger log) + { + _httpFactory = httpFactory; + _opt = opt.Value; + _log = log; + } + + private HttpClient CreateClient(int? timeoutMs = null) + { + var c = _httpFactory.CreateClient(nameof(WatchDogClient)); + c.Timeout = TimeSpan.FromMilliseconds(timeoutMs ?? _opt.RequestTimeoutMs); + return c; + } + + private string Base(string ip) => $"http://{ip}:{_opt.WatchDogPort}"; + + public async Task<(bool Ok, OtaAppVersions? M, OtaAppVersions? D, OtaAppVersions? C, string? Error)> GetMdcInfoAsync(string ip, CancellationToken ct) + { + try + { + using var client = CreateClient(); + using var resp = await client.GetAsync($"{Base(ip)}/getMDCInfo", ct); + if (!resp.IsSuccessStatusCode) + return (false, null, null, null, $"HTTP {(int)resp.StatusCode}"); + var text = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + var root = doc.RootElement; + return (true, ParseApp(root, "Medulla"), ParseApp(root, "Detour"), ParseApp(root, "Clumsy"), null); + } + catch (Exception ex) + { + return (false, null, null, null, ex.Message); + } + } + + private static OtaAppVersions? ParseApp(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var app) && !root.TryGetProperty(name.ToLowerInvariant(), out app)) + return null; + return new OtaAppVersions + { + Exe = ParseComp(app, "exe"), + Dll = ParseComp(app, "dll"), + Pdb = ParseComp(app, "pdb") + }; + } + + private static OtaComponentVersion? ParseComp(JsonElement app, string key) + { + if (!app.TryGetProperty(key, out var c)) return null; + string? ver = null; + string? time = null; + if (c.ValueKind == JsonValueKind.Object) + { + if (c.TryGetProperty("version", out var v)) + ver = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString(); + if (c.TryGetProperty("time", out var t)) + time = t.GetString(); + } + else if (c.ValueKind == JsonValueKind.String) + { + ver = c.GetString(); + } + return new OtaComponentVersion { Version = ver, Time = time }; + } + + public async Task MeasureRttMsAsync(string ip, CancellationToken ct) + { + try + { + var sw = Stopwatch.StartNew(); + using var tcp = new TcpClient(); + using var reg = ct.Register(() => { try { tcp.Close(); } catch { /* ignore */ } }); + var connectTask = tcp.ConnectAsync(ip, _opt.WatchDogPort); + var done = await Task.WhenAny(connectTask, Task.Delay(Math.Min(3000, _opt.RequestTimeoutMs), ct)); + if (done != connectTask || !tcp.Connected) return null; + await connectTask; + sw.Stop(); + return (int)sw.ElapsedMilliseconds; + } + catch + { + return null; + } + } + + public async Task TriggerPullAsync(string ip, string serverBaseUrl, string time, CancellationToken ct) + { + // 现网 WatchDog 忽略 server 查询参数,固定 POST 到 http://{config.serverIP}:8000/upload-mdcs/{key}。 + // serverBaseUrl 仅作日志/未来兼容;真正要通必须:车上 serverIP=本机局域网 IP,且本机监听 ReceivePort。 + using var client = CreateClient(_opt.UploadTimeoutMs); + var url = $"{Base(ip)}/getmdcsexe?time={Uri.EscapeDataString(time)}&server={Uri.EscapeDataString(serverBaseUrl.TrimEnd('/'))}"; + try + { + using var resp = await client.GetAsync(url, ct); + var body = (await resp.Content.ReadAsStringAsync(ct)).Trim(); + _log.LogInformation("getmdcsexe {Ip} -> {Code} body={Body} (WatchDog will POST to its config.serverIP:{Port}/upload-mdcs/*; expect receiver {Base})", + ip, (int)resp.StatusCode, body.Length > 200 ? body[..200] : body, _opt.ReceivePort, serverBaseUrl); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException($"WatchDog getmdcsexe HTTP {(int)resp.StatusCode}: {body}"); + if (body.Contains("请配置", StringComparison.Ordinal) + || body.Equals("false", StringComparison.OrdinalIgnoreCase) + || body.Equals("\"false\"", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "WatchDog 拒绝拉包或回传失败。请确认:1) 车已配置 Medulla/Detour/Clumsy 路径;" + + $"2) watch_dog.json 的 serverIP 指向本机局域网 IP(车将 POST 到 serverIP:{_opt.ReceivePort}/upload-mdcs/*);" + + $"3) 本机已监听 :{_opt.ReceivePort}。WatchDog 返回:{body}"); + } + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + _log.LogWarning(ex, "getmdcsexe failed {Ip}", ip); + throw; + } + } + + public async Task UploadComponentAsync(string ip, string componentKey, string localPath, string fileName, int bandwidthKbps, CancellationToken ct) + { + var endpoint = OtaPathMap.WatchDogUpdatePath(componentKey) + ?? throw new ArgumentException($"未知组件 {componentKey}"); + await UploadFileAsync($"{Base(ip)}/{endpoint}", localPath, fileName, bandwidthKbps, null, ct); + } + + public async Task UploadCustomFileAsync(string ip, string localPath, string fileName, string remotePath, int restartOp, int bandwidthKbps, CancellationToken ct) + { + var url = $"{Base(ip)}/updateFile/{Uri.EscapeDataString(fileName)}/{restartOp}/"; + await UploadFileAsync(url, localPath, fileName, bandwidthKbps, new Dictionary { ["path"] = remotePath }, ct); + } + + private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary? extraFields, CancellationToken ct) + { + using var client = CreateClient(_opt.UploadTimeoutMs); + await using var fs = File.OpenRead(localPath); + Stream contentStream = fs; + if (bandwidthKbps > 0) + contentStream = new ThrottledStream(fs, bandwidthKbps * 1024L); + + using var form = new MultipartFormDataContent(); + if (extraFields != null) + { + foreach (var (k, v) in extraFields) + form.Add(new StringContent(v, Encoding.UTF8), k); + } + var streamContent = new StreamContent(contentStream); + form.Add(streamContent, "file", fileName); + + using var resp = await client.PostAsync(url, form, ct); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"上传失败 HTTP {(int)resp.StatusCode}: {body}"); + } + } + + public async Task GetJsonAsync(string ip, string app, CancellationToken ct) + { + var path = app.ToLowerInvariant() switch + { + "medulla" => "getMedullajson", + "detour" => "getDetourjson", + "clumsy" => "getClumsyjson", + _ => throw new ArgumentException("app 须为 medulla|detour|clumsy") + }; + using var client = CreateClient(); + using var resp = await client.GetAsync($"{Base(ip)}/{path}", ct); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsStringAsync(ct); + } + + public async Task PutJsonAsync(string ip, string app, string json, CancellationToken ct) + { + var path = app.ToLowerInvariant() switch + { + "medulla" => "updateMedullajson", + "detour" => "updateDetourjson", + "clumsy" => "updateClumsyjson", + _ => throw new ArgumentException("app 须为 medulla|detour|clumsy") + }; + using var client = CreateClient(); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var resp = await client.PostAsync($"{Base(ip)}/{path}", content, ct); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"更新 JSON 失败 HTTP {(int)resp.StatusCode}: {body}"); + } + } + + /// 简易限速流:按字节/秒节流读取。 + private sealed class ThrottledStream : Stream + { + private readonly Stream _inner; + private readonly long _bytesPerSecond; + private long _windowBytes; + private long _windowStart = Environment.TickCount64; + + public ThrottledStream(Stream inner, long bytesPerSecond) + { + _inner = inner; + _bytesPerSecond = Math.Max(1024, bytesPerSecond); + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => throw new NotSupportedException(); } + public override void Flush() => _inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + var n = _inner.Read(buffer, offset, count); + if (n > 0) Throttle(n); + return n; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var n = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + if (n > 0) await ThrottleAsync(n, cancellationToken); + return n; + } + + private void Throttle(int n) + { + _windowBytes += n; + var elapsed = Environment.TickCount64 - _windowStart; + if (elapsed < 1) elapsed = 1; + var allowed = _bytesPerSecond * elapsed / 1000; + if (_windowBytes > allowed) + { + var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond); + if (wait > 0) Thread.Sleep(Math.Min(wait, 2000)); + } + if (elapsed >= 1000) + { + _windowBytes = 0; + _windowStart = Environment.TickCount64; + } + } + + private async Task ThrottleAsync(int n, CancellationToken ct) + { + _windowBytes += n; + var elapsed = Environment.TickCount64 - _windowStart; + if (elapsed < 1) elapsed = 1; + var allowed = _bytesPerSecond * elapsed / 1000; + if (_windowBytes > allowed) + { + var wait = (int)((_windowBytes - allowed) * 1000 / _bytesPerSecond); + if (wait > 0) await Task.Delay(Math.Min(wait, 2000), ct); + } + if (elapsed >= 1000) + { + _windowBytes = 0; + _windowStart = Environment.TickCount64; + } + } + + protected override void Dispose(bool disposing) + { + // 不释放 inner(由调用方 using FileStream) + base.Dispose(disposing); + } + } +} diff --git a/MiGu.Server/Persistence/PlatformDbContext.cs b/MiGu.Server/Persistence/PlatformDbContext.cs index 4b83d0e..1e2d345 100644 --- a/MiGu.Server/Persistence/PlatformDbContext.cs +++ b/MiGu.Server/Persistence/PlatformDbContext.cs @@ -27,6 +27,8 @@ public sealed class PlatformDbContext : DbContext public DbSet WmsTransportTaskHistories => Set(); public DbSet SimpleFields => Set(); public DbSet UserDashboardShortcuts => Set(); + public DbSet CdmTasks => Set(); + public DbSet VehicleAlarms => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -85,6 +87,12 @@ public sealed class PlatformDbContext : DbContext modelBuilder.Entity().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt }); modelBuilder.Entity().HasIndex(x => x.TypeCode); modelBuilder.Entity().HasIndex(x => x.ContainerId).IsUnique(); + // 库位占用 1:1:同一 Storage LocationId 同时只能有一条未删除记录(Car 等其它类型不限) + modelBuilder.Entity() + .HasIndex(x => x.LocationId) + .IsUnique() + .HasFilter("LocationType = 'Storage' AND IsDeleted = 0") + .HasDatabaseName("IX_wms_container_locations_StorageLocationId"); modelBuilder.Entity().HasIndex(x => new { x.LocationType, x.LocationId }); modelBuilder.Entity().HasIndex(x => x.MaterialId).IsUnique(); modelBuilder.Entity().HasIndex(x => x.ContainerId); @@ -103,6 +111,61 @@ public sealed class PlatformDbContext : DbContext ConfigureSimpleField(modelBuilder); ConfigureUserDashboardShortcut(modelBuilder); + ConfigureCdmTask(modelBuilder); + ConfigureVehicleAlarm(modelBuilder); + } + + private static void ConfigureVehicleAlarm(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + e.ToTable("vehicle_alarms"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36); + e.Property(x => x.CarId).HasColumnName("car_id"); + e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128); + e.Property(x => x.Info).HasColumnName("info").HasColumnType("text"); + e.Property(x => x.Level).HasColumnName("level"); + e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16); + e.Property(x => x.FirstAt).HasColumnName("first_at"); + e.Property(x => x.LastAt).HasColumnName("last_at"); + e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false); + e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false); + e.Property(x => x.Acknowledged).HasColumnName("acknowledged"); + e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false); + e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false); + e.HasIndex(x => new { x.CarId, x.Status }); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.FirstAt); + } + + private static void ConfigureCdmTask(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + e.ToTable("cdm_tasks"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64); + e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false); + e.Property(x => x.MissionId).HasColumnName("mission_id"); + e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128); + e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128); + e.Property(x => x.SrcSiteId).HasColumnName("src_site_id"); + e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256); + e.Property(x => x.DstSiteId).HasColumnName("dst_site_id"); + e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256); + e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32); + e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32); + e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false); + e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false); + e.Property(x => x.Priority).HasColumnName("priority"); + e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false); + e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false); + e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false); + e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false); + e.Property(x => x.Overdue).HasColumnName("overdue"); + e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at"); + e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at"); + e.HasIndex(x => x.StatusCode); + e.HasIndex(x => x.CreateTime); } private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder) diff --git a/MiGu.Server/Persistence/PlatformPersistence.cs b/MiGu.Server/Persistence/PlatformPersistence.cs index f4b45e6..5ef45b6 100644 --- a/MiGu.Server/Persistence/PlatformPersistence.cs +++ b/MiGu.Server/Persistence/PlatformPersistence.cs @@ -59,6 +59,8 @@ public static class PlatformPersistence await EnsureUserDashboardShortcutsTableAsync(db); await EnsureWmsTransportSchemaAsync(db); await EnsureWmsStructureSchemaAsync(db); + await EnsureCdmTasksTableAsync(db); + await EnsureVehicleAlarmsTableAsync(db); await MigrateWmsLegacyAsync(scope.ServiceProvider); } @@ -185,6 +187,83 @@ public static class PlatformPersistence await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''"); } + /// 为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。 + private static async Task EnsureVehicleAlarmsTableAsync(PlatformDbContext db) + { + if (db.Database.IsSqlite()) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS vehicle_alarms ( + id TEXT NOT NULL CONSTRAINT PK_vehicle_alarms PRIMARY KEY, + car_id INTEGER NOT NULL DEFAULT 0, + car_name TEXT NOT NULL DEFAULT '', + info TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + first_at TEXT NOT NULL, + last_at TEXT NOT NULL, + resolved_at TEXT, + duration_secs INTEGER, + acknowledged INTEGER NOT NULL DEFAULT 0, + acknowledged_at TEXT, + acknowledged_by TEXT + ); + """); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_car_status ON vehicle_alarms (car_id, status);"); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_status ON vehicle_alarms (status);"); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_first_at ON vehicle_alarms (first_at);"); + return; + } + + if (!await TableExistsAsync(db, "vehicle_alarms")) + { + var creator = db.GetService(); + await creator.CreateTablesAsync(); + } + } + + /// 为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。 + private static async Task EnsureCdmTasksTableAsync(PlatformDbContext db) + { + if (db.Database.IsSqlite()) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS cdm_tasks ( + id TEXT NOT NULL CONSTRAINT PK_cdm_tasks PRIMARY KEY, + task_id TEXT, + mission_id INTEGER NOT NULL DEFAULT 0, + mission_name TEXT NOT NULL DEFAULT '', + mission_type TEXT NOT NULL DEFAULT '', + src_site_id INTEGER NOT NULL DEFAULT 0, + src_label TEXT NOT NULL DEFAULT '', + dst_site_id INTEGER NOT NULL DEFAULT 0, + dst_label TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + status_code TEXT NOT NULL DEFAULT '', + car_id INTEGER, + car_name TEXT, + priority INTEGER NOT NULL DEFAULT 0, + create_time TEXT, + start_time TEXT, + finish_time TEXT, + stuck_reason TEXT, + overdue INTEGER NOT NULL DEFAULT 0, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL + ); + """); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_status_code ON cdm_tasks (status_code);"); + await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_create_time ON cdm_tasks (create_time);"); + return; + } + + if (!await TableExistsAsync(db, "cdm_tasks")) + { + var creator = db.GetService(); + await creator.CreateTablesAsync(); + } + } + /// 为已存在的数据库补建 simple_fields 表(幂等)。 private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db) { @@ -339,10 +418,33 @@ public static class PlatformPersistence SnapshotJson TEXT NOT NULL ); """); + + // 库位占用唯一约束(幂等);若库内已有重复占用会创建失败,不阻断启动 + try + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_container_locations_StorageLocationId + ON wms_container_locations (LocationId) + WHERE LocationType = 'Storage' AND IsDeleted = 0; + """); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"[WMS] 无法创建库位占用唯一索引 IX_wms_container_locations_StorageLocationId(可能已有重复占用): {ex.Message}"); + } } private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition) { + // 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白。 + static bool IsSafeIdent(string s) => + !string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_'); + if (!IsSafeIdent(table) || !IsSafeIdent(column) + || definition.Contains(';') || definition.Contains("--") + || !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$")) + throw new ArgumentException("unsafe sqlite migration identifier"); + var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(); diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index b26ffdb..ecdd69d 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -7,6 +7,7 @@ using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; using MiGu.Server.OpenApi; +using MiGu.Server.Ota; using MiGu.Server.Persistence; using Yarp.ReverseProxy.Transforms; @@ -31,12 +32,27 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory }); -if (string.IsNullOrWhiteSpace(builder.Configuration["urls"]) - && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")) - && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS"))) +// 管理面默认 :8080;WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。 { - // 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。 - builder.WebHost.UseUrls("http://0.0.0.0:8080"); + var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000); + var urls = builder.Configuration["urls"] + ?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS") + ?? Environment.GetEnvironmentVariable("DOTNET_URLS"); + if (string.IsNullOrWhiteSpace(urls)) + urls = "http://0.0.0.0:8080"; + + var parts = urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var hasReceive = parts.Any(u => + u.Contains($":{receivePort}", StringComparison.OrdinalIgnoreCase) + || u.EndsWith($":{receivePort}/", StringComparison.OrdinalIgnoreCase)); + if (!hasReceive) + { + urls = string.Join(';', parts.Append($"http://0.0.0.0:{receivePort}")); + // 安全提示:该端口挂匿名 upload-mdcs/upload-history(WatchDog 写死回传)。 + // upload-mdcs 仅在有进行中的拉取会话时可写入,其余管理端点仍需 JWT。请确保本机处于可信内网。 + Console.WriteLine($"[MiGu.Server] OTA 回传端口 {receivePort} 已监听(0.0.0.0):匿名接收车辆包,仅限可信内网。"); + } + builder.WebHost.UseUrls(urls); } // S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/ @@ -95,6 +111,11 @@ builder.Services.AddControllers() opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; opt.JsonSerializerOptions.WriteIndented = false; }); +// WatchDog 回传 M/D/C 可较大;放宽 multipart 默认 128MB 限制 +builder.Services.Configure(o => +{ + o.MultipartBodyLengthLimit = 512_000_000; +}); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => @@ -207,6 +228,25 @@ builder.Services.AddReverseProxy() var store = rt.HttpContext.RequestServices.GetRequiredService(); rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token"); rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token); + + var user = rt.HttpContext.User; + var username = user.FindFirst("unique_name")?.Value + ?? user.Identity?.Name + ?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + ?? user.FindFirst("sub")?.Value; + var userId = user.FindFirst("sub")?.Value + ?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var scope = user.FindFirst("scope")?.Value; + + rt.ProxyRequest.Headers.Remove("X-Platform-User"); + rt.ProxyRequest.Headers.Remove("X-Platform-User-Id"); + rt.ProxyRequest.Headers.Remove("X-Platform-Scope"); + if (!string.IsNullOrWhiteSpace(username)) + rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User", username); + if (!string.IsNullOrWhiteSpace(userId)) + rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User-Id", userId); + if (!string.IsNullOrWhiteSpace(scope)) + rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-Scope", scope); return ValueTask.CompletedTask; }); }); @@ -224,6 +264,20 @@ builder.Services.AddPlatformPersistence(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("SimpleLite")); builder.Services.AddSingleton(); +// OTA(WatchDog 编排):包库 / 任务 / 出站客户端 +builder.Services.Configure(builder.Configuration.GetSection("Ota")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient(nameof(WatchDogClient)); + var app = builder.Build(); await app.Services.EnsurePlatformDatabaseAsync(); diff --git a/MiGu.Server/Properties/launchSettings.json b/MiGu.Server/Properties/launchSettings.json index 5a626a4..42eafe7 100644 --- a/MiGu.Server/Properties/launchSettings.json +++ b/MiGu.Server/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://0.0.0.0:8080", + "applicationUrl": "http://0.0.0.0:8080;http://0.0.0.0:8000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/MiGu.Server/appsettings.json b/MiGu.Server/appsettings.json index 2c97ef8..346519e 100644 --- a/MiGu.Server/appsettings.json +++ b/MiGu.Server/appsettings.json @@ -44,6 +44,15 @@ "Dispatch": { } }, + "_comment_Ota": "车辆 OTA:包与任务落盘 DataRoot;WatchDog:9776。拉包时车会 POST 到 WatchDog 配置的 serverIP:ReceivePort/upload-mdcs/*(默认 8000,与旧 Electron OTA 一致)。请把各车 watch_dog.json 的 serverIP 设为本机局域网 IP。", + "Ota": { + "DataRoot": "data/ota", + "WatchDogPort": 9776, + "ReceivePort": 8000, + "RequestTimeoutMs": 60000, + "UploadTimeoutMs": 600000, + "PublicBaseUrl": "" + }, "_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。", "ReverseProxy": { "Routes": { @@ -65,6 +74,15 @@ { "PathRemovePrefix": "/api/sl" } ] }, + "sl-assistant-route": { + "ClusterId": "sl-cluster", + "AuthorizationPolicy": "PlatformScope", + "Order": -2, + "Match": { "Path": "/api/sl/projection/assistant/{**catch-all}" }, + "Transforms": [ + { "PathRemovePrefix": "/api/sl" } + ] + }, "sl-reflection-selection-route": { "ClusterId": "sl-cluster", "AuthorizationPolicy": "AnyAuthed", diff --git a/docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md b/docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md new file mode 100644 index 0000000..a019cf6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-migu-ota-watchdog.md @@ -0,0 +1,117 @@ +# 迷榖 OTA(WatchDog)实现计划 + +> **状态:** 核心任务已落地(后端 API + 前端工作台)。联调 WatchDog 实车需现场验证。 +> **面向 AI 代理的工作者:** 按任务顺序实现;每完成一大任务做一次验证。规格:`docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md`。 + +**目标:** 车辆运维改为「运维总览 + OTA」,平台编排 WatchDog 完成包库/下发/任务/配置/自定义文件/设置与延迟检测。 +**架构:** MiGu.Server `api/ota/*` 出站调 WatchDog `:9776`;包与任务落盘 `data/ota/`;前端 `OtaWorkbenchView` 只调平台 API。 +**技术栈:** ASP.NET Core 8、Vue 3、Element Plus、HttpClient、现有 JWT/RBAC。 + +--- + +## 文件结构 + +### 后端(新建) + +| 文件 | 职责 | +|------|------| +| `MiGu.Server/Ota/OtaOptions.cs` | DataRoot、WatchDogPort、超时、本机接收端口 | +| `MiGu.Server/Ota/OtaModels.cs` | Package/Target/Job/Settings/VehicleRow DTO | +| `MiGu.Server/Ota/OtaPathMap.cs` | MDC 路径与组件键 | +| `MiGu.Server/Ota/OtaHash.cs` | MD5 Base64 去 `-` | +| `MiGu.Server/Ota/OtaStore.cs` | packages/target/jobs/settings/history 读写 | +| `MiGu.Server/Ota/WatchDogClient.cs` | getMDCInfo、update*、get*json、getmdcsexe、latency | +| `MiGu.Server/Ota/OtaPackageReceiver.cs` | 供 WatchDog 回传 upload-mdcs* 的内部端点宿主或同进程路由 | +| `MiGu.Server/Ota/OtaJobRunner.cs` | 分批、限速、状态机、重试/取消 | +| `MiGu.Server/Ota/OtaVehicleSource.cs` | 从 SimpleLite projection 取车列表 | +| `MiGu.Server/Controllers/OtaController.cs` | `/api/ota/*` | +| `MiGu.Server/Controllers/OtaReceiveController.cs` | WatchDog 回传 `/api/ota/receive/upload-mdcs*` | + +### 后端(修改) + +| 文件 | 变更 | +|------|------| +| `MiGu.Server/Program.cs` | 注册 OTA 服务 | +| `MiGu.Server/appsettings.json` | `Ota` 节 | + +### 前端(新建) + +| 文件 | 职责 | +|------|------| +| `src/api/ota.ts` | API 客户端 | +| `src/types/ota.ts` | 类型 | +| `src/views/shared/ota/OtaWorkbenchView.vue` | 左导航壳 + 顶条 | +| `src/views/shared/ota/OtaVehiclesPane.vue` | 车辆升级 | +| `src/views/shared/ota/OtaPackagesPane.vue` | 版本库 | +| `src/views/shared/ota/OtaJobsPane.vue` | 任务中心 | +| `src/views/shared/ota/OtaConfigPane.vue` | 配置同步 | +| `src/views/shared/ota/OtaCustomFilePane.vue` | 自定义文件 | +| `src/views/shared/ota/OtaSettingsPane.vue` | 设置 | +| `src/composables/useOtaWorkbench.ts` | 目标版本、设置、刷新 | + +### 前端(修改) + +| 文件 | 变更 | +|------|------| +| `src/views/shared/VehicleHubView.vue` | Tab:overview \| ota;重定向旧 tab | + +--- + +## 任务 + +### 任务 1:后端基础(Options / Models / Store / Hash) + +1. 创建 `OtaOptions`、`OtaModels`、`OtaPathMap`、`OtaHash`、`OtaStore`。 +2. Settings 默认:bandwidth=0(不限)、maxCar=2、latencyEnabled=false、rttThresholdMs=200、overThreshold=`skip`、backupPeriodMinutes=60、backupExe=false。 +3. `Program.cs` + `appsettings.json` 注册。 +4. 验证:`dotnet build MiGu.Server/MiGu.Server.csproj` 通过。 + +### 任务 2:WatchDogClient + 车辆源 + Receive + +1. `WatchDogClient`:MDCInfo、组件上传(multipart)、JSON get/put、触发 getmdcsexe、TCP/HTTP RTT。 +2. `OtaVehicleSource`:HttpClient 调 SimpleLite `http://127.0.0.1:8222/api/agv/list` 或 projection cars(与现有一致优先 projection)。 +3. `OtaReceiveController`:接收 upload-mdcs* 写入当前 pull 会话目录。 +4. 验证:build 通过。 + +### 任务 3:JobRunner + OtaController API + +实现规格 §4.2 全部端点;JobRunner 支持 sync/custom/config 三类 job;审计写入 `OpsAuditStore` 或 `data/ota/audit.json`。 +验证:build 通过;手动 curl GET settings/packages。 + +### 任务 4:前端 API + 类型 + Hub Tab 切换 + +1. `types/ota.ts`、`api/ota.ts`。 +2. `VehicleHubView` 仅 overview/ota;挂载 `OtaWorkbenchView`。 +3. 验证:前端 typecheck/dev 可加载。 + +### 任务 5:OTA 工作台 UI(六子页) + +按规格 §5 实现各 Pane;延迟检测开关在车辆升级工具条。 +验证:页面可切换、设置可保存、无目标时下发被拦截。 + +### 任务 6:联调与验收 + +对照规格 §9 验收清单;修明显 bug。 + +--- + +## 关键实现约定 + +- MD5:`Convert.ToBase64String(MD5.HashData(bytes)).Replace("-", "")`(与参考工具一致则对照其实现)。 +- 组件键:`M.exe` `M.dll` `M.pdb` `D.exe` `C.exe` `C.dll` `C.pdb`。 +- Job kind:`sync` | `customFile` | `configPush`。 +- 进度字段:`doneSteps` / `totalSteps`;每车每组件 `status`:pending|running|succeeded|failed|skipped。 +- 拉取包:平台记录 `pendingPullId` + 本机可达 URL,调车 `getmdcsexe?time=`;车推到 `/api/ota/receive/...`。 + +--- + +## 规格覆盖自检 + +| 规格项 | 任务 | +|--------|------| +| IA / 删维护策略与生命周期 | 4 | +| WatchDog 编排 | 2–3 | +| 包库/目标/任务/设置/延迟 | 3、5 | +| 配置同步/自定义文件 | 3、5 | +| 审计 | 3 | +| 验收 §9 | 6 | diff --git a/docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md b/docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md new file mode 100644 index 0000000..fae2b69 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md @@ -0,0 +1,258 @@ +# 迷榖车辆运维 OTA(WatchDog)设计规格 + +**日期:** 2026-07-19 +**状态:** 已批准并实现中 +**范围:** 在迷榖「车辆运维」中交付完整 OTA 能力;车上协议沿用 WatchDog;管理面由 MiGu.Server 编排。 + +--- + +## 1. 背景与目标 + +### 1.1 参考实现 + +`E:\Work\FRLD\OTA\ota` 为 Electron OTA 管理工具:版本拉取/管理、多车 M/D/C 同步、自定义文件、配置 JSON 下发、带宽限速与分批。车上依赖 WatchDog(`:9776`)。参考工具缺少任务状态机、可靠进度与正式回滚。 + +### 1.2 迷榖现状 + +- 「车辆运维」=`VehicleHubView`:运维总览 / 维护策略 / 车队生命周期。 +- OTA 仅为 `OtaPolicy`(enabled / batchSize / rollbackOnFail)只读占位,无包库、下发、进度或审计。 + +### 1.3 目标 + +1. 车辆运维顶部仅保留 **运维总览** 与 **OTA**。 +2. 删除「维护策略」「车队生命周期」Tab 及维护策略配置页(本期不迁移)。 +3. OTA 全面对齐参考工具能力,并补齐平台侧任务进度、失败重试、审计。 +4. 车上协议 **沿用 WatchDog**;浏览器不直连车辆。 +5. UI 按迷榖运维工作台语言重做,不照搬 Electron 通用后台壳。 + +### 1.4 非目标(本期) + +- 差分/增量包、签名验签、A/B 双分区 +- 空闲时段自动升级 +- 浏览器直连 WatchDog +- 维护策略配置的任何入口保留或迁移 + +--- + +## 2. 架构决策 + +**选定:方案 A — 平台编排。** + + +| 组件 | 职责 | +| ------------------ | ---------------------------- | +| 前端 | 仅调用 `/api/ota/`*,展示对照/进度/设置 | +| MiGu.Server OTA 模块 | 包存储、目标版本、任务状态机、分批、限速、延迟探测、审计 | +| 车队名单 | 复用现有投影/车辆列表(IP、名称、状态) | +| WatchDog `:9776` | 装包、读版本、回传包、读写 JSON、自定义文件 | + + +--- + +## 3. 信息架构 + +### 3.1 车辆运维 Tab + + +| Tab | 内容 | +| ---- | ------------------ | +| 运维总览 | 保持现有:健康卡片、维护态、车队分配 | +| OTA | 新工作台 | + + +- 路由:`/admin/config/vehicle-hub?tab=ota`(监控侧同理)。 +- 旧 `?tab=maintenance` / `?tab=fleet` 深链重定向到 `ota`。 + +### 3.2 OTA 子导航(左侧) + +1. **车辆升级** — 版本对照、勾选、分组件/全量同步、延迟检测开关 +2. **版本库** — 从车拉取 / 本地上传、设为目标、清理 +3. **任务中心** — 进行中/历史、进度、重试、取消 +4. **配置同步** — Medulla/Detour/Clumsy JSON 浏览、编辑、多车下发 +5. **自定义文件** — 文件 + 车上路径 + 重启策略 +6. **设置** — 带宽、并发、备份、延迟检测阈值与门禁策略 + +### 3.3 与运维总览的边界 + + +| 能力 | Tab | +| ------------------------------------ | ---- | +| 健康、电量、报警、维护态、车队分配、开车上页 | 运维总览 | +| 版本对照、包库、下发、JSON/自定义文件、任务、OTA 设置、延迟检测 | OTA | + + +运维总览不强制展示完整 MDC 哈希;若后续加「版本落后」标记,仅作跳转 OTA 的入口。 + +--- + +## 4. 后端:存储、API、任务 + +### 4.1 存储布局 + +根目录:`MiGu.Server/data/ota/`(可配置) + + +| 路径 | 用途 | +| ---------------- | ----------------------- | +| `packages/{id}/` | 一次拉取或上传的 M/D/C 文件树 | +| `target.json` | 当前目标版本(等价参考 `ota.json`) | +| `jobs/` | 任务元数据与事件日志 | +| `history/` | 定时备份(JSON ± exe) | + + +- 版本标识:文件 **MD5(Base64,去除 `-`)**,与 WatchDog `getMDCInfo` 对齐。 +- 组件映射:内置/可配置 `MDCPath`(Medulla / Detour / Clumsy 的 exe·dll·pdb)。 + +### 4.2 API + + +| 方法 | 路径 | 作用 | +| ------- | ------------------------------------- | ------------------------ | +| GET | `/api/ota/vehicles` | 车列表 + 车上 MDC 版本 +(可选)RTT | +| POST | `/api/ota/packages/pull` | 从指定车拉取包 | +| POST | `/api/ota/packages/upload` | 管理端上传包 | +| GET | `/api/ota/packages` | 版本库列表 | +| POST | `/api/ota/packages/{id}/activate` | 设为目标版本 | +| DELETE | `/api/ota/packages/{id}` | 清理(当前目标不可删) | +| GET | `/api/ota/target` | 当前目标 | +| POST | `/api/ota/jobs` | 创建下发任务(全量/组件/自定义文件/JSON) | +| GET | `/api/ota/jobs` · `/jobs/{id}` | 列表与详情进度 | +| POST | `/api/ota/jobs/{id}/retry` · `cancel` | 重试失败项 / 取消未开始 | +| GET/PUT | `/api/ota/settings` | 带宽、maxCar、备份、延迟检测 | +| GET/PUT | `/api/ota/config/{carId}/{app}` | 单车 JSON(medulla | +| POST | `/api/ota/config/push` | JSON 多车下发(走 job) | +| GET | `/api/ota/latency` | 按需批量 RTT(受设置开关约束) | + + +权限:JWT/RBAC;Platform 全量写;Monitor 可读 + 受控执行(`ops.ota.`*)。写操作进入审计。 + +### 4.3 任务状态机 + +``` +pending → probing(可选) → running → succeeded + ↘ failed | partial + ↘ cancelled +``` + +- **分批:** `maxCar`(兼容原 `ota.batchSize` 语义) +- **限速:** 服务端上传流按 `bandwidth` kb/s 节流 +- **进度:** 按「车 × 组件」;SSE 或短轮询推到任务中心 +- **重试:** 仅重跑失败车辆/组件 +- **取消:** 仅 `pending` / 未开始批次;已在传的组件尽量完成并标记 +- **延迟门禁:** 任务可带 `requireLatencyCheck`;超阈值按设置跳过或二次确认后仍下发 + +### 4.4 WatchDog 映射 + + +| 能力 | WatchDog | +| ---- | --------------------------------------------------------- | +| 读版本 | `GET /getMDCInfo` | +| 拉包 | `GET /getmdcsexe` → 车推到平台接收端(或平台主动拉,实现时选更稳方案) | +| 下发 | `/updateMedullaExecutable` 等 + `/updateFile/{name}/{op}/` | +| JSON | `get*json` / `update*json` | +| 备份 | `gethistoryexe` + 平台 `history/` | + + +参考工具 Express `:8000` 接收能力收进 MiGu(内部端点,仅供 WatchDog 回传)。服务端对 WatchDog 统一超时与有限重试。 + +--- + +## 5. UI 设计 + +**Design Read:** 工业 B2B 运维工作台;延续运维总览玻璃卡片 + 状态色 + JetBrains Mono;密度偏驾驶舱。 +**Dial:** Variance 4 / Motion 3 / Density 7。使用 `--mg-`* 与 `--mg-status-`*。 + +### 5.1 骨架 + +左子导航 + 右工作区;顶条固定:**当前目标版本摘要**(M/D/C 短哈希 + 名称)+ 进行中任务角标。 + +### 5.2 车辆升级 + +- 工具条:搜索、车队筛选、「仅显示不一致」、**网络延迟检测开关**、刷新、同步全部/分组件 +- 主表:勾选 | 车名/ID | IP | Medulla | Detour | Clumsy | RTT | 维护态 + - 与目标一致 → 绿;不一致 → 琥珀;不可达 → 灰 + - 延迟检测关:RTT 为「—」;开:数值 + 超阈值着色 +- 有勾选时底栏粘性操作条:已选数、预计批次、开始下发(二次确认) + +### 5.3 版本库 + +包列表(时间、来源 IP、体积、是否目标)+ 包内文件树与哈希;操作:拉取、上传、激活、删除。 + +### 5.4 任务中心 + +进行中(车×组件进度)+ 历史;详情含错误摘要、重试失败项、取消未开始。 + +### 5.5 配置同步 / 自定义文件 / 设置 + +- 配置:选车 → JSON 树 → 编辑 → 多车下发(进任务) +- 自定义文件:文件、车上路径、重启策略(无/M/D/C/WatchDog)、多车 → 任务 +- 设置:传输(带宽、maxCar)、延迟检测(默认开关、RTT 阈值、超限策略)、备份、展示名 + +### 5.6 交互原则 + +- 下发二次确认,写清目标版本与车辆数 +- 延迟开且超阈值:默认排除并提示;设置可改为「仍允许但确认」 +- 任务进度可离页后续看(持久化) +- 动效克制:进度与状态点过渡即可 + +--- + +## 6. 错误处理与审计 + + +| 场景 | 行为 | +| --------------- | ---------------------------- | +| WatchDog 不可达/超时 | 该车失败,不阻塞同批其他车;任务可为 `partial` | +| 上传中断/哈希不匹配 | 组件级失败;可按失败项重试 | +| 延迟超阈值 | 依设置跳过或确认后下发 | +| 无目标版本却同步 | 前端拦截 + API 400 | +| 磁盘满/包损坏 | 拉取/上传失败,不激活残包 | +| 任务取消 | 仅未开始批次 | + + +审计覆盖:激活目标、创建/取消/重试任务、改设置、推 JSON/自定义文件(操作者、时间、摘要)。 + +--- + +## 7. 前端改动要点(实现指引) + +- `VehicleHubView`:Tab 改为 `overview` | `ota`;移除 `VehicleMaintenanceView` / `FleetLifecycleView` 挂载。 +- 新增 `views/.../OtaWorkbenchView.vue`(及子页/composables/api)。 +- 新增 `src/api/ota.ts` 对接 `/api/ota/`*。 +- 路由/深链:`maintenance`/`fleet` → `ota`。 +- 删除或停用对维护策略页、FleetLifecycle 只读 OTA 页的导航依赖;`FleetLifecycleConfig.ota` 可迁移到 `/api/ota/settings` 后废弃只读 UI。 + +--- + +## 8. 后端改动要点(实现指引) + +- 新增 OTA Controller / Service / 存储 / Job runner / WatchDog HttpClient。 +- 配置项:OTA 数据根路径、WatchDog 端口(默认 9776)、超时。 +- RBAC:`ops.ota.`*;与现有 Ops 审计集成或并行 OTA audit store。 +- 包接收端点替代原 Express `:8000` 的 `upload-mdcs`* / `upload-history`*。 + +--- + +## 9. 验收标准 + +1. 车辆运维仅见「运维总览」「OTA」;旧 Tab 深链落到 OTA。 +2. 可从车拉取或上传包,激活为目标,在车辆升级页看到绿/琥珀对照。 +3. 可分批全量或分组件下发;任务中心可见进度;失败可重试;可取消未开始。 +4. 延迟检测开关生效:关不探测;开显示 RTT 并按阈值门禁。 +5. 配置 JSON 多车下发、自定义文件同步可用。 +6. 设置可持久化(带宽、并发、备份、延迟策略)。 +7. 写操作有审计;浏览器不直连 `:9776`。 + +--- + +## 10. 已确认决策摘要 + + +| 决策 | 选择 | +| ------ | ---------------------- | +| 车上协议 | WatchDog | +| IA | 运维总览 + OTA;去掉维护策略与生命周期 | +| 维护策略配置 | 本期删除 | +| 功能范围 | 全面对齐参考工具 + 平台任务/进度/重试 | +| 架构 | MiGu.Server 平台编排 | +| 延迟检测 | OTA 内按钮开关;开才检测 | From 7056cf087221da80c26fd9849b48eee92fd6935b Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Sat, 25 Jul 2026 18:44:35 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BD=A6=E8=BE=86?= =?UTF-8?q?=E8=BF=90=E7=BB=B4=20OTA=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持包管理、车辆同步、任务与自定义文件下发,并按 ops.ota* 对齐可写权限。 Co-authored-by: Cursor --- .../apps/simple-platform-vue/.env.production | 4 +- .../apps/simple-platform-vue/src/api/ota.ts | 123 +++++ .../src/composables/useOtaWorkbench.ts | 69 +++ .../apps/simple-platform-vue/src/types/ota.ts | 88 ++++ .../src/views/shared/VehicleHubView.vue | 42 +- .../src/views/shared/ota/OtaConfigPane.vue | 491 ++++++++++++++++++ .../views/shared/ota/OtaCustomFilePane.vue | 406 +++++++++++++++ .../src/views/shared/ota/OtaJobsPane.vue | 170 ++++++ .../src/views/shared/ota/OtaPackagesPane.vue | 234 +++++++++ .../src/views/shared/ota/OtaSettingsPane.vue | 116 +++++ .../src/views/shared/ota/OtaVehiclesPane.vue | 419 +++++++++++++++ .../src/views/shared/ota/OtaWorkbenchView.vue | 295 +++++++++++ .../src/views/shared/ota/jsonPath.ts | 82 +++ .../src/views/shared/ota/otaCopy.ts | 47 ++ 14 files changed, 2567 insertions(+), 19 deletions(-) create mode 100644 frontends/apps/simple-platform-vue/src/api/ota.ts create mode 100644 frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts create mode 100644 frontends/apps/simple-platform-vue/src/types/ota.ts create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts create mode 100644 frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts diff --git a/frontends/apps/simple-platform-vue/.env.production b/frontends/apps/simple-platform-vue/.env.production index c9f5b85..02596b8 100644 --- a/frontends/apps/simple-platform-vue/.env.production +++ b/frontends/apps/simple-platform-vue/.env.production @@ -2,5 +2,7 @@ # - VITE_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。 # - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。 VITE_API_BASE=/api -VITE_VRENDER_HOST=localhost:8223 +# 留空:走 defaultVrHost() → window.location.hostname:8223。 +# 切勿写死 localhost,远程浏览器会去连访问者本机而非服务器。 +# VITE_VRENDER_HOST= VITE_USE_MOCK=false diff --git a/frontends/apps/simple-platform-vue/src/api/ota.ts b/frontends/apps/simple-platform-vue/src/api/ota.ts new file mode 100644 index 0000000..f74f6f0 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/ota.ts @@ -0,0 +1,123 @@ +import http from './http' +import type { + OtaJob, + OtaPackageInfo, + OtaSettings, + OtaTarget, + OtaVehicleRow +} from '@/types/ota' + +export async function getOtaSettings(): Promise { + const { data } = await http.get('/ota/settings') + return data +} + +export async function putOtaSettings(settings: OtaSettings): Promise { + const { data } = await http.put('/ota/settings', settings) + return data +} + +export async function getOtaTarget(): Promise<{ target: OtaTarget | null; summary?: Record }> { + const { data } = await http.get<{ target: OtaTarget | null; summary?: Record }>('/ota/target') + return data +} + +export async function listOtaPackages(): Promise { + const { data } = await http.get('/ota/packages') + return data +} + +export async function activateOtaPackage(id: string, name?: string): Promise { + const { data } = await http.post(`/ota/packages/${encodeURIComponent(id)}/activate`, null, { + params: name ? { name } : undefined + }) + return data +} + +export async function deleteOtaPackage(id: string): Promise { + await http.delete(`/ota/packages/${encodeURIComponent(id)}`) +} + +export async function pullOtaPackage(carId: string): Promise { + const { data } = await http.post('/ota/packages/pull', { carId }, { timeout: 120000 }) + return data +} + +export async function uploadOtaPackage(file: File): Promise { + const form = new FormData() + form.append('file', file) + const { data } = await http.post('/ota/packages/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000 + }) + return data +} + +export async function listOtaVehicles(latency?: boolean): Promise { + const { data } = await http.get('/ota/vehicles', { + params: latency === undefined ? undefined : { latency }, + timeout: 60000 + }) + return data +} + +export async function listOtaJobs(take = 100): Promise { + const { data } = await http.get('/ota/jobs', { params: { take } }) + return data +} + +export async function getOtaJob(id: string): Promise { + const { data } = await http.get(`/ota/jobs/${encodeURIComponent(id)}`) + return data +} + +export async function createOtaSyncJob(body: { + carIds: string[] + components?: string[] + requireLatencyCheck?: boolean +}): Promise { + const { data } = await http.post('/ota/jobs', body) + return data +} + +export async function cancelOtaJob(id: string): Promise { + await http.post(`/ota/jobs/${encodeURIComponent(id)}/cancel`) +} + +export async function retryOtaJob(id: string): Promise { + const { data } = await http.post(`/ota/jobs/${encodeURIComponent(id)}/retry`) + return data +} + +export async function getOtaConfig(carId: string, app: string): Promise<{ json: string }> { + const { data } = await http.get<{ json: string }>(`/ota/config/${encodeURIComponent(carId)}/${encodeURIComponent(app)}`) + return data +} + +export async function pushOtaConfig(body: { + carIds: string[] + app: string + json: string + requireLatencyCheck?: boolean +}): Promise { + const { data } = await http.post('/ota/config/push', body) + return data +} + +export async function pushOtaCustomFile(opts: { + carIds: string[] + remotePath: string + restartOps: number[] + files: File[] +}): Promise { + const form = new FormData() + form.append('carIds', JSON.stringify(opts.carIds)) + form.append('remotePath', opts.remotePath) + form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1])) + for (const f of opts.files) form.append('files', f) + const { data } = await http.post('/ota/custom-file', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000 + }) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts b/frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts new file mode 100644 index 0000000..8eef0a3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts @@ -0,0 +1,69 @@ +import { computed, ref } from 'vue' +import { getOtaSettings, getOtaTarget, listOtaJobs } from '@/api/ota' +import type { OtaJob, OtaSettings, OtaTarget } from '@/types/ota' +import { OTA_COPY } from '@/views/shared/ota/otaCopy' + +const settings = ref(null) +const target = ref(null) +const targetSummary = ref>({}) +const activeJobCount = ref(0) +const loadingMeta = ref(false) +let pollTimer: ReturnType | null = null +let started = false + +export async function refreshOtaMeta() { + loadingMeta.value = true + try { + const [s, t, jobs] = await Promise.all([ + getOtaSettings(), + getOtaTarget(), + listOtaJobs(30) + ]) + settings.value = s + target.value = t.target + targetSummary.value = t.summary ?? {} + activeJobCount.value = jobs.filter((j) => + ['pending', 'probing', 'running'].includes(j.status) + ).length + } finally { + loadingMeta.value = false + } +} + +export function startOtaMetaPolling() { + if (started) return + started = true + void refreshOtaMeta() + pollTimer = setInterval(() => { + void refreshOtaMeta() + }, 8000) +} + +export function stopOtaMetaPolling() { + started = false + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } +} + +export function useOtaWorkbench() { + const targetLabel = computed(() => { + if (!target.value) return OTA_COPY.unsetTarget + return target.value.name || target.value.packageId + }) + + return { + settings, + target, + targetSummary, + targetLabel, + activeJobCount, + loadingMeta, + refreshMeta: refreshOtaMeta, + startPolling: startOtaMetaPolling, + stopPolling: stopOtaMetaPolling + } +} + +export type { OtaJob, OtaSettings, OtaTarget } diff --git a/frontends/apps/simple-platform-vue/src/types/ota.ts b/frontends/apps/simple-platform-vue/src/types/ota.ts new file mode 100644 index 0000000..3847359 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/types/ota.ts @@ -0,0 +1,88 @@ +export interface OtaSettings { + bandwidthKbps: number + maxCar: number + latencyEnabled: boolean + rttThresholdMs: number + overThreshold: 'skip' | 'confirm' | string + backupPeriodMinutes: number + backupExe: boolean + newVersionName?: string | null +} + +export interface OtaFileArtifact { + hash: string + path: string + fileName: string + time?: string + size: number +} + +export interface OtaTarget { + packageId: string + name?: string + activatedAt: string + components: Record +} + +export interface OtaPackageInfo { + id: string + sourceIp?: string + createdAt: string + totalBytes: number + isTarget: boolean + components: Record +} + +export interface OtaComponentVersion { + version?: string + time?: string +} + +export interface OtaAppVersions { + exe?: OtaComponentVersion + dll?: OtaComponentVersion + pdb?: OtaComponentVersion +} + +export interface OtaVehicleRow { + id: string + name: string + ip?: string + state?: string + group?: string + reachable: boolean + rttMs?: number | null + medulla?: OtaAppVersions + detour?: OtaAppVersions + clumsy?: OtaAppVersions + match?: Record +} + +export interface OtaJobStep { + carId: string + ip?: string + component: string + status: string + error?: string +} + +export interface OtaJob { + id: string + kind: string + status: string + createdAt: string + finishedAt?: string + createdBy?: string + packageId?: string + carIds: string[] + components: string[] + requireLatencyCheck: boolean + doneSteps: number + totalSteps: number + steps: OtaJobStep[] + message?: string +} + +export const OTA_COMPONENT_KEYS = ['M.exe', 'M.dll', 'M.pdb', 'D.exe', 'C.exe', 'C.dll', 'C.pdb'] as const + +export type OtaPane = 'vehicles' | 'packages' | 'jobs' | 'config' | 'custom' | 'settings' diff --git a/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue b/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue index 16275c3..1be40bc 100644 --- a/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue +++ b/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue @@ -114,19 +114,15 @@ -

故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计)

+

+ 延迟 = 本机到车辆 WatchDog(:9776) 的 TCP 往返;故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计) +

- -
- -
-
- - -
- + +
+
@@ -141,8 +137,7 @@ import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue' import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue' import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue' import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue' -import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue' -import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue' +import OtaWorkbenchView from '@/views/shared/ota/OtaWorkbenchView.vue' import { useVehicleHub } from '@/composables/useVehicleHub' import { useFleetGroups } from '@/composables/useFleetGroups' import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps' @@ -150,21 +145,31 @@ import type { CarState, VehicleCardModel } from '@/types/car' import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() -const canWrite = computed(() => auth.scope === 'Platform' || (auth.effectivePermissions?.allowedOps ?? []).includes('*')) +const canWrite = computed(() => { + if (auth.scope === 'Platform') return true + const ops = auth.effectivePermissions?.allowedOps ?? [] + return ops.includes('*') || ops.some((o) => o === 'ops.ota' || o.startsWith('ops.ota.')) +}) // Tab 与 URL ?tab= 同步,支持深链接(旧 /config/vehicle、/config/fleet 已下线,统一进车辆运维)。 const route = useRoute() const router = useRouter() -const TAB_NAMES = ['overview', 'maintenance', 'fleet'] as const +const TAB_NAMES = ['overview', 'ota'] as const type TabName = (typeof TAB_NAMES)[number] +const LEGACY_TABS = new Set(['maintenance', 'fleet']) function readTab(): TabName { const q = route.query.tab + if (typeof q === 'string' && LEGACY_TABS.has(q)) return 'ota' return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'overview' } const activeTab = ref(readTab()) -watch(activeTab, (t) => { - if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } }) -}) +watch( + activeTab, + (t) => { + if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } }) + }, + { immediate: true } +) watch(() => route.query.tab, () => { const next = readTab() if (next !== activeTab.value) activeTab.value = next @@ -326,7 +331,8 @@ async function onBatchCommand(cmd: string) { overflow: hidden; } -.config-pane { +.config-pane, +.ota-pane { height: 100%; overflow: auto; } diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue new file mode 100644 index 0000000..82cc672 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue new file mode 100644 index 0000000..ff9d8c3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue new file mode 100644 index 0000000..ab0acf5 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue new file mode 100644 index 0000000..f3b2d97 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue new file mode 100644 index 0000000..d9673dd --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue new file mode 100644 index 0000000..0e5afd2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue new file mode 100644 index 0000000..026b786 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue @@ -0,0 +1,295 @@ + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts b/frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts new file mode 100644 index 0000000..a827d06 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts @@ -0,0 +1,82 @@ +/** Build nested partial object from path like root.Network.Timeout (aligned with OTA Electron resolvePath). */ +export function resolvePathPartial(data: any, path: string): Record { + const raw = path.replace(/^root\.?/, '') + if (!raw) { + if (data != null && typeof data === 'object' && !Array.isArray(data)) { + return { ...(data as Record) } + } + return {} + } + const keys = raw.split(/\.|\[|\]/).filter(Boolean) + if (!keys.length) return {} + let result = data + const currentObject: Record = {} + keys.reduce((acc: any, key: string, index: number, array: string[]) => { + if (index === array.length - 1) { + acc[key] = result?.[key] + } else { + acc[key] = {} + } + result = result?.[key] + return acc[key] + }, currentObject) + return currentObject +} + +export interface JsonTreeNode { + label: string + path: string + valuePreview: string + selectable: boolean + children?: JsonTreeNode[] +} + +export function jsonToTree(data: unknown, path = 'root', depth = 0): JsonTreeNode[] { + if (data == null || typeof data !== 'object') return [] + if (Array.isArray(data)) { + return data.map((item, i) => { + const p = `${path}[${i}]` + const isObj = item != null && typeof item === 'object' + return { + label: `[${i}]`, + path: p, + valuePreview: preview(item), + selectable: false, + children: isObj && depth < 8 ? jsonToTree(item, p, depth + 1) : undefined + } + }) + } + return Object.keys(data as object).map((key) => { + const val = (data as any)[key] + const p = path === 'root' ? `root.${key}` : `${path}.${key}` + const isObj = val != null && typeof val === 'object' + return { + label: key, + path: p, + valuePreview: preview(val), + selectable: true, + children: isObj && depth < 8 ? jsonToTree(val, p, depth + 1) : undefined + } + }) +} + +function preview(v: unknown): string { + if (v == null) return 'null' + if (typeof v === 'string') return v.length > 40 ? `"${v.slice(0, 40)}…"` : `"${v}"` + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + if (Array.isArray(v)) return `Array(${v.length})` + return `Object(${Object.keys(v as object).length})` +} + +export function getValueAtPath(data: any, path: string): unknown { + const keys = path + .replace(/^root\.?/, '') + .split(/\.|\[|\]/) + .filter(Boolean) + let cur = data + for (const k of keys) { + if (cur == null) return undefined + cur = cur[k] + } + return cur +} diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts b/frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts new file mode 100644 index 0000000..d38aa00 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts @@ -0,0 +1,47 @@ +/** OTA UI copy — kept in one UTF-8 module to avoid editor/encoding corruption in templates. */ +export const OTA_COPY = { + searchPh: '搜索 ID / 名称 / IP', + onlyMismatch: '仅显示不一致', + latency: '网络延迟检测', + refresh: '刷新', + sync: '同步', + syncAll: '同步全部组件', + syncM: '仅 Medulla.exe', + syncD: '仅 Detour.exe', + syncC: '仅 Clumsy.exe', + vehicle: '车辆', + state: '状态', + selectedPrefix: '已选', + selectedSuffix: '台', + batchPrefix: '批次约', + startDeploy: '开始下发', + loadFail: '加载车辆失败', + latencySaveFail: '保存延迟开关失败', + confirmTitle: '确认 OTA 下发', + confirmOk: '开始下发', + confirmCancel: '取消', + confirmBody: (n: number, comps: string) => `将向 ${n} 台车下发${comps}。确认继续?`, + compsAll: '全部组件', + skipLatency: (n: number) => `已排除 ${n} 台延迟超限车辆`, + noCars: '没有可下发的车辆', + jobCreated: '任务已创建', + jobFail: '创建任务失败', + unsetTarget: '未设置目标版本', + activateHint: '请先在版本库激活一个包', + navVehicles: '车辆升级', + navPackages: '版本库', + navJobs: '任务进度', + navConfig: '参数配置', + navCustom: '自定义同步', + navSettings: '设置', + pullFromCar: '拉取选中车版本', + pullNeedOne: '请先勾选恰好一台车辆再拉取', + pullNeedIp: '该车无 IP,无法拉取', + pullOk: '拉取完成,可到版本库设为目标', + pullEmpty: '未收到文件:请把车上 WatchDog 的 serverIP 设为本机局域网 IP(回传端口 8000)', + pullFail: '拉取失败', + receiveHint: + '拉包回传:WatchDog 固定 POST 到 http://{serverIP}:8000/upload-mdcs/*。请在各车 watch_dog.json 把 serverIP 设为运行迷毂的电脑局域网 IP;迷毂已同时监听 8000 接收。', + jobsHint: '任务进度用于跟踪「同步升级 / 参数下发 / 自定义文件」的执行结果,支持查看明细、取消未开始批次、重试失败车辆。', + vehiclesHint: '勾选车辆后可「拉取选中车版本」或「同步」;拉取请只选一台有 IP 的车。' +} as const From 6986370e9df33eb0eb8ae0c08ab0b505deb3ab77 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Sat, 25 Jul 2026 18:44:48 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E6=8A=A5=E8=AD=A6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=8C=E5=B9=B6=E6=8E=A5=E5=85=A5=E8=BD=A6?= =?UTF-8?q?=E9=98=9F=E5=81=A5=E5=BA=B7=E6=95=B0=E6=8D=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。 Co-authored-by: Cursor --- MiGu.Server/Controllers/HealthController.cs | 20 + .../apps/simple-platform-vue/src/api/alarm.ts | 18 + .../simple-platform-vue/src/api/delivery.ts | 73 +- .../src/api/fleetHealth.ts | 8 + .../simple-platform-vue/src/api/health.ts | 58 ++ .../src/components/PlaybackProgressBar.vue | 161 ++++ .../components/workbench/MissionListPanel.vue | 26 +- .../workbench/VehicleMonitorPanel.vue | 276 ++++++- .../src/composables/useVehicleCardState.ts | 6 +- .../simple-platform-vue/src/config/navMenu.ts | 56 +- .../src/config/quickEntries.ts | 8 +- .../src/mock/data/alarms.ts | 27 + .../src/mock/data/deliveries.ts | 103 ++- .../apps/simple-platform-vue/src/mock/rbac.ts | 2 + .../simple-platform-vue/src/router/index.ts | 12 +- .../simple-platform-vue/src/types/alarm.ts | 24 + .../apps/simple-platform-vue/src/types/car.ts | 4 +- .../simple-platform-vue/src/types/delivery.ts | 25 +- .../src/utils/batterySoc.ts | 58 ++ .../src/utils/carRuntime.ts | 82 ++ .../simple-platform-vue/src/utils/dateTime.ts | 43 ++ .../src/views/admin/AlarmManagementView.vue | 373 +++++++++ .../src/views/admin/TaskManagementView.vue | 712 ++++++++++++++++++ 23 files changed, 2097 insertions(+), 78 deletions(-) create mode 100644 frontends/apps/simple-platform-vue/src/api/alarm.ts create mode 100644 frontends/apps/simple-platform-vue/src/api/health.ts create mode 100644 frontends/apps/simple-platform-vue/src/components/PlaybackProgressBar.vue create mode 100644 frontends/apps/simple-platform-vue/src/mock/data/alarms.ts create mode 100644 frontends/apps/simple-platform-vue/src/types/alarm.ts create mode 100644 frontends/apps/simple-platform-vue/src/utils/batterySoc.ts create mode 100644 frontends/apps/simple-platform-vue/src/utils/carRuntime.ts create mode 100644 frontends/apps/simple-platform-vue/src/utils/dateTime.ts create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/AlarmManagementView.vue create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/TaskManagementView.vue diff --git a/MiGu.Server/Controllers/HealthController.cs b/MiGu.Server/Controllers/HealthController.cs index 425b0ef..de256c5 100644 --- a/MiGu.Server/Controllers/HealthController.cs +++ b/MiGu.Server/Controllers/HealthController.cs @@ -34,6 +34,26 @@ public class HealthController : ControllerBase [Authorize] public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics()); + /// 关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。 + [HttpPost("simplelite/stop")] + [Authorize(Policy = "PlatformScope")] + public IActionResult StopSimpleLite() + { + var killed = _launcher.StopAll(); + var diag = _launcher.GetDiagnostics(); + return Ok(new { killed, diagnostics = diag }); + } + + /// 关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。 + [HttpPost("simplelite/restart")] + [Authorize(Policy = "PlatformScope")] + public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly") + { + var result = _launcher.Restart(launchMode); + var diag = _launcher.GetDiagnostics(); + return Ok(new { restart = result, diagnostics = diag }); + } + /// /// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。 /// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。 diff --git a/frontends/apps/simple-platform-vue/src/api/alarm.ts b/frontends/apps/simple-platform-vue/src/api/alarm.ts new file mode 100644 index 0000000..564bf06 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/alarm.ts @@ -0,0 +1,18 @@ +import http from './http' +import type { AlarmFeed } from '@/types/alarm' + +const MOCK = import.meta.env.VITE_USE_MOCK === 'true' + +/** 报警管理数据源:平台记录库 /fleet/alarms(离线可读 + 历史保留)。 */ +export async function fetchAlarmFeed(limit = 2000): Promise { + if (MOCK) { + const { mockAlarms } = await import('@/mock/data/alarms') + return { online: true, lastSyncAt: new Date().toISOString(), alarms: await mockAlarms() } + } + const { data } = await http.get('/fleet/alarms', { params: { limit } }) + return { + online: !!data?.online, + lastSyncAt: data?.lastSyncAt ?? null, + alarms: Array.isArray(data?.alarms) ? data.alarms : [] + } +} diff --git a/frontends/apps/simple-platform-vue/src/api/delivery.ts b/frontends/apps/simple-platform-vue/src/api/delivery.ts index be3da13..5aa37a2 100644 --- a/frontends/apps/simple-platform-vue/src/api/delivery.ts +++ b/frontends/apps/simple-platform-vue/src/api/delivery.ts @@ -1,5 +1,5 @@ import http from './http' -import type { DeliveryTask } from '@/types/delivery' +import type { CreateDeliveryPayload, DeliveryTask } from '@/types/delivery' const MOCK = import.meta.env.VITE_USE_MOCK === 'true' @@ -20,17 +20,72 @@ export async function listDeliveries(opts?: { return Array.isArray(data) ? data : [] } -export async function cancelDelivery(id: number): Promise { - if (MOCK) return - await http.post(`${BASE}/${id}/cancel`) +/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */ +export interface CdmTaskFeed { + online: boolean + lastSyncAt: string | null + tasks: DeliveryTask[] } -export async function resendDelivery(id: number): Promise { - if (MOCK) return - await http.post(`${BASE}/${id}/resend`) +/** + * 任务页数据源:优先读平台快照库 /fleet/tasks(离线可读 + 历史保留); + * 若平台端点不可用则回退到实时投影 /sl/projection/deliveries。 + */ +export async function fetchCdmTaskFeed(limit = 1000): Promise { + if (MOCK) { + const { mockDeliveries } = await import('@/mock/data/deliveries') + return { online: true, lastSyncAt: new Date().toISOString(), tasks: await mockDeliveries() } + } + try { + const { data } = await http.get('/fleet/tasks', { params: { limit } }) + return { + online: !!data?.online, + lastSyncAt: data?.lastSyncAt ?? null, + tasks: Array.isArray(data?.tasks) ? data.tasks : [] + } + } catch { + const tasks = await listDeliveries({ includeFinished: true, includeAborted: true }) + return { online: true, lastSyncAt: new Date().toISOString(), tasks } + } } -export async function forceCompleteDelivery(id: number): Promise { +export async function cancelDelivery(id: string): Promise { if (MOCK) return - await http.post(`${BASE}/${id}/force-complete`) + await http.post(`${BASE}/${encodeURIComponent(id)}/cancel`) +} + +export async function resendDelivery(id: string): Promise { + if (MOCK) return + await http.post(`${BASE}/${encodeURIComponent(id)}/resend`) +} + +export async function forceCompleteDelivery(id: string): Promise { + if (MOCK) return + await http.post(`${BASE}/${encodeURIComponent(id)}/force-complete`) +} + +export async function pauseDelivery(id: string): Promise { + if (MOCK) return + await http.post(`${BASE}/${encodeURIComponent(id)}/pause`) +} + +export async function resumeDelivery(id: string): Promise { + if (MOCK) return + await http.post(`${BASE}/${encodeURIComponent(id)}/resume`) +} + +export async function changeCarDelivery(id: string): Promise { + if (MOCK) return + await http.post(`${BASE}/${encodeURIComponent(id)}/change-car`) +} + +export async function setDeliveryPriority(id: string, value: number): Promise { + if (MOCK) return + await http.post(`${BASE}/${encodeURIComponent(id)}/priority`, { value }) +} + +export async function createDelivery(payload: CreateDeliveryPayload): Promise<{ id: string }> { + if (MOCK) return { id: `MOCK-${Date.now()}` } + const { data } = await http.post<{ success: boolean; id: string }>(BASE, payload) + return { id: data?.id ?? '' } } diff --git a/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts b/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts index 7488b73..5a052f4 100644 --- a/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts +++ b/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts @@ -32,6 +32,14 @@ export async function fetchFleetHealth(): Promise { await new Promise((r) => setTimeout(r, 120)) return mockFleetHealth() } + // 平台侧聚合:SimpleLite 指标 + WatchDog(:9776) TCP RTT。 + // 不再直打 /sl/projection/fleet/health(其探测车载 :8081,现场多数未开导致假超时 2000ms)。 + try { + const { data } = await http.get('/fleet/health') + if (Array.isArray(data) && data.length > 0) return data + } catch { + /* fall through */ + } const { data } = await http.get('/sl/projection/fleet/health') return Array.isArray(data) ? data : [] } diff --git a/frontends/apps/simple-platform-vue/src/api/health.ts b/frontends/apps/simple-platform-vue/src/api/health.ts new file mode 100644 index 0000000..7ab1b88 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/health.ts @@ -0,0 +1,58 @@ +import http from '@/api/http' +import type { LaunchMode, RunMode } from '@/types/auth' + +export interface HealthInfo { + status: string + startTime: string + uptimeSec: number +} + +export interface SimpleLiteDiagnostics { + enabled: boolean + isRunning: boolean + lastLaunchMode?: string | null + projectionPort: number + projectionPortReachable: boolean + gotoSiteApiAvailable?: boolean | null + executableExists: boolean + deployHint?: string | null +} + +export interface SimpleLiteLaunchResult { + started: boolean + status: string + detail: string + displayMode?: string | null + warning?: string | null +} + +export function getHealth() { + return http.get('/health') +} + +export function getSimpleLiteDiagnostics() { + return http.get('/health/simplelite') +} + +export function stopSimpleLite() { + return http.post<{ killed: number; diagnostics: SimpleLiteDiagnostics }>('/health/simplelite/stop') +} + +export function restartSimpleLite(launchMode: LaunchMode) { + return http.post<{ restart: SimpleLiteLaunchResult; diagnostics: SimpleLiteDiagnostics }>( + '/health/simplelite/restart', + null, + { params: { launchMode }, timeout: 60_000 } + ) +} + +/** 从诊断/会话 runMode 推断重启时使用的 launchMode。 */ +export function resolveRestartLaunchMode( + lastLaunchMode: string | null | undefined, + runMode: RunMode | null | undefined +): LaunchMode { + const mode = (lastLaunchMode ?? '').toLowerCase() + if (mode === 'web') return 'WebOnly' + if (mode === 'web+local') return 'DesktopAndWeb' + return runMode === 'WebOnly' ? 'WebOnly' : 'DesktopAndWeb' +} diff --git a/frontends/apps/simple-platform-vue/src/components/PlaybackProgressBar.vue b/frontends/apps/simple-platform-vue/src/components/PlaybackProgressBar.vue new file mode 100644 index 0000000..9cd60d7 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/PlaybackProgressBar.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/MissionListPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/MissionListPanel.vue index 330e391..d888cca 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/MissionListPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/MissionListPanel.vue @@ -28,7 +28,7 @@ @row-click="onRowClick" @row-contextmenu="onRowContextMenu" > - + @@ -113,13 +125,14 @@ import { useAuthStore } from '@/stores/auth' import { useUiStore } from '@/stores/ui' import ScopeSwitcher from '@/components/ScopeSwitcher.vue' import ThemeSwitcher from '@/components/ThemeSwitcher.vue' +import AiAssistantDrawer from '@/components/assistant/AiAssistantDrawer.vue' const route = useRoute() const router = useRouter() const auth = useAuthStore() const ui = useUiStore() -const vrPort = computed(() => (import.meta.env.VITE_VRENDER_HOST as string | undefined)?.split(':')[1] ?? '8223') +const slPort = computed(() => (import.meta.env.VITE_SL_PORT as string | undefined) ?? '8222') const apiBase = computed(() => (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api') const initial = computed(() => (auth.user?.displayName ?? auth.user?.username ?? '?').slice(0, 1).toUpperCase()) @@ -171,6 +184,10 @@ const activePath = computed(() => { const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '') function onUserCommand(cmd: string) { + if (cmd.startsWith('theme:')) { + ui.setThemeId(cmd.slice('theme:'.length)) + return + } if (cmd === 'logout') { auth.logout() router.push('/login') @@ -187,6 +204,7 @@ function onUserCommand(cmd: string) { .app-shell { position: relative; height: 100vh; + height: 100dvh; overflow: hidden; } @@ -236,6 +254,7 @@ function onUserCommand(cmd: string) { position: relative; z-index: 1; height: 100vh; + height: 100dvh; } /* ─────────────── Sidebar ─────────────── */ @@ -549,3 +568,25 @@ function onUserCommand(cmd: string) { flex-direction: column; } + + diff --git a/frontends/apps/simple-platform-vue/src/stores/ui.ts b/frontends/apps/simple-platform-vue/src/stores/ui.ts index 489e33d..189d55b 100644 --- a/frontends/apps/simple-platform-vue/src/stores/ui.ts +++ b/frontends/apps/simple-platform-vue/src/stores/ui.ts @@ -2,8 +2,11 @@ import { defineStore } from 'pinia' import { DEFAULT_THEME_ID, + LEGACY_THEMES, + PRIMARY_THEMES, applyThemeVars, findTheme, + resolveThemeId, THEMES, type ThemePreset } from '@/styles/themes' @@ -28,6 +31,9 @@ interface UiState { } const STORAGE_KEY = 'simple.ui.state' +/** Outpost 壳层上线:旧本地主题只换了 id、观感几乎不变 → 升 schema 强制一次默认浅色 */ +const THEME_SCHEMA_KEY = 'simple.ui.themeSchema' +const THEME_SCHEMA_VERSION = 2 const DEFAULT_STATE: UiState = { sidebarCollapsed: false, @@ -39,14 +45,33 @@ const DEFAULT_STATE: UiState = { function loadInitial(): UiState { try { const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return { ...DEFAULT_STATE } + if (!raw) { + localStorage.setItem(THEME_SCHEMA_KEY, String(THEME_SCHEMA_VERSION)) + return { ...DEFAULT_STATE } + } const parsed = JSON.parse(raw) as Partial - return { + let themeId = resolveThemeId(parsed.themeId ?? DEFAULT_STATE.themeId) + const schema = Number(localStorage.getItem(THEME_SCHEMA_KEY) || '0') + const migrated = schema < THEME_SCHEMA_VERSION + if (migrated) { + // ponytail: 一次强制 outpost-light,兼容主题仍可从高级菜单切回 + themeId = DEFAULT_THEME_ID + localStorage.setItem(THEME_SCHEMA_KEY, String(THEME_SCHEMA_VERSION)) + } + const state: UiState = { sidebarCollapsed: parsed.sidebarCollapsed ?? DEFAULT_STATE.sidebarCollapsed, theme: parsed.theme === 'dark' ? 'dark' : 'light', - themeId: parsed.themeId ?? DEFAULT_STATE.themeId, + themeId, themeOverrides: parsed.themeOverrides ?? {} } + if (migrated) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch { + /* ignore */ + } + } + return state } catch (err) { console.warn('[ui.store] localStorage 中的 UI 状态解析失败,已回退默认:', err) return { ...DEFAULT_STATE } @@ -60,8 +85,16 @@ export const useUiStore = defineStore('ui', { activeTheme(state): ThemePreset { return mergeThemePreset(findTheme(state.themeId), state.themeOverrides[state.themeId]) }, - /** 所有可选主题列表 */ + /** 顶栏精选主题(浅色 / 紫色 / 蓝色) */ availableThemes(): ThemePreset[] { + return PRIMARY_THEMES + }, + /** 高级 → 兼容主题(旧深色霓虹) */ + legacyThemes(): ThemePreset[] { + return LEGACY_THEMES + }, + /** 全量(自定义配色等内部用) */ + allThemes(): ThemePreset[] { return THEMES } }, @@ -107,7 +140,7 @@ export const useUiStore = defineStore('ui', { this.persist() }, - /** 切换品牌主题色板 */ + /** 切换品牌主题色板(精选或兼容均可) */ setThemeId(id: string) { const preset = findTheme(id) this.themeId = preset.id diff --git a/frontends/apps/simple-platform-vue/src/styles/theme.css b/frontends/apps/simple-platform-vue/src/styles/theme.css index 99e8ad5..51529b9 100644 --- a/frontends/apps/simple-platform-vue/src/styles/theme.css +++ b/frontends/apps/simple-platform-vue/src/styles/theme.css @@ -1,15 +1,13 @@ -/* 迷毂 平台主题:可切换色板 (v1.9) - * 默认主题:industrial-purple (星云紫 · 紫主色 + 蓝紫辅助 · 霓虹磨砂玻璃) - * 所有品牌相关颜色全部走 --mg-* CSS 变量,可由 src/styles/themes.ts 在运行时切换 - * - * 设计系统规范见 design-system/MASTER.md (由 ui-ux-pro-max skill 推导) +/* ?? ?????????? (v2.0) + * ?????outpost-light?Harbor??????+ ?????????? themes.ts ?? + * ?????design-system/outpost.md / MASTER.md */ -/* JetBrains Mono · 用于 KPI / 坐标 / ID 等数据数字(tabular-nums)*/ +/* JetBrains Mono ? KPI / coords / IDs (tabular-nums) */ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap'); :root { - /* ── 状态色(与品牌色解耦,跨主题保持语义) ── */ + /* ?? ??????????????????? ?? */ --mg-status-success: #22c55e; --mg-status-success-rgb: 34, 197, 94; --mg-status-warning: #f59e0b; @@ -21,7 +19,7 @@ --mg-status-idle: #94a3b8; --mg-status-idle-rgb: 148, 163, 184; - /* ── 数据可视化色板(ECharts / Progress 等) ── */ + /* ?? ????????ECharts / Progress ?? ?? */ --mg-data-c1: #3b82f6; --mg-data-c2: #22c55e; --mg-data-c3: #a855f7; @@ -31,19 +29,19 @@ --mg-data-c7: #ec4899; --mg-data-c8: #84cc16; - /* ── 字体栈 ── */ - --mg-font-sans: 'PingFang SC', 'Microsoft YaHei', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + /* ?? ????Outpost?Inter ?????? */ + --mg-font-sans: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --mg-font-mono: 'JetBrains Mono', 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace; - /* ── 登录页 / 配置向导 固定品牌色(紫蓝混合,永不随主题切换变化) ── - * 登录窗与「平台配置向导」共用同一套定值,保证两处观感统一、且不受用户切换主题影响。 - * 色相:靛紫 #7c5cff ↔ 蓝紫(periwinkle) #5e7cff 的「紫蓝」渐变,刻意避开纯蓝 / 青色。 */ + /* ?? ????/ ???? ???????????????????????? + * ?????????????????????????????????????????? + * ??????#7c5cff ????(periwinkle) #5e7cff ?????????????? / ????*/ --lg-primary: #7d4dff; --lg-primary-rgb: 125, 77, 255; --lg-primary-hover: #9c79ff; --lg-primary-hover-rgb: 156, 121, 255; --lg-primary-deep: #5a2fc4; - /* accent 与登录按钮同款紫(同色系、略亮),让强调色/光晕/激活态都呈现按钮那种紫 */ + /* accent ????????????????????????/????????????*/ --lg-accent: #8b5cff; --lg-accent-rgb: 139, 92, 255; --lg-deep-rgb: 14, 9, 28; @@ -53,31 +51,30 @@ --lg-text-soft: rgba(220, 220, 250, 0.82); --lg-text-dim: rgba(196, 198, 235, 0.58); - /* ── 品牌主色(默认 = 星云紫,可被 themes.ts 注入覆盖) ── */ + /* default = outpost-light pure white (themes.ts overrides at runtime) */ --mg-primary: #7c3aed; --mg-primary-rgb: 124, 58, 237; --mg-primary-hover: #8b5cf6; --mg-primary-hover-rgb: 139, 92, 246; --mg-primary-active: #5b21b6; - --mg-accent: #c4b5fd; - --mg-accent-rgb: 196, 181, 253; + --mg-accent: #a855f7; + --mg-accent-rgb: 168, 85, 247; - --mg-primary-light-3: #9d6cf2; - --mg-primary-light-5: #b594f7; - --mg-primary-light-7: #6f4eab; - --mg-primary-light-8: #4c357a; - --mg-primary-light-9: #2d1f55; - --mg-primary-dark-2: #4c1d95; + --mg-primary-light-3: #a78bfa; + --mg-primary-light-5: #c4b5fd; + --mg-primary-light-7: #ddd6fe; + --mg-primary-light-8: #ede9fe; + --mg-primary-light-9: #f5f3ff; + --mg-primary-dark-2: #6d28d9; - /* ── 背景:深紫黑底 ── */ - --mg-bg-app-1: #0e0a24; - --mg-bg-app-2: #1f1147; - --mg-bg-app-3: #06031a; - --mg-bg-app-deep-rgb: 6, 3, 26; + --mg-bg-app-1: #ffffff; + --mg-bg-app-2: #ffffff; + --mg-bg-app-3: #ffffff; + --mg-bg-app-deep-rgb: 255, 255, 255; - --mg-bg-aside-1: #0c0820; - --mg-bg-aside-2: #03020e; - --mg-bg-aside-rgb: 10, 6, 30; + --mg-bg-aside-1: #ffffff; + --mg-bg-aside-2: #fafafa; + --mg-bg-aside-rgb: 255, 255, 255; --mg-bg-card-rgb: 38, 24, 78; --mg-bg-card-hi-rgb: 58, 38, 110; @@ -91,7 +88,7 @@ --mg-orb-b-opacity: 0.50; --mg-orb-c-opacity: 0.40; - /* ── 玻璃设计令牌(霓虹磨砂玻璃体系,主色感知更明显) ── */ + /* ?? ???????????????????????? ?? */ --mg-glass-bg: rgba(255, 255, 255, 0.06); --mg-glass-bg-hover: rgba(255, 255, 255, 0.10); --mg-glass-bg-active: rgba(255, 255, 255, 0.14); @@ -115,14 +112,14 @@ --mg-radius-lg: 20px; --mg-radius-xl: 24px; - /* ── 文字 ── */ + /* ?? ?? ?? */ --mg-text-light: #ffffff; --mg-text-muted: rgba(255, 255, 255, 0.7); --mg-text-dim: rgba(255, 255, 255, 0.5); --mg-text-faint: rgba(255, 255, 255, 0.35); --mg-divider: rgba(255, 255, 255, 0.08); - /* ── Element Plus 主色覆盖 ── */ + /* ?? Element Plus ???? ?? */ --el-color-primary: var(--mg-primary); --el-color-primary-rgb: var(--mg-primary-rgb); --el-color-primary-light-3: var(--mg-primary-light-3); @@ -134,19 +131,34 @@ --mg-transition: all .25s cubic-bezier(.25, .8, .25, 1); - /* ── 通用「内嵌表面」veil 令牌 ── - * 面板里的嵌套行 / 子卡 / 统计项常用 rgba(255,255,255,.0x) 当作浅色微透表面, - * 在深色主题里是「白色微透」效果;但 fame-lavender 浅紫主题切到白底后这些白veil - * 直接消失(白叠白)。把它们抽成令牌:深色主题保持白微透,fame-lavender 翻成紫微透, - * 这样所有引用处自动双主题可见,且不影响 6 套深色主题观感。 */ + /* ?? ????????veil ?? ?? + * ????????/ ?? / ??????rgba(255,255,255,.0x) ????????? + * ??????????????????fame-lavender ????????????veil + * ????????????????????????????fame-lavender ?????? + * ??????????????????? 6 ?????????*/ --mg-veil-1: rgba(255, 255, 255, 0.05); --mg-veil-2: rgba(255, 255, 255, 0.08); --mg-veil-3: rgba(255, 255, 255, 0.12); --mg-veil-border: rgba(255, 255, 255, 0.12); --mg-veil-border-hi: rgba(255, 255, 255, 0.20); - /* ── 面板「动作按钮」令牌 ── - * 深色主题:半透明紫玻璃底 + 白字;fame-lavender:实色紫底 + 白字(对比足够)。 */ + /* ?? ???? chrome????Harbor ????/???? themes.ts ???? ?? + * ???? themes.ts applyThemeVars ???????????*/ + --me-chrome-1: #ffffff; + --me-chrome-2: #ffffff; + --me-chrome-rgb: 255, 255, 255; + --me-border: rgba(15, 23, 42, 0.10); + --me-text: rgba(15, 23, 42, 0.92); + --me-text-muted: rgba(15, 23, 42, 0.52); + --me-surface: rgba(15, 23, 42, 0.03); + --me-hover: rgba(30, 58, 95, 0.06); + --me-active-glow: rgba(30, 58, 95, 0.14); + --me-card-bg: #ffffff; + --me-card-border: #e5e7eb; + --me-inset: #f8fafc; + + /* ?? ????????????? + * ???????????? + ???fame-lavender??????+ ??????????*/ --mg-action-bg: rgba(76, 29, 149, 0.45); --mg-action-bg-hover: rgba(109, 40, 217, 0.75); --mg-action-border: rgba(167, 139, 250, 0.55); @@ -179,7 +191,7 @@ body { #app { min-height: 100vh; position: relative; } -/* ── 全局科幻网格层 (默认开启,主题可单独关闭) ── */ +/* ?? ????????(????????????? ?? */ #app::before { content: ''; position: fixed; @@ -194,7 +206,7 @@ body { -webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.85) 0%, transparent 75%); opacity: 0.55; } -/* ── 极淡扫描线(增加科技感) ── */ +/* ?? ???????????? ?? */ #app::after { content: ''; position: fixed; @@ -211,10 +223,10 @@ body { mix-blend-mode: overlay; } -/* 让所有 router 内容在背景层之上 */ +/* ????router ???????? */ .app-shell, .login-page { position: relative; z-index: 1; } -/* ── 玻璃卡片复用类 ── */ +/* ?? ?????????? */ .mg-glass { background: var(--mg-glass-bg); border: 1px solid var(--mg-glass-border); @@ -242,7 +254,7 @@ body { 0 1px 0 rgba(255, 255, 255, 0.4) inset; } -/* ── 主按钮统一 ── */ +/* ?? ????? ?? */ .el-button--primary { --el-button-bg-color: var(--mg-primary); --el-button-border-color: var(--mg-primary); @@ -252,7 +264,7 @@ body { --el-button-active-border-color: var(--mg-primary-active); } -/* ── 滚动条 ── */ +/* ?? ?????? */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-thumb { background: rgba(var(--mg-accent-rgb), 0.4); @@ -266,19 +278,19 @@ body { a { color: var(--mg-accent); } a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } -/* ── 玻璃语境下 Element Plus 全局填充/背景变量暗化(会话 N+2 UI 修复)── - * 问题:默认 industrial-purple (星云紫) 主题下,`.mg-content` 把 `--el-text-color-primary` - * 改成白色 (line ~290) 让卡片/Table 等深底组件的字可读,但 element-plus 内部仍然有 - * `var(--el-fill-color-light)` (#f5f7fa)、`var(--el-bg-color)` (#fff) 等浅色填充值。 - * 任何组件(如 FieldMemberEditor `.fme-desc`、ProjectPropertiesView .pp-desc)用 - * `background: var(--el-fill-color-light)` + `color: var(--el-text-color-primary)` 就撞出 - * 「浅灰底 + 白字」不可读。 +/* ?? ??????Element Plus ????/??????????N+2 UI ????? + * ??????industrial-purple (???? ????`.mg-content` ??`--el-text-color-primary` + * ???? (line ~290) ????Table ????????????element-plus ?????? + * `var(--el-fill-color-light)` (#f5f7fa)?`var(--el-bg-color)` (#fff) ???????? + * ?????? FieldMemberEditor `.fme-desc`?ProjectPropertiesView .pp-desc?? + * `background: var(--el-fill-color-light)` + `color: var(--el-text-color-primary)` ???? + * ???? + ???????? * - * 解法:把 element-plus 填充/背景变量改成基于 `--mg-bg-card-rgb` 的半透明: - * - industrial-purple 时 --mg-bg-card-rgb = "38, 24, 78" → 暗紫 35-55% 透明,深底+白字 OK; - * - fame-lavender 时 --mg-bg-card-rgb = "255, 255, 255" → 白色 35-55% 透明,配 fame-lavender - * 的 #2d1b69 深字(line ~1069 已配)也清楚。 - * 边框、占位符同步走「主色感知」的灰阶。 */ + * ???? element-plus ??/???????? `--mg-bg-card-rgb` ?????? + * - industrial-purple ??--mg-bg-card-rgb = "38, 24, 78" ???? 35-55% ???????? OK?? + * - fame-lavender ??--mg-bg-card-rgb = "255, 255, 255" ???? 35-55% ???? fame-lavender + * ??#2d1b69 ???line ~1069 ???????? + * ????????????????????*/ .mg-content { --el-fill-color-blank: transparent; --el-fill-color-light: rgba(var(--mg-bg-card-rgb), 0.45); @@ -289,6 +301,9 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } --el-bg-color: rgba(var(--mg-bg-card-rgb), 0.55); --el-bg-color-page: transparent; --el-bg-color-overlay: rgba(var(--mg-bg-card-rgb), 0.92); + /* ??/???????? alpha ????????????? */ + --el-dialog-bg-color: rgb(var(--mg-bg-card-rgb)); + --el-drawer-bg-color: rgb(var(--mg-bg-card-rgb)); --el-border-color: rgba(255, 255, 255, 0.14); --el-border-color-light: rgba(255, 255, 255, 0.10); --el-border-color-lighter: rgba(255, 255, 255, 0.08); @@ -298,10 +313,37 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } --el-text-color-disabled: rgba(255, 255, 255, 0.30); } -/* FieldMemberEditor / ProjectPropertiesView 的「描述长条」专项修复 ─ - * 组件 scoped css 写 `background: var(--el-fill-color-light)` + `color: var(--el-text-color-primary)`, - * 即便上面 `.mg-content` 已经把变量改基于 --mg-bg-card-rgb,组件本身仍可能因 Vue scoped 选择器 - * 优先级把变量「冻结」住。这里直接给 `.fme-desc` 在 .mg-content 上下文里写绝对色,杜绝歧义。 */ +/* el-drawer????????????????? ? + * EP ????background-color: var(--el-drawer-bg-color)?? .el-drawer ?????? + * ????????var(--el-dialog-bg-color, var(--el-bg-color))??????0.55 ????? + * ?????? modal-class="tm-detail-drawer-modal"?? el-drawer ??class ???? + * ??????inheritAttrs=false + Teleport ????????? theme ?????? + * ?? alpha ??rgb() ?????????*/ +.mg-content .el-drawer, +.tm-detail-drawer-modal .el-drawer { + --el-drawer-bg-color: rgb(var(--mg-bg-card-rgb)) !important; + --el-dialog-bg-color: rgb(var(--mg-bg-card-rgb)) !important; + background: rgb(var(--mg-bg-card-rgb)) !important; + background-color: rgb(var(--mg-bg-card-rgb)) !important; + opacity: 1 !important; +} +.tm-detail-drawer-modal .el-drawer::before { + content: ''; + position: absolute; + inset: 0; + background: rgb(var(--mg-bg-card-rgb)) !important; + z-index: 0; + pointer-events: none; +} +.tm-detail-drawer-modal .el-drawer > * { + position: relative; + z-index: 1; +} + +/* FieldMemberEditor / ProjectPropertiesView ????????????? + * ?? scoped css ??`background: var(--el-fill-color-light)` + `color: var(--el-text-color-primary)`?? + * ???? `.mg-content` ???????? --mg-bg-card-rgb??????????Vue scoped ???? + * ????????????????? `.fme-desc` ??.mg-content ???????????????*/ .mg-content .fme-desc { background: rgba(var(--mg-bg-card-rgb), 0.55) !important; color: var(--mg-text-light) !important; @@ -310,15 +352,15 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } .mg-content .fme-path { color: var(--mg-text-light) !important; } .mg-content .fme-path-empty { color: var(--mg-text-muted) !important; } -/* ─── 「admin 风格 tabs」共享样式(会话 N+2 用户反馈) ──────────────────────────── - * 把「场景管理」(SceneManagerView) 里的 type="border-card" + 紫色高亮 tabs 抽成 - * 全局 .admin-tabs 类,让项目属性 / 车辆管理 / 后续配置中心等多 tab 页面统一外观。 +/* ??? ?admin ?? tabs???????? N+2 ?????????????????????????????????? + * ????????SceneManagerView) ?? type="border-card" + ???? tabs ?? + * ?? .admin-tabs ????????/ ???? / ???????? tab ???????? * - * 用法: ... - * - 容器:透明背景 + 紫色微辉边框; - * - tab 头:透明 + 浅紫边线; - * - tab 项:未激活淡紫白;hover 加亮;激活时紫色高亮底 + 紫色底边。 - * 仅在 .mg-content 内生效,避免污染登录页等非主区组件。 */ + * ????el-tabs class="admin-tabs" type="border-card"> ... + * - ??????? + ???????? + * - tab ???? + ?????? + * - tab ?????????hover ????????????+ ?????? + * ?? .mg-content ???????????????????*/ .mg-content .admin-tabs.el-tabs--border-card { height: 100%; background: rgba(var(--mg-bg-card-rgb), 0.45) !important; @@ -370,29 +412,92 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } height: 100%; } -/* fame-lavender 主题下 admin-tabs 反白:浅紫底 + 深紫字 + 紫色高亮 */ -:root[data-theme="fame-lavender"] .mg-content .admin-tabs.el-tabs--border-card { +/* fame-lavender ????admin-tabs ?????? + ????+ ???? */ +:root[data-shell="outpost"] .mg-content .admin-tabs.el-tabs--border-card { background: #ffffff !important; border: 1px solid rgba(124, 58, 237, 0.18) !important; box-shadow: 0 8px 24px rgba(124, 58, 237, 0.06) !important; } -:root[data-theme="fame-lavender"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header { +:root[data-shell="outpost"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header { background: rgba(124, 58, 237, 0.04) !important; border-bottom: 1px solid rgba(124, 58, 237, 0.14) !important; } -:root[data-theme="fame-lavender"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header .el-tabs__item { +:root[data-shell="outpost"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header .el-tabs__item { color: #4a3d6e !important; } -:root[data-theme="fame-lavender"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header .el-tabs__item:hover { +:root[data-shell="outpost"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header .el-tabs__item:hover { background: rgba(124, 58, 237, 0.08) !important; color: #7c3aed !important; } -:root[data-theme="fame-lavender"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { +:root[data-shell="outpost"] .mg-content .admin-tabs.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { background: rgba(124, 58, 237, 0.14) !important; color: #7c3aed !important; } -/* ── 玻璃语境下 Element Plus 的常见组件调整 ── */ +/* Style A ? Ops Console?underline tabs?????????? border-card ??*/ +.mg-content .admin-tabs.admin-tabs--ops { + height: 100%; + background: transparent !important; + border: 0 !important; + box-shadow: none !important; + border-radius: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header { + background: transparent !important; + border-bottom: 1px solid var(--mg-veil-border, rgba(124, 58, 237, 0.14)) !important; + margin: 0 0 8px; + flex: none; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__nav { + border: 0 !important; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__item { + background: transparent !important; + border: 0 !important; + height: 36px; + line-height: 36px; + font-size: 13.5px; + letter-spacing: 0.2px; + padding: 0 16px !important; + color: var(--mg-text-muted, #756d85) !important; + font-weight: 500; + box-shadow: none !important; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__item:hover { + color: var(--mg-primary, #7c3aed) !important; + background: transparent !important; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__item.is-active { + color: var(--mg-primary, #7c3aed) !important; + font-weight: 700; + background: transparent !important; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__active-bar { + background: var(--mg-primary, #7c3aed); + height: 2px; +} +.mg-content .admin-tabs.admin-tabs--ops > .el-tabs__content { + flex: 1; + padding: 0; + overflow: hidden; + background: transparent !important; + min-height: 0; +} +.mg-content .admin-tabs.admin-tabs--ops .el-tab-pane { + height: 100%; +} +:root[data-shell="outpost"] .mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__item { + color: #4a3d6e !important; +} +:root[data-shell="outpost"] .mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__item:hover, +:root[data-shell="outpost"] .mg-content .admin-tabs.admin-tabs--ops > .el-tabs__header .el-tabs__item.is-active { + color: #7c3aed !important; +} + +/* ?? ??????Element Plus ?????????? */ .mg-content .el-card { background: linear-gradient(135deg, rgba(var(--mg-bg-card-rgb), 0.55) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.6) 100%) !important; border: 1px solid rgba(255, 255, 255, 0.14); @@ -425,7 +530,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: var(--mg-text-light); } -/* 嵌套的 card(如 ConfigPageBase 内层)使用更浅的透明度 */ +/* ????card?? ConfigPageBase ????????????*/ .mg-content .el-card .el-card { background: rgba(255, 255, 255, 0.04) !important; border-color: rgba(255, 255, 255, 0.1); @@ -442,12 +547,12 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } .mg-content h3 { color: var(--mg-text-light); margin-top: 0; } .mg-content p { color: var(--mg-text-muted); } -/* el-statistic 在玻璃语境下 */ +/* el-statistic ?????? */ .mg-content .el-statistic__head { color: var(--mg-text-muted); } .mg-content .el-statistic__number { color: var(--mg-text-light); } .mg-content .el-statistic__content { color: var(--mg-text-light); } -/* el-tabs 玻璃语境(SimpleLite 监控台:地图/进程/场景等) */ +/* el-tabs ?????SimpleLite ??????/??/???? */ .mg-content .el-tabs { --el-text-color-primary: #fff; } @@ -468,7 +573,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } background-color: rgba(255, 255, 255, 0.14) !important; } -/* el-table 在玻璃语境下 */ +/* el-table ?????? */ .mg-content .el-table, .mg-content .el-table tr, .mg-content .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell { @@ -507,7 +612,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } .mg-content .el-table__inner-wrapper::before, .mg-content .el-table::before { background: rgba(255, 255, 255, 0.1) !important; } -/* el-input 在玻璃语境下 (admin 区表单) */ +/* el-input ?????? (admin ???? */ .mg-content .el-input__wrapper, .mg-content .el-textarea__inner { background: rgba(255, 255, 255, 0.06) !important; @@ -526,7 +631,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } .mg-content .el-divider__text { background: transparent !important; color: var(--mg-text-muted); } .mg-content .el-divider { background: var(--mg-divider); } -/* el-tag 在玻璃语境下保持主题色 */ +/* el-tag ????????????*/ .mg-content .el-tag { background: rgba(var(--mg-accent-rgb), 0.18); color: rgba(var(--mg-text-tint-rgb), 1); @@ -553,7 +658,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } border-color: rgba(122, 107, 135, 0.5); } -/* el-descriptions 在玻璃语境 */ +/* el-descriptions ??????*/ .mg-content .el-descriptions { --el-text-color-primary: #fff; --el-text-color-regular: var(--mg-text-muted); @@ -579,7 +684,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } } .mg-content .el-descriptions__title { color: #fff; } -/* el-progress 主题色 */ +/* el-progress ????*/ .mg-content .el-progress-bar__outer { background: rgba(255, 255, 255, 0.1); } @@ -587,7 +692,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } background: linear-gradient(90deg, var(--mg-primary-hover) 0%, var(--mg-accent) 100%); } -/* el-alert 玻璃化 */ +/* el-alert ????*/ .mg-content .el-alert { background: rgba(255, 255, 255, 0.06) !important; border: 1px solid rgba(255, 255, 255, 0.14); @@ -596,7 +701,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } .mg-content .el-alert__title, .mg-content .el-alert__description { color: var(--mg-text-light) !important; } -/* el-collapse 玻璃化 */ +/* el-collapse ????*/ .mg-content .el-collapse { border-color: rgba(255, 255, 255, 0.1); --el-collapse-header-bg-color: transparent; @@ -611,7 +716,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: var(--mg-text-light) !important; } -/* el-radio / el-checkbox 在玻璃语境下 */ +/* el-radio / el-checkbox ?????? */ .mg-content .el-radio__label, .mg-content .el-checkbox__label { color: var(--mg-text-light) !important; } .mg-content .el-radio__inner, @@ -632,13 +737,13 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: #fff !important; } -/* el-select 玻璃化 */ +/* el-select ????*/ .mg-content .el-select .el-input__wrapper { background: rgba(255, 255, 255, 0.06) !important; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset !important; } -/* el-input-number 玻璃化 */ +/* el-input-number ????*/ .mg-content .el-input-number .el-input-number__decrease, .mg-content .el-input-number .el-input-number__increase { background: rgba(255, 255, 255, 0.06) !important; @@ -646,13 +751,13 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } border-color: rgba(255, 255, 255, 0.15) !important; } -/* el-switch 主题色 */ +/* el-switch ????*/ .mg-content .el-switch.is-checked .el-switch__core { background: var(--mg-primary); border-color: var(--mg-primary); } -/* el-statistic 颜色重置 */ +/* el-statistic ???? */ .mg-content .el-statistic .el-statistic__head { color: var(--mg-text-muted); font-size: 13px; @@ -664,7 +769,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } text-shadow: 0 2px 8px rgba(var(--mg-primary-hover-rgb), 0.4); } -/* KPI 玻璃卡片: 内的 el-statistic 自动玻璃化 */ +/* KPI ??????el-row class="kpi-row"> ?? el-statistic ??????*/ .mg-content .kpi-row .el-statistic, .mg-content .kpi-card { background: linear-gradient(135deg, rgba(var(--mg-bg-card-rgb), 0.55) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.6) 100%); @@ -684,7 +789,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } border-color: rgba(255, 255, 255, 0.22); } -/* el-pagination 玻璃化 */ +/* el-pagination ????*/ .mg-content .el-pagination { --el-pagination-bg-color: transparent; --el-pagination-button-bg-color: rgba(255, 255, 255, 0.06); @@ -693,7 +798,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: var(--mg-text-muted); } -/* el-dropdown-menu 暗化 */ +/* el-dropdown-menu ?? */ .el-dropdown-menu { background: rgba(var(--mg-bg-aside-rgb), 0.92) !important; backdrop-filter: blur(20px); @@ -707,9 +812,9 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: #fff !important; } -/* el-message / 全局消息提示沿用 Element Plus 默认浅色(在暗背景上更易读) */ +/* el-message / ???????? Element Plus ?????????????? */ -/* el-button default 按钮在玻璃语境下 */ +/* el-button default ???????? */ .mg-content .el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--text) { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.2); @@ -721,11 +826,11 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: #fff; } -/* h2 在浅色页面(如对话框)保留主题深色 */ +/* h2 ??????????????????*/ .el-dialog h2 { color: var(--mg-primary-active); } .el-dialog { border-radius: var(--mg-radius); } -/* ─────────────── 星云紫专项:深紫底 + 蓝紫辉光 + 霓虹高光 ─────────────── */ +/* ??????????????? ??????????+ ???? + ???? ??????????????? */ :root[data-theme="industrial-purple"] { --mg-orb-a-opacity: 0.55; --mg-orb-b-opacity: 0.50; @@ -757,7 +862,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } linear-gradient(135deg, var(--mg-bg-app-1) 0%, var(--mg-bg-app-2) 48%, var(--mg-bg-app-3) 100%); } -/* 星云紫主题下:网格更紫,扫描线更明显 */ +/* ??????:????,?????? */ :root[data-theme="industrial-purple"] #app::before { background-image: linear-gradient(rgba(var(--mg-accent-rgb), 0.07) 1px, transparent 1px), @@ -774,7 +879,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } ); } -/* 卡片:紫色玻璃质感 */ +/* ??????????*/ :root[data-theme="industrial-purple"] .mg-content .el-card, :root[data-theme="industrial-purple"] .mg-content .kpi-row .el-statistic, :root[data-theme="industrial-purple"] .mg-content .kpi-card { @@ -793,7 +898,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } -webkit-backdrop-filter: blur(18px) saturate(160%); overflow: hidden; } -/* 顶部 1px 高光线 → 玻璃质感的灵魂 */ +/* ?? 1px ??????????????*/ :root[data-theme="industrial-purple"] .mg-content .el-card::before, :root[data-theme="industrial-purple"] .mg-content .kpi-card::before { content: ''; @@ -853,7 +958,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } box-shadow: 0 0 0 1px rgba(var(--mg-accent-rgb), 0.18) inset !important; } -/* 主按钮:紫色霓虹脉冲 */ +/* ?????????? */ :root[data-theme="industrial-purple"] .el-button--primary { background: linear-gradient(135deg, var(--mg-primary) 0%, @@ -887,14 +992,14 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } transform: translateY(-1px); } -/* el-tag 增强紫色感 */ +/* el-tag ??????*/ :root[data-theme="industrial-purple"] .mg-content .el-tag { background: linear-gradient(135deg, rgba(var(--mg-primary-rgb), 0.25), rgba(var(--mg-accent-rgb), 0.18)); color: #fff; border-color: rgba(var(--mg-accent-rgb), 0.45); } -/* 工具类:霓虹文字(卡片标题、KPI数值可用) */ +/* ??????????????KPI????? */ .mg-neon-text { background: linear-gradient(135deg, #ffffff 0%, var(--mg-accent) 100%); -webkit-background-clip: text; @@ -903,7 +1008,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } text-shadow: 0 0 18px rgba(var(--mg-primary-hover-rgb), 0.45); } -/* 工具类:浮动 orb 节奏可视化(其他组件可用) */ +/* ?????? orb ??????????????*/ @keyframes mg-pulse-glow { 0%, 100% { box-shadow: @@ -917,13 +1022,13 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } } } -/* ════════════════════════════════════════════════════════════════════ - * 迷毂浅紫 (fame-lavender) ─ 参考 FAME 系统的浅色变体 - * 设计目标:长时间使用不刺眼;右侧主区采用浅紫白底,深紫字色; - * 侧栏保留深紫渐变以维持品牌一致性; - * 关闭所有 orb / 网格 / 扫描线 / 霓虹光效。 - * ════════════════════════════════════════════════════════════════════ */ -:root[data-theme="fame-lavender"] { +/* ???????????????????????????????????????????????????????????????????? + * ???? (fame-lavender) ? ???FAME ???????? + * ?????????????????????????????? + * ????????????????? + * ?????orb / ?? / ????/ ?????? + * ???????????????????????????????????????????????????????????????????? */ +:root[data-shell="outpost"] { --mg-glass-bg: rgba(255, 255, 255, 0.95); --mg-glass-bg-hover: rgba(249, 246, 255, 1); --mg-glass-bg-active: rgba(245, 240, 251, 1); @@ -938,14 +1043,14 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } --mg-glass-blur: none; --mg-glass-blur-hi: none; - /* 文字层级(v2 升级:对比度从 4.5:1 提至 7-15:1,关键提升正文清晰度) */ - --mg-text-light: #1a0f3d; /* 主标题 / KPI 数字 · 对比 ~15:1 */ - --mg-text-muted: #4a3d6e; /* 正文 / 说明文字 · 对比 ~9:1 (旧 #606266 在浅紫底上仅 5:1) */ - --mg-text-dim: #7a6b9a; /* 次要信息 / 时间戳 / 占位 · 对比 ~5:1 */ - --mg-text-faint: #a8a0c0; /* 禁用 / 装饰文字 · 对比 ~3:1 */ - --mg-divider: #e8e1f0; /* 分割线 / 卡片描边(比 #ebe5f4 略冷略深,更清晰) */ + /* ?????v2 ????????4.5:1 ?? 7-15:1????????????*/ + --mg-text-light: #1a0f3d; /* ????/ KPI ?? ? ?? ~15:1 */ + --mg-text-muted: #4a3d6e; /* ?? / ???? ? ?? ~9:1 (??#606266 ?????? 5:1) */ + --mg-text-dim: #7a6b9a; /* ???? / ????/ ?? ? ?? ~5:1 */ + --mg-text-faint: #a8a0c0; /* ?? / ???? ? ?? ~3:1 */ + --mg-divider: #e8e1f0; /* ????/ ?????? #ebe5f4 ??????????*/ - /* 浅色主题状态色 (WCAG AA 兼容) */ + /* ??????? (WCAG AA ??) */ --mg-status-success: #15803d; --mg-status-success-rgb: 21, 128, 61; --mg-status-warning: #b45309; @@ -957,56 +1062,207 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } --mg-status-idle: #64748b; --mg-status-idle-rgb: 100, 116, 139; - /* 卡片 / 列表行:浅底白卡(与 industrial-purple 深紫卡区分) */ + /* ?? / ?????????? industrial-purple ?????? */ --mg-bg-card-rgb: 255, 255, 255; --mg-bg-card-hi-rgb: 249, 246, 255; --mg-bg-card-darker-rgb: 245, 240, 251; --mg-accent-rgb: 124, 58, 237; - /* 内嵌表面 veil:浅紫主题翻成紫色微透,保证白底卡片内的嵌套行 / 子卡可见 */ + /* ???? veil????????????????????????/ ???? */ --mg-veil-1: rgba(124, 58, 237, 0.05); --mg-veil-2: rgba(124, 58, 237, 0.10); --mg-veil-3: rgba(124, 58, 237, 0.16); --mg-veil-border: rgba(124, 58, 237, 0.16); --mg-veil-border-hi: rgba(124, 58, 237, 0.32); - /* 动作按钮:实色紫底 + 白字(对比 ~6.9:1,WCAG AA 通过) */ + /* ??????????+ ??????~6.9:1?WCAG AA ????*/ --mg-action-bg: #7c3aed; --mg-action-bg-hover: #6d28d9; --mg-action-border: #7c3aed; --mg-action-border-hi: #6d28d9; } -/* body:纯净紫白渐变(更柔和、长时间阅读不刺眼) */ -:root[data-theme="fame-lavender"] body { - background: - linear-gradient(160deg, #edeaf5 0%, #e7e4f1 55%, #f1eef8 100%) !important; - color: #1a0f3d; +/* outpost-light: pure white shell + indigo accents */ +:root[data-theme="outpost-light"] { + --mg-glass-bg: #ffffff; + --mg-glass-bg-hover: #ffffff; + --mg-glass-bg-active: #f8fafc; + --mg-glass-border: rgba(15, 23, 42, 0.10); + --mg-glass-border-hi: rgba(30, 58, 95, 0.22); + --mg-glass-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 0 0 1px rgba(15, 23, 42, 0.06); + --mg-glass-shadow-hi: + 0 4px 16px rgba(15, 23, 42, 0.06), + 0 0 0 1px rgba(30, 58, 95, 0.12); + --mg-text-light: #0f172a; + --mg-text-muted: #64748b; + --mg-text-dim: #94a3b8; + --mg-text-faint: #cbd5e1; + --mg-divider: #e5e7eb; + --mg-veil-1: rgba(15, 23, 42, 0.03); + --mg-veil-2: rgba(15, 23, 42, 0.05); + --mg-veil-3: rgba(15, 23, 42, 0.08); + --mg-veil-border: rgba(15, 23, 42, 0.10); + --mg-veil-border-hi: rgba(30, 58, 95, 0.22); + --mg-action-bg: #7c3aed; + --mg-action-bg-hover: #8b5cf6; + --mg-action-border: #7c3aed; + --mg-action-border-hi: #8b5cf6; +} +:root[data-theme="outpost-light"] body { + background: #ffffff !important; +} +:root[data-theme="outpost-light"] .aside { + background: #ffffff !important; + border-right: 1px solid #e5e7eb !important; + box-shadow: 1px 0 0 rgba(15, 23, 42, 0.04) !important; +} +:root[data-theme="outpost-light"] .aside .logo { + border-bottom-color: #e5e7eb !important; + background: #ffffff !important; +} +:root[data-theme="outpost-light"] .aside .logo-img-full, +:root[data-theme="outpost-light"] .aside .logo-img-small { + filter: brightness(0) saturate(100%) invert(27%) sepia(86%) saturate(2400%) hue-rotate(248deg) brightness(0.95); +} +:root[data-theme="outpost-light"] .aside .logo-title-zh { + background: none !important; + -webkit-text-fill-color: #7c3aed !important; + color: #7c3aed !important; + text-shadow: none !important; +} +:root[data-theme="outpost-light"] .aside .logo-title-en { + color: #94a3b8 !important; +} +:root[data-theme="outpost-light"] .aside .logo-title-en b { + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .aside .logo-abbr { + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .aside .el-menu-item, +:root[data-theme="outpost-light"] .aside .el-sub-menu__title { + color: #475569 !important; +} +:root[data-theme="outpost-light"] .aside .el-menu-item:hover, +:root[data-theme="outpost-light"] .aside .el-sub-menu__title:hover { + background: #f8fafc !important; + color: #0f172a !important; +} +:root[data-theme="outpost-light"] .aside .el-menu-item.is-active, +:root[data-theme="outpost-light"] .aside .el-sub-menu.is-active > .el-sub-menu__title { + background: #f5f3ff !important; + color: #7c3aed !important; + box-shadow: inset 3px 0 0 #7c3aed !important; + font-weight: 700 !important; +} +:root[data-theme="outpost-light"] .aside .el-menu .el-menu-item.is-active { + background: #f5f3ff !important; + box-shadow: inset 2px 0 0 #7c3aed !important; + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .aside .el-sub-menu .el-menu { + background: #f8fafc !important; +} +:root[data-theme="outpost-light"] .aside-footer { + border-top-color: #e5e7eb !important; + color: #94a3b8 !important; +} +:root[data-theme="outpost-light"] .aside-footer-badge { + background: #f5f3ff !important; + border-color: #ddd6fe !important; + color: #7c3aed !important; + box-shadow: none !important; +} +:root[data-theme="outpost-light"] .header { + background: #ffffff !important; + border-bottom-color: #e5e7eb !important; + box-shadow: none !important; +} +:root[data-theme="outpost-light"] .header .el-breadcrumb__inner { color: #64748b !important; } +:root[data-theme="outpost-light"] .header .el-breadcrumb__item:last-child .el-breadcrumb__inner { + color: #0f172a !important; +} +:root[data-theme="outpost-light"] .header .el-breadcrumb__separator { color: #cbd5e1 !important; } +:root[data-theme="outpost-light"] .header .el-breadcrumb__inner:hover { color: #7c3aed !important; } +:root[data-theme="outpost-light"] .header .user-name { color: #0f172a !important; } +:root[data-theme="outpost-light"] .header .icon-btn:hover { color: #7c3aed !important; } +:root[data-theme="outpost-light"] .header .el-radio-button.is-active .el-radio-button__inner { + background: #7c3aed !important; + border-color: #7c3aed !important; + color: #fff !important; +} +:root[data-theme="outpost-light"] .mg-content h1, +:root[data-theme="outpost-light"] .mg-content h2, +:root[data-theme="outpost-light"] .mg-content h3 { + color: #0f172a !important; +} +:root[data-theme="outpost-light"] .mg-content p { color: #64748b !important; } +:root[data-theme="outpost-light"] .mg-content .el-tabs__item, +:root[data-theme="outpost-light"] .el-tabs__item { color: #64748b !important; } +:root[data-theme="outpost-light"] .mg-content .el-tabs__item:hover, +:root[data-theme="outpost-light"] .el-tabs__item:hover { color: #7c3aed !important; } +:root[data-theme="outpost-light"] .mg-content .el-tabs__item.is-active, +:root[data-theme="outpost-light"] .el-tabs__item.is-active { + color: #7c3aed !important; + font-weight: 700; +} +:root[data-theme="outpost-light"] .el-tabs__active-bar { + background-color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .mg-content .el-form-item__label { color: #64748b !important; } +:root[data-theme="outpost-light"] .mg-content .method-btn, +:root[data-theme="outpost-light"] .mg-content .header-action-btn { + background: #7c3aed !important; + border-color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .mg-content .method-btn:hover:not(:disabled), +:root[data-theme="outpost-light"] .mg-content .header-action-btn:hover { + background: #8b5cf6 !important; + border-color: #8b5cf6 !important; +} +:root[data-theme="outpost-light"] .mg-content .add-field-btn { + background: #ffffff !important; + border: 1px dashed #cbd5e1 !important; + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .mg-content .add-field-btn:hover { + background: #f8fafc !important; + border-color: #7c3aed !important; + color: #0f172a !important; } -/* 关闭全局背景网格与扫描线 */ -:root[data-theme="fame-lavender"] #app::before, -:root[data-theme="fame-lavender"] #app::after { +/* body???????????--mg-bg-app-*??*/ +:root[data-shell="outpost"] body { + background: + linear-gradient(160deg, var(--mg-bg-app-1) 0%, var(--mg-bg-app-2) 55%, var(--mg-bg-app-3) 100%) !important; + color: var(--mg-text-light); +} + +/* ???????????? */ +:root[data-shell="outpost"] #app::before, +:root[data-shell="outpost"] #app::after { display: none !important; } -/* 关闭 AppShell 中的浮动 orb */ -:root[data-theme="fame-lavender"] .bg-orbs { display: none !important; } +/* ?? AppShell ???? orb */ +:root[data-shell="outpost"] .bg-orbs { display: none !important; } -/* 侧栏:宝石紫渐变 + 高可读性 menu 状态(v2 优化对比度与层级) */ -:root[data-theme="fame-lavender"] .aside { - background: linear-gradient(180deg, #2e1065 0%, #1c0a4d 100%) !important; +/* ?????? aside ???? plum / ??/ ?? */ +:root[data-shell="outpost"] .aside { + background: linear-gradient(180deg, var(--mg-bg-aside-1) 0%, var(--mg-bg-aside-2) 100%) !important; border-right: 1px solid rgba(0, 0, 0, 0.08) !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; box-shadow: 4px 0 24px rgba(45, 27, 105, 0.18) !important; } -:root[data-theme="fame-lavender"] .aside::before { display: none !important; } -:root[data-theme="fame-lavender"] .aside .logo { +:root[data-shell="outpost"] .aside::before { display: none !important; } +:root[data-shell="outpost"] .aside .logo { background: transparent !important; border-bottom: 1px solid rgba(196, 181, 253, 0.12) !important; } -:root[data-theme="fame-lavender"] .aside .logo-title-zh { +:root[data-shell="outpost"] .aside .logo-title-zh { background: linear-gradient(135deg, #ffffff 0%, #c4b5fd 100%) !important; -webkit-background-clip: text !important; background-clip: text !important; @@ -1016,26 +1272,26 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } font-weight: 700; } -/* inactive menu item:浅紫白文字(提高可读性,比纯白稍柔和) - * 会话 14:原规则同样误用了 Vue scoped 专属的 :deep(),在全局 css 中无效。 - * 去掉 :deep() 后规则才能落到 element-plus 渲染出的 .el-menu-item / .el-sub-menu__title 上。 */ -:root[data-theme="fame-lavender"] .aside .el-menu { +/* inactive menu item????????????????????? + * ?? 14??????????Vue scoped ????:deep()???? css ????? + * ?? :deep() ????????element-plus ???? .el-menu-item / .el-sub-menu__title ???*/ +:root[data-shell="outpost"] .aside .el-menu { background: transparent !important; border-right: none !important; } -:root[data-theme="fame-lavender"] .aside .el-menu-item, -:root[data-theme="fame-lavender"] .aside .el-sub-menu__title { +:root[data-shell="outpost"] .aside .el-menu-item, +:root[data-shell="outpost"] .aside .el-sub-menu__title { color: #d4cae8 !important; font-weight: 500 !important; background: transparent !important; } -:root[data-theme="fame-lavender"] .aside .el-menu-item:hover, -:root[data-theme="fame-lavender"] .aside .el-sub-menu__title:hover { +:root[data-shell="outpost"] .aside .el-menu-item:hover, +:root[data-shell="outpost"] .aside .el-sub-menu__title:hover { background: rgba(196, 181, 253, 0.10) !important; color: #ffffff !important; } -:root[data-theme="fame-lavender"] .aside .el-menu-item.is-active, -:root[data-theme="fame-lavender"] .aside .el-sub-menu.is-active > .el-sub-menu__title { +:root[data-shell="outpost"] .aside .el-menu-item.is-active, +:root[data-shell="outpost"] .aside .el-sub-menu.is-active > .el-sub-menu__title { background: linear-gradient(90deg, rgba(168, 85, 247, 0.30) 0%, rgba(124, 58, 237, 0.16) 100%) !important; color: #ffffff !important; font-weight: 700 !important; @@ -1043,110 +1299,161 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } text-shadow: none !important; } -/* 子菜单 active 区分(左条更细更柔和) */ -:root[data-theme="fame-lavender"] .aside .el-menu .el-menu-item.is-active { +/* ????active ????????????*/ +:root[data-shell="outpost"] .aside .el-menu .el-menu-item.is-active { background: linear-gradient(90deg, rgba(168, 85, 247, 0.22) 0%, transparent 100%) !important; box-shadow: inset 2px 0 0 #a855f7 !important; } -/* 顶部 header:白色卡片质感 */ -:root[data-theme="fame-lavender"] .header { +/* outpost-light white sidebar wins over purple shell nav colors */ +:root[data-shell="outpost"][data-theme="outpost-light"] .aside { + background: #ffffff !important; + border-right: 1px solid #e5e7eb !important; + box-shadow: 1px 0 0 rgba(15, 23, 42, 0.04) !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .logo { + border-bottom-color: #e5e7eb !important; + background: #ffffff !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .logo-img-full, +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .logo-img-small { + filter: brightness(0) saturate(100%) invert(27%) sepia(86%) saturate(2400%) hue-rotate(248deg) brightness(0.95); +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .logo-title-zh { + background: none !important; + -webkit-background-clip: initial !important; + background-clip: initial !important; + -webkit-text-fill-color: #7c3aed !important; + color: #7c3aed !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-menu-item, +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-sub-menu__title { + color: #475569 !important; + background: transparent !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-menu-item:hover, +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-sub-menu__title:hover { + background: #f8fafc !important; + color: #0f172a !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-menu-item.is-active, +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-sub-menu.is-active > .el-sub-menu__title { + background: #f5f3ff !important; + color: #7c3aed !important; + box-shadow: inset 3px 0 0 #7c3aed !important; + text-shadow: none !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-menu .el-menu-item.is-active { + background: #f5f3ff !important; + box-shadow: inset 2px 0 0 #7c3aed !important; + color: #7c3aed !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .aside .el-sub-menu .el-menu { + background: #f8fafc !important; +} +:root[data-shell="outpost"][data-theme="outpost-light"] .header { + border-bottom-color: #e5e7eb !important; + box-shadow: none !important; +} + +/* ?? header????????*/ +:root[data-shell="outpost"] .header { background: #fff !important; border-bottom: 1px solid #ebe5f4 !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; box-shadow: 0 1px 6px rgba(45, 27, 105, 0.05) !important; } -:root[data-theme="fame-lavender"] .header::after { display: none !important; } -/* 会话 14:原规则误用 Vue scoped 专属的 :deep() 伪类写在全局 css 里,浏览器不识别 → 整条规则 - * 被丢弃,AppShell.vue scoped 中的白色面包屑文字在 fame-lavender 白底 header 上完全看不见。 - * 这里去掉 :deep(),直接用普通后代选择器;特异性 (0,4,0) + !important 足以覆盖 scoped 的 (0,2,0)。 - * 同时补上面包屑分隔符 "/" 的色,原默认是 rgba(255,255,255,.35),在白底上也不可见。 */ -:root[data-theme="fame-lavender"] .header .el-breadcrumb__inner { color: #4a3d6e !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .header .el-breadcrumb__item:last-child .el-breadcrumb__inner { +:root[data-shell="outpost"] .header::after { display: none !important; } +/* ?? 14?????? Vue scoped ????:deep() ?????? css ???????? ?????? + * ????AppShell.vue scoped ?????????? fame-lavender ?? header ???????? + * ???? :deep()????????????????(0,4,0) + !important ???? scoped ??(0,2,0)?? + * ?????????? "/" ????????rgba(255,255,255,.35)???????????*/ +:root[data-shell="outpost"] .header .el-breadcrumb__inner { color: #4a3d6e !important; font-weight: 500; } +:root[data-shell="outpost"] .header .el-breadcrumb__item:last-child .el-breadcrumb__inner { color: #7c3aed !important; font-weight: 700; } -:root[data-theme="fame-lavender"] .header .el-breadcrumb__separator { color: #a8a0c0 !important; } -:root[data-theme="fame-lavender"] .header .el-breadcrumb__inner:hover { color: #7c3aed !important; } -:root[data-theme="fame-lavender"] .header .icon-btn { +:root[data-shell="outpost"] .header .el-breadcrumb__separator { color: #a8a0c0 !important; } +:root[data-shell="outpost"] .header .el-breadcrumb__inner:hover { color: #7c3aed !important; } +:root[data-shell="outpost"] .header .icon-btn { border-color: #ebe5f4 !important; color: #7c3aed !important; background: #f8f4fc !important; } -:root[data-theme="fame-lavender"] .header .icon-btn:hover { +:root[data-shell="outpost"] .header .icon-btn:hover { background: #ede9fe !important; border-color: #c4b5fd !important; color: #5b21b6 !important; } -:root[data-theme="fame-lavender"] .header .runmode-tag { +:root[data-shell="outpost"] .header .runmode-tag { background: #f5f3ff !important; border-color: #ddd6fe !important; color: #5b21b6 !important; } -:root[data-theme="fame-lavender"] .header .runmode-tag.WebEnabled { +:root[data-shell="outpost"] .header .runmode-tag.WebEnabled { background: #ecfdf5 !important; border-color: #bbf7d0 !important; color: #047857 !important; } -/* 会话 N+2:补 WebOnly 适配。原默认深主题 .runmode-tag.WebOnly 用 var(--mg-accent) 浅紫白字, - 在 fame-lavender 白色 header 上跟背景同色 → 完全看不见。这里强制为「紫色描边 + 深紫字」。 */ -:root[data-theme="fame-lavender"] .header .runmode-tag.WebOnly { +/* ?? N+2?? WebOnly ??????????.runmode-tag.WebOnly ??var(--mg-accent) ?????? + ??fame-lavender ?? header ?????? ???????????????????+ ??????*/ +:root[data-shell="outpost"] .header .runmode-tag.WebOnly { background: #ede9fe !important; border-color: #c4b5fd !important; color: #6d28d9 !important; } -/* 会话 N+2:header 内 ScopeSwitcher (el-radio-button) 在 fame-lavender 白底下的适配。 - * 默认深主题下 .mg-content .el-radio-button 已配深底白字,但 header 不在 .mg-content 里, - * 所以 fame-lavender 切到白底 header 时 element-plus 默认样式 (#fff 底 + #606266 字) 接管, - * 看起来"灰色椭圆 + 几乎不可读" —— 这里强制改成紫色描边 + 深紫字,激活态紫底白字。 */ -:root[data-theme="fame-lavender"] .header .el-radio-button__inner { +/* ?? N+2?header ??ScopeSwitcher (el-radio-button) ??fame-lavender ???????? + * ?????? .mg-content .el-radio-button ???????? header ?? .mg-content ?? + * ???fame-lavender ???? header ??element-plus ???? (#fff ??+ #606266 ?? ???? + * ???????? + ?????? ????????????? + ?????????????*/ +:root[data-shell="outpost"] .header .el-radio-button__inner { background: #fff !important; border-color: #ddd6fe !important; color: #5b21b6 !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .header .el-radio-button__inner:hover { +:root[data-shell="outpost"] .header .el-radio-button__inner:hover { color: #6d28d9 !important; border-color: #c4b5fd !important; background: #f5f3ff !important; } -:root[data-theme="fame-lavender"] .header .el-radio-button.is-active .el-radio-button__inner { +:root[data-shell="outpost"] .header .el-radio-button.is-active .el-radio-button__inner { background: #7c3aed !important; border-color: #7c3aed !important; color: #ffffff !important; box-shadow: 0 2px 6px rgba(124, 58, 237, 0.25); } -:root[data-theme="fame-lavender"] .header .user-link { +:root[data-shell="outpost"] .header .user-link { background: #f8f4fc !important; border-color: #ebe5f4 !important; color: #2d1b69 !important; } -:root[data-theme="fame-lavender"] .header .user-link:hover { +:root[data-shell="outpost"] .header .user-link:hover { background: #ede9fe !important; border-color: #c4b5fd !important; } -:root[data-theme="fame-lavender"] .header .user-name { color: #2d1b69 !important; } +:root[data-shell="outpost"] .header .user-name { color: #2d1b69 !important; } -/* 主内容区:纯净紫白底 + 高对比深字(v2 文字层级清晰化) */ -:root[data-theme="fame-lavender"] .main, -:root[data-theme="fame-lavender"] .mg-content { +/* ???????????+ ??????v2 ???????? */ +:root[data-shell="outpost"] .main, +:root[data-shell="outpost"] .mg-content { background: transparent !important; color: #1a0f3d !important; } -:root[data-theme="fame-lavender"] .mg-content h1, -:root[data-theme="fame-lavender"] .mg-content h2, -:root[data-theme="fame-lavender"] .mg-content h3 { +:root[data-shell="outpost"] .mg-content h1, +:root[data-shell="outpost"] .mg-content h2, +:root[data-shell="outpost"] .mg-content h3 { color: #1a0f3d !important; font-weight: 700; } -:root[data-theme="fame-lavender"] .mg-content p { color: #4a3d6e !important; } -:root[data-theme="fame-lavender"] .mg-content small, -:root[data-theme="fame-lavender"] .mg-content .text-secondary { color: #7a6b9a !important; } +:root[data-shell="outpost"] .mg-content p { color: #4a3d6e !important; } +:root[data-shell="outpost"] .mg-content small, +:root[data-shell="outpost"] .mg-content .text-secondary { color: #7a6b9a !important; } /* footer */ -:root[data-theme="fame-lavender"] .footer { +:root[data-shell="outpost"] .footer { background: rgba(255, 255, 255, 0.7) !important; border-top: 1px solid #ebe5f4 !important; backdrop-filter: none !important; @@ -1154,8 +1461,8 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: #909399 !important; } -/* el-card:白底紫描边 + 双层阴影(紫色阴影更显品牌) */ -:root[data-theme="fame-lavender"] .mg-content .el-card { +/* el-card?????? + ?????????????? */ +:root[data-shell="outpost"] .mg-content .el-card { background: #ffffff !important; border: 1px solid #e2dbf0 !important; border-radius: 14px !important; @@ -1167,8 +1474,8 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: #1a0f3d !important; overflow: hidden; } -:root[data-theme="fame-lavender"] .mg-content .el-card::before { display: none !important; } -:root[data-theme="fame-lavender"] .mg-content .el-card:hover { +:root[data-shell="outpost"] .mg-content .el-card::before { display: none !important; } +:root[data-shell="outpost"] .mg-content .el-card:hover { background: #ffffff !important; border-color: #c4b5fd !important; box-shadow: @@ -1176,25 +1483,25 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 0 18px 40px rgba(108, 71, 200, 0.16) !important; transform: translateY(-2px) !important; } -:root[data-theme="fame-lavender"] .mg-content .el-card .el-card { +:root[data-shell="outpost"] .mg-content .el-card .el-card { background: #faf7fd !important; border-color: #f0eaf6 !important; box-shadow: none !important; } -:root[data-theme="fame-lavender"] .mg-content .el-card__header { +:root[data-shell="outpost"] .mg-content .el-card__header { background: #faf7fd !important; border-bottom: 1px solid #e8e1f0 !important; color: #1a0f3d !important; font-weight: 600; } -:root[data-theme="fame-lavender"] .mg-content .el-card__body { +:root[data-shell="outpost"] .mg-content .el-card__body { background: transparent !important; color: #1a0f3d !important; } -/* KPI 玻璃卡片:浅底化 */ -:root[data-theme="fame-lavender"] .mg-content .kpi-row .el-statistic, -:root[data-theme="fame-lavender"] .mg-content .kpi-card { +/* KPI ???????? */ +:root[data-shell="outpost"] .mg-content .kpi-row .el-statistic, +:root[data-shell="outpost"] .mg-content .kpi-card { background: #fff !important; border: 1px solid #e2dbf0 !important; border-radius: 12px !important; @@ -1205,8 +1512,8 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } -webkit-backdrop-filter: none !important; padding: 16px 18px !important; } -:root[data-theme="fame-lavender"] .mg-content .kpi-row .el-statistic:hover, -:root[data-theme="fame-lavender"] .mg-content .kpi-card:hover { +:root[data-shell="outpost"] .mg-content .kpi-row .el-statistic:hover, +:root[data-shell="outpost"] .mg-content .kpi-card:hover { background: #fff !important; border-color: #c4b5fd !important; box-shadow: @@ -1214,9 +1521,9 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 0 14px 32px rgba(108, 71, 200, 0.15) !important; transform: translateY(-2px) !important; } -:root[data-theme="fame-lavender"] .mg-content .el-statistic__head { color: #4a3d6e !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .el-statistic__number, -:root[data-theme="fame-lavender"] .mg-content .el-statistic__content { +:root[data-shell="outpost"] .mg-content .el-statistic__head { color: #4a3d6e !important; font-weight: 500; } +:root[data-shell="outpost"] .mg-content .el-statistic__number, +:root[data-shell="outpost"] .mg-content .el-statistic__content { background: none !important; -webkit-background-clip: initial !important; background-clip: initial !important; @@ -1227,8 +1534,8 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } font-weight: 700; } -/* el-table:浅底高对比 + 紫色 hover */ -:root[data-theme="fame-lavender"] .mg-content .el-table { +/* el-table?????? + ?? hover */ +:root[data-shell="outpost"] .mg-content .el-table { --el-table-bg-color: #ffffff; --el-table-tr-bg-color: #ffffff; --el-table-header-bg-color: #faf7fd; @@ -1240,207 +1547,249 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } --el-fill-color-light: #faf7fd; color: #1a0f3d !important; } -:root[data-theme="fame-lavender"] .mg-content .el-table, -:root[data-theme="fame-lavender"] .mg-content .el-table tr { +:root[data-shell="outpost"] .mg-content .el-table, +:root[data-shell="outpost"] .mg-content .el-table tr { background: #ffffff !important; } -:root[data-theme="fame-lavender"] .mg-content .el-table th.el-table__cell { +:root[data-shell="outpost"] .mg-content .el-table th.el-table__cell { background: #faf7fd !important; color: #1a0f3d !important; border-bottom-color: #e8e1f0 !important; font-weight: 600; } -:root[data-theme="fame-lavender"] .mg-content .el-table td.el-table__cell { +:root[data-shell="outpost"] .mg-content .el-table td.el-table__cell { background: #ffffff !important; color: #1a0f3d !important; border-bottom-color: #f0eaf6 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-table--striped .el-table__body tr:nth-child(2n) td.el-table__cell { +:root[data-shell="outpost"] .mg-content .el-table--striped .el-table__body tr:nth-child(2n) td.el-table__cell { background: #faf7fd !important; } -:root[data-theme="fame-lavender"] .mg-content .el-table tr:hover > td.el-table__cell { +:root[data-shell="outpost"] .mg-content .el-table tr:hover > td.el-table__cell { background: #f5f3ff !important; } -/* el-input / el-textarea / el-select:浅底 + 深字 + 焦点紫光晕 */ -:root[data-theme="fame-lavender"] .mg-content .el-input__wrapper, -:root[data-theme="fame-lavender"] .mg-content .el-textarea__inner, -:root[data-theme="fame-lavender"] .mg-content .el-select .el-input__wrapper { +/* el-input / el-textarea / el-select????+ ?? + ??????*/ +:root[data-shell="outpost"] .mg-content .el-input__wrapper, +:root[data-shell="outpost"] .mg-content .el-textarea__inner, +:root[data-shell="outpost"] .mg-content .el-select .el-input__wrapper { background: #ffffff !important; box-shadow: 0 0 0 1px #e0d4f5 inset !important; } -:root[data-theme="fame-lavender"] .mg-content .el-input__wrapper:hover { +:root[data-shell="outpost"] .mg-content .el-input__wrapper:hover { box-shadow: 0 0 0 1px #c4b5fd inset !important; } -:root[data-theme="fame-lavender"] .mg-content .el-input__wrapper.is-focus { +:root[data-shell="outpost"] .mg-content .el-input__wrapper.is-focus { box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.15) !important; } -:root[data-theme="fame-lavender"] .mg-content .el-input__inner, -:root[data-theme="fame-lavender"] .mg-content .el-textarea__inner { color: #1a0f3d !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .el-input__inner::placeholder, -:root[data-theme="fame-lavender"] .mg-content .el-textarea__inner::placeholder { color: #a8a0c0 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-form-item__label { color: #4a3d6e !important; font-weight: 500; } +:root[data-shell="outpost"] .mg-content .el-input__inner, +:root[data-shell="outpost"] .mg-content .el-textarea__inner { color: #1a0f3d !important; font-weight: 500; } +:root[data-shell="outpost"] .mg-content .el-input__inner::placeholder, +:root[data-shell="outpost"] .mg-content .el-textarea__inner::placeholder { color: #a8a0c0 !important; } +:root[data-shell="outpost"] .mg-content .el-form-item__label { color: #4a3d6e !important; font-weight: 500; } -/* el-tabs:紫色活跃 + 高对比 */ -/* 浅色主题下 el-tabs 容器内不再继承玻璃主题强制白字,恢复深色 primary, - 避免 .mg-content .el-tabs { --el-text-color-primary: #fff } 把内部 h2 / label - 一并染白后出现"白底白字"(车辆管理标题、其他 tabs 内 .ref-mgr-title 同理)。 */ -:root[data-theme="fame-lavender"] .mg-content .el-tabs { +/* el-tabs??????+ ????*/ +/* ??????el-tabs ???????????????????? primary?? + ?? .mg-content .el-tabs { --el-text-color-primary: #fff } ????h2 / label + ???????"????"???????????tabs ??.ref-mgr-title ?????*/ +:root[data-shell="outpost"] .mg-content .el-tabs { --el-text-color-primary: #2d1b69 !important; --el-text-color-regular: #303133 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tabs__item { color: #4a3d6e !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .el-tabs__item:hover { color: #7c3aed !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tabs__item.is-active { +:root[data-shell="outpost"] .mg-content .el-tabs__item { color: #4a3d6e !important; font-weight: 500; } +:root[data-shell="outpost"] .mg-content .el-tabs__item:hover { color: #7c3aed !important; } +:root[data-shell="outpost"] .mg-content .el-tabs__item.is-active { color: #7c3aed !important; font-weight: 700; } -/* 兜底:element-plus 内部 css 改版后曾出现「.el-tabs__item 不在 .mg-content 路径下」的场景, - 再加一组不带 .mg-content 限定的规则,避免在 dialog / drawer 中弹出的 tabs 仍然白字。 */ -:root[data-theme="fame-lavender"] .el-tabs__item { color: #4a3d6e !important; } -:root[data-theme="fame-lavender"] .el-tabs__item:hover { color: #7c3aed !important; } -:root[data-theme="fame-lavender"] .el-tabs__item.is-active { color: #7c3aed !important; font-weight: 700; } -:root[data-theme="fame-lavender"] .mg-content .el-tabs__active-bar { +/* ???element-plus ?? css ????????el-tabs__item ?? .mg-content ????????? + ???????.mg-content ??????????dialog / drawer ???? tabs ??????*/ +:root[data-shell="outpost"] .el-tabs__item { color: #4a3d6e !important; } +:root[data-shell="outpost"] .el-tabs__item:hover { color: #7c3aed !important; } +:root[data-shell="outpost"] .el-tabs__item.is-active { color: #7c3aed !important; font-weight: 700; } +:root[data-shell="outpost"] .mg-content .el-tabs__active-bar { background-color: #7c3aed !important; height: 3px !important; border-radius: 2px; } -:root[data-theme="fame-lavender"] .mg-content .el-tabs__nav-wrap::after { background-color: #e8e1f0 !important; } +:root[data-shell="outpost"] .mg-content .el-tabs__nav-wrap::after { background-color: #e8e1f0 !important; } -/* ───────────────────────────────────────────────────────────────────────── - * ReflectionManagerPanel / CarPanelView 等 tabs 内长标题(会话 N+2 UI 修复) - * 问题:组件 scoped CSS 写 `color: var(--el-text-color-primary, #fff)`, - * 但在 el-tab-pane 嵌套层级里变量没下传,回退到 #fff → 浅紫背景白字看不清。 - * 解法:在 fame-lavender 主题下直接给关键文字类「深紫色 #2d1b69」+ !important, - * 优先级压过 scoped css。同时把面板卡片底色从深紫透明改成白色,对比足够。 - * ───────────────────────────────────────────────────────────────────────── */ -:root[data-theme="fame-lavender"] .mg-content .ref-mgr-page, -:root[data-theme="fame-lavender"] .mg-content .ref-mgr-title, -:root[data-theme="fame-lavender"] .mg-content .field-section-title, -:root[data-theme="fame-lavender"] .mg-content .detail-name { +/* ????????????????????????????????????????????????????????????????????????? + * ReflectionManagerPanel / CarPanelView ??tabs ????????N+2 UI ???? + * ??????scoped CSS ??`color: var(--el-text-color-primary, #fff)`?? + * ?? el-tab-pane ???????????????#fff ????????????? + * ???? fame-lavender ??????????????? #2d1b69?? !important?? + * ??????scoped css?????????????????????????? + * ????????????????????????????????????????????????????????????????????????? */ +:root[data-shell="outpost"] .mg-content .ref-mgr-page, +:root[data-shell="outpost"] .mg-content .ref-mgr-title, +:root[data-shell="outpost"] .mg-content .field-section-title, +:root[data-shell="outpost"] .mg-content .detail-name { color: #2d1b69 !important; } -:root[data-theme="fame-lavender"] .mg-content .ref-mgr-title { font-weight: 700; } -:root[data-theme="fame-lavender"] .mg-content .detail-id, -:root[data-theme="fame-lavender"] .mg-content .ref-type-asm, -:root[data-theme="fame-lavender"] .mg-content .field-empty { +:root[data-shell="outpost"] .mg-content .ref-mgr-title { font-weight: 700; } +:root[data-shell="outpost"] .mg-content .detail-id, +:root[data-shell="outpost"] .mg-content .ref-type-asm, +:root[data-shell="outpost"] .mg-content .field-empty { color: #7a6b9a !important; } -:root[data-theme="fame-lavender"] .mg-content .ref-mgr-list-card, -:root[data-theme="fame-lavender"] .mg-content .ref-mgr-detail-card { - background: #ffffff !important; - border: 1px solid rgba(124, 58, 237, 0.16) !important; - box-shadow: 0 8px 24px rgba(124, 58, 237, 0.06) !important; +:root[data-shell="outpost"] .mg-content .ref-mgr-list-card, +:root[data-shell="outpost"] .mg-content .ref-mgr-detail-card { + background: #fffefd !important; + border: 1px solid rgba(40, 33, 58, 0.10) !important; + box-shadow: none !important; + border-radius: 8px !important; } -:root[data-theme="fame-lavender"] .mg-content .ref-mgr-header, -:root[data-theme="fame-lavender"] .mg-content .detail-header { +:root[data-shell="outpost"] .mg-content .ref-mgr-count, +:root[data-shell="outpost"] .mg-content .col-mono, +:root[data-shell="outpost"] .mg-content .col-type { + color: #756d85 !important; +} +:root[data-shell="outpost"] .mg-content .ref-chip { + background: #fffefd !important; + border-color: rgba(40, 33, 58, 0.12) !important; + color: #756d85 !important; +} +:root[data-shell="outpost"] .mg-content .ref-chip b { color: #28213a !important; } +:root[data-shell="outpost"] .mg-content .ref-chip.is-active { + color: #7c3aed !important; + border-color: rgba(124, 58, 237, 0.4) !important; + background: rgba(124, 58, 237, 0.08) !important; +} +:root[data-shell="outpost"] .mg-content .plugin-list-card { + background: #fffefd !important; + border: 1px solid rgba(40, 33, 58, 0.10) !important; + box-shadow: none !important; +} +:root[data-shell="outpost"] .mg-content .ref-mgr-header, +:root[data-shell="outpost"] .mg-content .detail-header { border-color: rgba(124, 58, 237, 0.18) !important; } -:root[data-theme="fame-lavender"] .mg-content .field-section { +:root[data-shell="outpost"] .mg-content .field-section { color: #303133; } -:root[data-theme="fame-lavender"] .mg-content .status-value { +:root[data-shell="outpost"] .mg-content .status-value { color: #303133 !important; } -/* el-tag:浅紫 */ -:root[data-theme="fame-lavender"] .mg-content .el-tag { +/* el-tag????*/ +:root[data-shell="outpost"] .mg-content .el-tag { background: #ede9fe !important; color: #5b21b6 !important; border-color: #ddd6fe !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tag--primary { +:root[data-shell="outpost"] .mg-content .el-tag--primary { background: #ede9fe !important; color: #5b21b6 !important; border-color: #c4b5fd !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tag--success { +:root[data-shell="outpost"] .mg-content .el-tag--success { background: #ecfdf5 !important; color: #047857 !important; border-color: #bbf7d0 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tag--warning { +:root[data-shell="outpost"] .mg-content .el-tag--warning { background: #fffbeb !important; color: #b45309 !important; border-color: #fde68a !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tag--danger { +:root[data-shell="outpost"] .mg-content .el-tag--danger { background: #fef2f2 !important; color: #b91c1c !important; border-color: #fecaca !important; } -:root[data-theme="fame-lavender"] .mg-content .el-tag--info { +:root[data-shell="outpost"] .mg-content .el-tag--info { background: #f3f4f6 !important; color: #4b5563 !important; border-color: #e5e7eb !important; } /* el-descriptions */ -:root[data-theme="fame-lavender"] .mg-content .el-descriptions { +:root[data-shell="outpost"] .mg-content .el-descriptions { --el-text-color-primary: #2d1b69; --el-text-color-regular: #606266; --el-border-color-lighter: #ebe5f4; --el-fill-color-blank: #fff; color: #303133 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-descriptions__label, -:root[data-theme="fame-lavender"] .mg-content .el-descriptions__cell.is-bordered-label { +:root[data-shell="outpost"] .mg-content .el-descriptions__label, +:root[data-shell="outpost"] .mg-content .el-descriptions__cell.is-bordered-label { background: #faf7fd !important; color: #606266 !important; border-color: #ebe5f4 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-descriptions__content, -:root[data-theme="fame-lavender"] .mg-content .el-descriptions__cell.is-bordered-content { +:root[data-shell="outpost"] .mg-content .el-descriptions__content, +:root[data-shell="outpost"] .mg-content .el-descriptions__cell.is-bordered-content { background: #fff !important; color: #303133 !important; border-color: #ebe5f4 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-descriptions__title { color: #2d1b69 !important; } +:root[data-shell="outpost"] .mg-content .el-descriptions__title { color: #2d1b69 !important; } -/* el-alert / collapse / radio / checkbox 浅化 */ -:root[data-theme="fame-lavender"] .mg-content .el-alert { +/* el-alert / collapse / radio / checkbox ?? */ +:root[data-shell="outpost"] .mg-content .el-alert { background: #faf7fd !important; border: 1px solid #ebe5f4 !important; color: #303133 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-alert__title, -:root[data-theme="fame-lavender"] .mg-content .el-alert__description { color: #303133 !important; } +:root[data-shell="outpost"] .mg-content .el-alert__title, +:root[data-shell="outpost"] .mg-content .el-alert__description { color: #303133 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-collapse { +:root[data-shell="outpost"] .mg-content .el-collapse { border-color: #ebe5f4 !important; --el-collapse-header-text-color: #2d1b69; --el-collapse-content-text-color: #303133; } -:root[data-theme="fame-lavender"] .mg-content .el-collapse-item__header, -:root[data-theme="fame-lavender"] .mg-content .el-collapse-item__wrap { +:root[data-shell="outpost"] .mg-content .el-collapse-item__header, +:root[data-shell="outpost"] .mg-content .el-collapse-item__wrap { background: #fff !important; border-color: #ebe5f4 !important; color: #303133 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-radio__label, -:root[data-theme="fame-lavender"] .mg-content .el-checkbox__label { color: #303133 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-radio__inner, -:root[data-theme="fame-lavender"] .mg-content .el-checkbox__inner { +:root[data-shell="outpost"] .mg-content .el-radio__label, +:root[data-shell="outpost"] .mg-content .el-checkbox__label { color: #303133 !important; } +:root[data-shell="outpost"] .mg-content .el-radio__inner, +:root[data-shell="outpost"] .mg-content .el-checkbox__inner { + background: #fff !important; + border-color: #7c3aed !important; + border-width: 1.5px !important; +} +/* ???????????? !important???????????????????????*/ +:root[data-shell="outpost"] .mg-content .el-checkbox__input.is-checked .el-checkbox__inner, +:root[data-shell="outpost"] .mg-content .el-checkbox__input.is-indeterminate .el-checkbox__inner { + background: #7c3aed !important; + border-color: #7c3aed !important; +} +:root[data-shell="outpost"] .mg-content .el-checkbox__input.is-checked .el-checkbox__inner::after { + border-color: #fff !important; +} +:root[data-shell="outpost"] .mg-content .el-checkbox__input.is-indeterminate .el-checkbox__inner::before { + background-color: #fff !important; +} +:root[data-shell="outpost"] .mg-content .el-radio__input.is-checked .el-radio__inner { + background: #7c3aed !important; + border-color: #7c3aed !important; +} +:root[data-shell="outpost"] .mg-content .el-radio__input.is-checked .el-radio__inner::after { background: #fff !important; - border-color: #c4b5fd !important; } -:root[data-theme="fame-lavender"] .mg-content .el-radio-button__inner { +:root[data-shell="outpost"] .mg-content .el-radio-button__inner { background: #fff !important; border-color: #ddd6fe !important; color: #5b21b6 !important; } -:root[data-theme="fame-lavender"] .mg-content .el-radio-button.is-active .el-radio-button__inner { +:root[data-shell="outpost"] .mg-content .el-radio-button.is-active .el-radio-button__inner { background: #7c3aed !important; border-color: #7c3aed !important; color: #fff !important; } -/* el-button--primary:紫色稳重 + 高对比白字(去霓虹动画) */ -:root[data-theme="fame-lavender"] .el-button--primary { +/* el-button--primary??????+ ???????????? */ +:root[data-shell="outpost"] .el-button--primary { background: #7c3aed !important; border: 1px solid #7c3aed !important; color: #ffffff !important; @@ -1449,8 +1798,8 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 0 1px 2px rgba(45, 27, 105, 0.08), 0 2px 6px rgba(124, 58, 237, 0.22) !important; } -:root[data-theme="fame-lavender"] .el-button--primary::after { display: none !important; } -:root[data-theme="fame-lavender"] .el-button--primary:hover { +:root[data-shell="outpost"] .el-button--primary::after { display: none !important; } +:root[data-shell="outpost"] .el-button--primary:hover { background: #6d28d9 !important; border-color: #6d28d9 !important; box-shadow: @@ -1458,20 +1807,20 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 0 4px 14px rgba(124, 58, 237, 0.32) !important; transform: translateY(-1px) !important; } -:root[data-theme="fame-lavender"] .el-button--primary:active { +:root[data-shell="outpost"] .el-button--primary:active { background: #5b21b6 !important; border-color: #5b21b6 !important; transform: translateY(0) !important; } -/* default 按钮:白底紫描边 + hover 浅紫底 */ -:root[data-theme="fame-lavender"] .mg-content .el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--text) { +/* default ???????? + hover ????*/ +:root[data-shell="outpost"] .mg-content .el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--text) { background: #ffffff !important; border-color: #ddd6fe !important; color: #5b21b6 !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--text):hover { +:root[data-shell="outpost"] .mg-content .el-button:not(.el-button--primary):not(.el-button--success):not(.el-button--warning):not(.el-button--danger):not(.el-button--text):hover { background: #f5f3ff !important; border-color: #a78bfa !important; color: #5b21b6 !important; @@ -1479,13 +1828,13 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } } /* el-progress */ -:root[data-theme="fame-lavender"] .mg-content .el-progress-bar__outer { background: #ede9fe !important; } -:root[data-theme="fame-lavender"] .mg-content .el-progress-bar__inner { +:root[data-shell="outpost"] .mg-content .el-progress-bar__outer { background: #ede9fe !important; } +:root[data-shell="outpost"] .mg-content .el-progress-bar__inner { background: linear-gradient(90deg, #7c3aed 0%, #a855f7 100%) !important; } -/* el-pagination 浅化 */ -:root[data-theme="fame-lavender"] .mg-content .el-pagination { +/* el-pagination ?? */ +:root[data-shell="outpost"] .mg-content .el-pagination { --el-pagination-bg-color: #fff; --el-pagination-button-bg-color: #faf7fd; --el-pagination-button-color: #2d1b69; @@ -1493,46 +1842,46 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } color: #606266 !important; } -/* el-dropdown 菜单 浅化(影响 header 用户菜单) */ -:root[data-theme="fame-lavender"] .el-dropdown-menu { +/* el-dropdown ?? ??????header ??????*/ +:root[data-shell="outpost"] .el-dropdown-menu { background: #fff !important; backdrop-filter: none !important; border: 1px solid #ebe5f4 !important; box-shadow: 0 6px 16px rgba(45, 27, 105, 0.10) !important; } -:root[data-theme="fame-lavender"] .el-dropdown-menu__item { +:root[data-shell="outpost"] .el-dropdown-menu__item { color: #303133 !important; } -:root[data-theme="fame-lavender"] .el-dropdown-menu__item:hover { +:root[data-shell="outpost"] .el-dropdown-menu__item:hover { background: #f5f3ff !important; color: #7c3aed !important; } /* el-switch */ -:root[data-theme="fame-lavender"] .mg-content .el-switch.is-checked .el-switch__core { +:root[data-shell="outpost"] .mg-content .el-switch.is-checked .el-switch__core { background: #7c3aed !important; border-color: #7c3aed !important; } -/* el-dialog 上的 h2 */ -:root[data-theme="fame-lavender"] .el-dialog h2 { color: #2d1b69 !important; } -:root[data-theme="fame-lavender"] .el-dialog { background: #fff !important; } -:root[data-theme="fame-lavender"] .el-dialog__title { color: #2d1b69 !important; } -:root[data-theme="fame-lavender"] .el-dialog__body { color: #303133 !important; } +/* el-dialog ?? h2 */ +:root[data-shell="outpost"] .el-dialog h2 { color: #2d1b69 !important; } +:root[data-shell="outpost"] .el-dialog { background: #fff !important; } +:root[data-shell="outpost"] .el-dialog__title { color: #2d1b69 !important; } +:root[data-shell="outpost"] .el-dialog__body { color: #303133 !important; } -/* 滚动条:紫色半透明(与 FAME 一致) */ -:root[data-theme="fame-lavender"] ::-webkit-scrollbar-thumb { +/* ??????????? FAME ??? */ +:root[data-shell="outpost"] ::-webkit-scrollbar-thumb { background: rgba(124, 58, 237, 0.25); } -:root[data-theme="fame-lavender"] ::-webkit-scrollbar-thumb:hover { +:root[data-shell="outpost"] ::-webkit-scrollbar-thumb:hover { background: rgba(124, 58, 237, 0.45); } -/* ════════════════════════════════════════════════════════════════════ - * 通用工具类(基于 design-system tokens · 见 design-system/MASTER.md) - * ════════════════════════════════════════════════════════════════════ */ +/* ???????????????????????????????????????????????????????????????????? + * ???????? design-system tokens ? ??design-system/MASTER.md?? + * ???????????????????????????????????????????????????????????????????? */ -/* 等宽数字:KPI 数值 / 坐标 / ID / 时间戳 */ +/* ?????KPI ???/ ?? / ID / ????*/ .mg-mono, .mg-num { font-family: var(--mg-font-mono); @@ -1540,7 +1889,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } letter-spacing: 0; } -/* 状态徽章(pill 样式,胶囊状) */ +/* ?????pill ????????*/ .mg-pill { display: inline-flex; align-items: center; @@ -1579,7 +1928,7 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } border-color: rgba(var(--mg-status-idle-rgb), 0.40); } -/* 实时脉动指示点: */ +/* ???????? */ .mg-live-dot { display: inline-block; width: 8px; @@ -1610,33 +1959,36 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 100% { transform: scale(1.8); opacity: 0; } } -/* 尊重用户的减少动画偏好 */ +/* ????????????*/ @media (prefers-reduced-motion: reduce) { .mg-live-dot::after { animation: none; } .bg-orb, .hero-orb, .hero-scene .agv-1, .hero-scene .agv-2, - .hero-scene .float-data { animation: none !important; } + .hero-scene .float-data, + .tag-dot, + .quick-item, + .quick-item.primary .quick-icon { animation: none !important; } } -/* ───────────────────────────────────────────────────────────────────────── - * fame-lavender 下 ReflectionManagerPanel 自定义按钮反差修复 +/* ????????????????????????????????????????????????????????????????????????? + * fame-lavender ??ReflectionManagerPanel ?????????? * - * 问题:组件 scoped css 把 .method-btn / .header-action-btn / .add-field-btn 写成 - * 「半透明紫底 + 白字」(rgba(170,110,250,0.30) 衬 #fff),本来是给星云紫深底主题设计的。 - * fame-lavender 切到白色卡片底后,半透明紫直接糊在白底 → 浅紫;白字糊在浅紫 - * → 对比度 ~1.5:1,"动作 / 方法" 区按钮文字几乎不可见(见会话 N+3 用户反馈截图)。 + * ??????scoped css ??.method-btn / .header-action-btn / .add-field-btn ?? + * ?????? + ????rgba(170,110,250,0.30) ??#fff)????????????????? + * fame-lavender ???????????????????????????????? + * ??????~1.5:1???? / ??" ?????????????? N+3 ????????? * - * 策略:与 fame-lavender 下 .el-button--primary 保持一致 —— - * 实色 #7c3aed 紫底 + #ffffff 白字 + 紫色双层阴影,对比度 ~6.9:1(WCAG AA 通过)。 - * 「+ 添加字段」(.add-field-btn) 偏次要等级,改为白底紫描边紫字(与 default 按钮一致)。 + * ???? fame-lavender ??.el-button--primary ???????? + * ?? #7c3aed ?? + #ffffff ?? + ?????????? ~6.9:1?WCAG AA ????? + * ?? ??????.add-field-btn) ??????????????????default ??????? * - * 特异性::root[data-theme] .mg-content .x = (0,3,0),加 !important 足以压过 Vue - * scoped css 的 .x[data-v-xxx] = (0,2,0)。 - * ───────────────────────────────────────────────────────────────────────── */ -:root[data-theme="fame-lavender"] .mg-content .method-btn, -:root[data-theme="fame-lavender"] .mg-content .header-action-btn { + * ????:root[data-theme] .mg-content .x = (0,3,0)?? !important ???? Vue + * scoped css ??.x[data-v-xxx] = (0,2,0)?? + * ????????????????????????????????????????????????????????????????????????? */ +:root[data-shell="outpost"] .mg-content .method-btn, +:root[data-shell="outpost"] .mg-content .header-action-btn { background: #7c3aed !important; border: 1px solid #7c3aed !important; color: #ffffff !important; @@ -1645,8 +1997,8 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 0 1px 2px rgba(45, 27, 105, 0.08), 0 2px 6px rgba(124, 58, 237, 0.22) !important; } -:root[data-theme="fame-lavender"] .mg-content .method-btn:hover:not(:disabled), -:root[data-theme="fame-lavender"] .mg-content .header-action-btn:hover { +:root[data-shell="outpost"] .mg-content .method-btn:hover:not(:disabled), +:root[data-shell="outpost"] .mg-content .header-action-btn:hover { background: #6d28d9 !important; border-color: #6d28d9 !important; color: #ffffff !important; @@ -1654,166 +2006,320 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } 0 2px 6px rgba(45, 27, 105, 0.12), 0 4px 14px rgba(124, 58, 237, 0.32) !important; } -:root[data-theme="fame-lavender"] .mg-content .method-btn:active:not(:disabled) { +:root[data-shell="outpost"] .mg-content .method-btn:active:not(:disabled) { background: #5b21b6 !important; border-color: #5b21b6 !important; } -:root[data-theme="fame-lavender"] .mg-content .method-btn:disabled { +:root[data-shell="outpost"] .mg-content .method-btn:disabled { background: #c4b5fd !important; border-color: #c4b5fd !important; color: #ffffff !important; box-shadow: none !important; opacity: 0.85 !important; } -:root[data-theme="fame-lavender"] .mg-content .method-btn .method-btn-icon, -:root[data-theme="fame-lavender"] .mg-content .method-btn .method-btn-spin { +:root[data-shell="outpost"] .mg-content .method-btn .method-btn-icon, +:root[data-shell="outpost"] .mg-content .method-btn .method-btn-spin { color: rgba(255, 255, 255, 0.92) !important; } -:root[data-theme="fame-lavender"] .mg-content .method-btn .method-btn-tag { +:root[data-shell="outpost"] .mg-content .method-btn .method-btn-tag { background: rgba(255, 255, 255, 0.24) !important; color: #ffffff !important; } -/* 「+ 添加字段」次级按钮:与 default 按钮同视觉等级(白底紫描边紫字),避免与 primary 等高。 */ -:root[data-theme="fame-lavender"] .mg-content .add-field-btn { +/* ?? ????????????default ???????????????????? primary ????*/ +:root[data-shell="outpost"] .mg-content .add-field-btn { background: #ffffff !important; border: 1px dashed #a78bfa !important; color: #5b21b6 !important; font-weight: 500 !important; } -:root[data-theme="fame-lavender"] .mg-content .add-field-btn:hover { +:root[data-shell="outpost"] .mg-content .add-field-btn:hover { background: #f5f3ff !important; border-color: #7c3aed !important; color: #5b21b6 !important; } +/* ????????????????????????? */ +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .prop-add-field-btn, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .add-field-btn { + background: transparent !important; + border: 1px dashed rgba(var(--mg-primary-rgb), 0.5) !important; + color: rgba(255, 255, 255, 0.88) !important; + font-weight: 600 !important; +} +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .prop-add-field-btn:hover, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .add-field-btn:hover { + background: rgba(var(--mg-primary-rgb), 0.18) !important; + border-color: var(--mg-primary) !important; + color: #fff !important; +} -/* ───────────────────────────────────────────────────────────────────────── - * fame-lavender × 地图编辑器深紫沉浸 UI(MapEditorView) - * 侧栏 / 属性面板 / 工具轨保持深紫底 + 浅字。全局浅紫规则会把 .mg-content 内 - * el-form 标签、el-tabs 等改成深紫字 (#4a3d6e),在深底上出现「深底深字」看不清。 - * ───────────────────────────────────────────────────────────────────────── */ -:root[data-theme="fame-lavender"] .mg-content .map-editor-page { +/* ????????????????????????????????????????????????????????????????????????? + * fame-lavender ? ??????????UI?MapEditorView?? + * ?? / ?????/ ???????? + ??????????? .mg-content ?? + * el-form ???el-tabs ?????? (#4a3d6e)?????????????????? + * ????????????????????????????????????????????????????????????????????????? */ +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page { --el-text-color-primary: rgba(232, 215, 245, 0.95); --el-text-color-regular: rgba(220, 200, 230, 0.88); --el-text-color-secondary: rgba(200, 180, 220, 0.72); color: rgba(232, 215, 245, 0.92); } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-form-item__label { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-form-item__label { color: rgba(220, 200, 230, 0.9) !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-tabs { --el-text-color-primary: rgba(232, 215, 245, 0.95) !important; --el-text-color-regular: rgba(220, 200, 240, 0.75) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__item, -:root[data-theme="fame-lavender"] .map-editor-page .el-tabs__item { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-tabs__item, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .map-editor-page .el-tabs__item { color: rgba(220, 200, 240, 0.72) !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__item:hover, -:root[data-theme="fame-lavender"] .map-editor-page .el-tabs__item:hover { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-tabs__item:hover, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .map-editor-page .el-tabs__item:hover { color: rgba(240, 225, 255, 0.95) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__item.is-active, -:root[data-theme="fame-lavender"] .map-editor-page .el-tabs__item.is-active { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-tabs__item.is-active, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .map-editor-page .el-tabs__item.is-active { color: #ffffff !important; font-weight: 600; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__nav-wrap::after { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-tabs__nav-wrap::after { background-color: rgba(255, 255, 255, 0.1) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__active-bar { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-tabs__active-bar { background: linear-gradient(90deg, rgba(170, 110, 250, 1) 0%, rgba(255, 110, 220, 1) 100%) !important; } -/* 图层 Tab:白底数据岛 + 深紫字(与属性面板输入框一致,避免白底叠浅字) */ -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table { - --el-table-bg-color: #ffffff; - --el-table-tr-bg-color: #ffffff; - --el-table-header-bg-color: #f5f3ff; - --el-table-text-color: #1a0f3d; - --el-table-header-text-color: #2d1b69; - --el-table-row-hover-bg-color: #ede9fe; - --el-table-border-color: #e0d4f5; - --el-fill-color-blank: #ffffff; - --el-fill-color-light: #faf7fd; - color: #1a0f3d !important; - background: #ffffff !important; - border-radius: 8px; +/* ?? Tab?????????????chrome ???????????*/ +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table.el-table { + --el-table-bg-color: rgba(0, 0, 0, 0.28); + --el-table-tr-bg-color: transparent; + --el-table-header-bg-color: rgba(var(--mg-primary-rgb), 0.16); + --el-table-text-color: var(--me-text); + --el-table-header-text-color: var(--me-text); + --el-table-row-hover-bg-color: rgba(var(--mg-primary-rgb), 0.14); + --el-table-border-color: var(--me-border); + --el-fill-color-blank: transparent; + --el-fill-color-light: rgba(255, 255, 255, 0.04); + color: var(--me-text) !important; + background: rgba(0, 0, 0, 0.22) !important; + border-radius: 10px; overflow: hidden; - box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.14); + box-shadow: inset 0 0 0 1px var(--me-border); } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table tr, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table .el-table__inner-wrapper { - background: #ffffff !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table.el-table, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table.el-table tr, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table .el-table__inner-wrapper { + background: transparent !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table th.el-table__cell { - background: #f5f3ff !important; - color: #2d1b69 !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table th.el-table__cell { + background: rgba(var(--mg-primary-rgb), 0.16) !important; + color: var(--me-text) !important; font-weight: 600 !important; - border-bottom-color: #e0d4f5 !important; + border-bottom-color: var(--me-border) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table td.el-table__cell { - background: #ffffff !important; - color: #1a0f3d !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table td.el-table__cell { + background: transparent !important; + color: var(--me-text) !important; font-weight: 500 !important; - border-bottom-color: #f0eaf6 !important; + border-bottom-color: rgba(255, 255, 255, 0.06) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table .cell { - color: #1a0f3d !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table .cell { + color: var(--me-text) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table--striped .el-table__body tr:nth-child(2n) td.el-table__cell { - background: #faf7fd !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table.el-table--striped .el-table__body tr:nth-child(2n) td.el-table__cell { + background: rgba(255, 255, 255, 0.03) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table tr:hover > td.el-table__cell { - background: #ede9fe !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .layer-table tr:hover > td.el-table__cell { + background: rgba(var(--mg-primary-rgb), 0.14) !important; } -/* 新建图层行:白底深字 + 占位符可读 */ -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input__inner { - color: #1a0f3d !important; +/* ?????????? */ +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .new-layer .el-input__inner { + color: var(--me-text) !important; font-weight: 500; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input__inner::placeholder { - color: #7a6b9a !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .new-layer .el-input__inner::placeholder { + color: var(--me-text-muted) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input-group__append .el-button { - background: #f5f3ff !important; - border-color: #ddd6fe !important; - color: #5b21b6 !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .new-layer .el-input-group__append .el-button { + background: rgba(var(--mg-primary-rgb), 0.18) !important; + border-color: rgba(var(--mg-primary-rgb), 0.35) !important; + color: #fff !important; font-weight: 600 !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input-group__append .el-button:hover { - background: #ede9fe !important; - border-color: #a78bfa !important; - color: #5b21b6 !important; +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .new-layer .el-input-group__append .el-button:hover { + background: rgba(var(--mg-primary-rgb), 0.28) !important; + border-color: var(--mg-primary) !important; + color: #fff !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-checkbox__label { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .el-checkbox__label { color: rgba(220, 200, 230, 0.88) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag-text { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .prop-card-flag, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .prop-card-flag-text { color: rgba(200, 220, 255, 0.88) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag.is-synced, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag.is-synced .prop-card-flag-text { +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .prop-card-flag.is-synced, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .prop-card-flag.is-synced .prop-card-flag-text { color: rgba(220, 255, 230, 0.98) !important; } -/* 属性面板内输入框仍用白底深字(与组件 scoped 设计一致),仅修正标签/Tab 等说明文字 */ -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-input__wrapper, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-select .el-input__wrapper { +/* ?????????????Outpost ?????????? chrome ??? */ +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-input__wrapper, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-textarea__inner, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select .el-input__wrapper, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select__wrapper, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-input-number .el-input__wrapper { + background: rgba(0, 0, 0, 0.32) !important; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12) !important; +} +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-input__wrapper:hover, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select__wrapper:hover, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select .el-input__wrapper:hover { + box-shadow: inset 0 0 0 1px rgba(var(--mg-primary-rgb), 0.5) !important; +} +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-input__wrapper.is-focus, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select__wrapper.is-focused, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select .el-input__wrapper.is-focus { + box-shadow: + inset 0 0 0 1px var(--mg-primary), + 0 0 0 3px rgba(var(--mg-primary-rgb), 0.18) !important; +} +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-input__inner, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-textarea__inner, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select__placeholder, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-select__selected-item { + color: var(--me-text) !important; + font-weight: 500; +} +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-input__inner::placeholder, +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-textarea__inner::placeholder { + color: var(--me-text-muted) !important; +} +:root[data-shell="outpost"]:not([data-theme="outpost-light"]) .mg-content .map-editor-page .edit-property-panel .el-form-item__label { + color: var(--me-text-muted) !important; +} + +/* ????????????????????????????????????????????????????????????????????????? + * Harbor ?? ? ????????? + ?????????? + * ????????????????????????????????????????????????????????????????????????? */ +:root[data-theme="outpost-light"] .mg-content .map-editor-page { + --el-text-color-primary: #0b1220; + --el-text-color-regular: #64748b; + --el-text-color-secondary: #94a3b8; + color: var(--me-text); +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-tabs__item { + color: var(--me-text-muted) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-tabs__item:hover { + color: var(--mg-primary) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-tabs__item.is-active { + color: var(--mg-primary) !important; + font-weight: 700; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-tabs__active-bar { + background: linear-gradient(90deg, #7c3aed 0%, #64748b 100%) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-tabs__nav-wrap::after { + background-color: var(--me-border) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-input__wrapper, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-select .el-input__wrapper, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-select__wrapper, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-input-number .el-input__wrapper { background: #ffffff !important; - box-shadow: 0 0 0 1px rgba(170, 110, 250, 0.28) inset !important; + box-shadow: inset 0 0 0 1px rgba(30, 58, 95, 0.14) !important; } -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-input__inner, -:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner { +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-input__wrapper:hover, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-select__wrapper:hover { + box-shadow: inset 0 0 0 1px rgba(30, 58, 95, 0.32) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-input__wrapper.is-focus, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-select__wrapper.is-focused { + box-shadow: + inset 0 0 0 1px #7c3aed, + 0 0 0 3px rgba(30, 58, 95, 0.12) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-input__inner, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-select__selected-item { + color: #0b1220 !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-input__inner::placeholder, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner::placeholder, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .el-select__placeholder { + color: #94a3b8 !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-add-field-btn { + background: #f5f3ff !important; + border: 1px dashed rgba(124, 58, 237, 0.42) !important; + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-add-field-btn:hover { + background: #ede9fe !important; + border-color: #7c3aed !important; + border-style: solid !important; + color: #5b21b6 !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .custom-field-item { + background: #f8fafc !important; + border: 1px solid #e5e7eb !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .custom-field-key { + color: rgba(45, 27, 105, 0.88) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .custom-field-input .el-input__wrapper { + background: #ffffff !important; + box-shadow: inset 0 0 0 1px rgba(124, 58, 237, 0.16) !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .custom-field-input .el-input__inner { color: #1a0f3d !important; + font-weight: 550 !important; + -webkit-text-fill-color: #1a0f3d !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn { + background: #7c3aed !important; + border: 1px solid #7c3aed !important; + color: #ffffff !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn .prop-action-icon, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn .prop-action-label { + color: #ffffff !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:nth-child(2n) { + background: #ffffff !important; + border-color: rgba(124, 58, 237, 0.4) !important; + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:nth-child(2n) .prop-action-icon, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:nth-child(2n) .prop-action-label { + color: #7c3aed !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:hover:not(:disabled) { + background: #8b5cf6 !important; + border-color: #8b5cf6 !important; + color: #ffffff !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:nth-child(2n):hover:not(:disabled) { + background: #f5f3ff !important; + border-color: #7c3aed !important; + color: #5b21b6 !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:nth-child(2n):hover:not(:disabled) .prop-action-icon, +:root[data-theme="outpost-light"] .mg-content .map-editor-page .edit-property-panel .prop-action-btn:nth-child(2n):hover:not(:disabled) .prop-action-label { + color: #5b21b6 !important; +} +:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-checkbox__label { + color: #64748b !important; } diff --git a/frontends/apps/simple-platform-vue/src/styles/themes.ts b/frontends/apps/simple-platform-vue/src/styles/themes.ts index cf5ebfc..3bb0b9d 100644 --- a/frontends/apps/simple-platform-vue/src/styles/themes.ts +++ b/frontends/apps/simple-platform-vue/src/styles/themes.ts @@ -1,10 +1,9 @@ /** * 主题色板预设 * - * 设计原则: - * - 每个主题提供一组 CSS 变量,运行时通过 document.documentElement.style.setProperty 注入 - * - 命名遵循 theme.css 中既有的 --mg-* 体系 - * - 工业紫色 (industrial-purple) 为默认主题:紫蓝渐变,深蓝底 + 天蓝高光 + * 主路径:Outpost 壳层三套精选(浅色 / 紫色 / 蓝色) + * 高级:旧深色霓虹主题(LEGACY_THEMES) + * 规范:docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md */ export interface ThemePreset { @@ -15,116 +14,229 @@ export interface ThemePreset { preview: string /** 强调高光,用于 UI 预览的次代表色 */ previewAccent: string + /** Outpost 浅色壳(纸白主区);legacy 深色为 false */ + outpostShell?: boolean vars: Record } -export const DEFAULT_THEME_ID = 'fame-lavender' +/** Outpost 浅色壳共用表面(纸白卡、深字、关 orb);各主题可覆盖 card/tint */ +const OUTPOST_SHELL_VARS: Record = { + '--mg-bg-card-rgb': '255, 254, 253', + '--mg-bg-card-hi-rgb': '251, 249, 253', + '--mg-bg-card-darker-rgb': '245, 240, 251', + '--mg-text-tint-rgb': '40, 33, 58', + '--mg-text-hi-rgb': '117, 109, 133', + '--mg-orb-a-opacity': '0', + '--mg-orb-b-opacity': '0', + '--mg-orb-c-opacity': '0', + '--mg-radius-sm': '10px', + '--mg-radius': '16px', + '--mg-radius-lg': '20px', + '--mg-radius-xl': '24px' +} -export const THEMES: ThemePreset[] = [ - { - id: 'fame-lavender', - name: '迷毂浅紫', - description: '参考 FAME 系统:深紫侧栏 + 浅紫白主区,柔和耐看,长时间使用不刺眼', +/** + * 地图编辑 chrome(顶栏/工具轨/属性栏/底栏)跟主题色相走。 + * 默认真色壳(跟侧栏);浅色 Harbor 等可显式覆盖 --me-* 为浅壳。 + * 画布始终保持深色。 + */ +function withMapEditorChrome(vars: Record): Record { + const primaryRgb = vars['--mg-primary-rgb'] ?? '30, 58, 95' + const chrome1 = vars['--mg-bg-aside-1'] ?? '#1e3a5f' + const chrome2 = vars['--mg-bg-aside-2'] ?? '#0b1220' + const chromeRgb = vars['--mg-bg-aside-rgb'] ?? primaryRgb + const lightChrome = vars['--me-chrome-mode'] === 'light' + return { + ...vars, + '--me-chrome-1': vars['--me-chrome-1'] ?? chrome1, + '--me-chrome-2': vars['--me-chrome-2'] ?? chrome2, + '--me-chrome-rgb': vars['--me-chrome-rgb'] ?? chromeRgb, + '--me-border': vars['--me-border'] ?? (lightChrome ? 'rgba(30, 58, 95, 0.12)' : 'rgba(255, 255, 255, 0.12)'), + '--me-text': vars['--me-text'] ?? (lightChrome ? 'rgba(11, 18, 32, 0.92)' : 'rgba(245, 242, 255, 0.94)'), + '--me-text-muted': vars['--me-text-muted'] ?? (lightChrome ? 'rgba(11, 18, 32, 0.55)' : 'rgba(210, 200, 235, 0.68)'), + '--me-surface': vars['--me-surface'] ?? (lightChrome ? 'rgba(30, 58, 95, 0.04)' : 'rgba(255, 255, 255, 0.05)'), + '--me-hover': vars['--me-hover'] ?? (lightChrome ? `rgba(${primaryRgb}, 0.08)` : `rgba(${primaryRgb}, 0.28)`), + '--me-active-glow': vars['--me-active-glow'] ?? `rgba(${primaryRgb}, 0.28)`, + '--me-card-bg': vars['--me-card-bg'] ?? (lightChrome ? 'rgba(255, 255, 255, 0.96)' : 'rgba(0, 0, 0, 0.22)'), + '--me-card-border': vars['--me-card-border'] ?? (lightChrome ? '#e2e6ec' : 'rgba(255, 255, 255, 0.08)'), + '--me-inset': vars['--me-inset'] ?? (lightChrome ? '#f4f6f8' : 'rgba(0, 0, 0, 0.28)') + } +} + +function outpostPreset( + partial: Omit & { vars: Record } +): ThemePreset { + return { + ...partial, + outpostShell: true, + vars: withMapEditorChrome({ ...OUTPOST_SHELL_VARS, ...partial.vars }) + } +} + +/** 默认:Outpost 浅色(对齐 fairylandtech.amerc.ai) */ +export const DEFAULT_THEME_ID = 'outpost-light' + +/** 主路径精选三色(顶栏 ThemeSwitcher) */ +export const PRIMARY_THEMES: ThemePreset[] = [ + outpostPreset({ + id: 'outpost-light', + name: '浅色', + description: '纯白壳 + 紫色品牌点缀;地图壳白,画布深色', + preview: '#ffffff', + previewAccent: '#7c3aed', + vars: { + '--mg-primary': '#7c3aed', + '--mg-primary-rgb': '124, 58, 237', + '--mg-primary-hover': '#8b5cf6', + '--mg-primary-hover-rgb': '139, 92, 246', + '--mg-primary-active': '#5b21b6', + '--mg-accent': '#a855f7', + '--mg-accent-rgb': '168, 85, 247', + '--mg-primary-light-3': '#a78bfa', + '--mg-primary-light-5': '#c4b5fd', + '--mg-primary-light-7': '#ddd6fe', + '--mg-primary-light-8': '#ede9fe', + '--mg-primary-light-9': '#f5f3ff', + '--mg-primary-dark-2': '#6d28d9', + '--mg-bg-app-1': '#ffffff', + '--mg-bg-app-2': '#ffffff', + '--mg-bg-app-3': '#ffffff', + '--mg-bg-app-deep-rgb': '255, 255, 255', + '--mg-bg-aside-1': '#ffffff', + '--mg-bg-aside-2': '#fafafa', + '--mg-bg-aside-rgb': '255, 255, 255', + '--mg-shadow-rgb': '76, 29, 149', + '--mg-bg-card-rgb': '255, 255, 255', + '--mg-bg-card-hi-rgb': '255, 255, 255', + '--mg-bg-card-darker-rgb': '248, 250, 252', + '--mg-text-tint-rgb': '45, 27, 105', + '--mg-text-hi-rgb': '109, 40, 217', + /* 地图编辑:纯白壳 */ + '--me-chrome-mode': 'light', + '--me-chrome-1': '#ffffff', + '--me-chrome-2': '#ffffff', + '--me-chrome-rgb': '255, 255, 255', + '--me-border': 'rgba(124, 58, 237, 0.12)', + '--me-text': 'rgba(45, 27, 105, 0.92)', + '--me-text-muted': 'rgba(76, 29, 149, 0.55)', + '--me-surface': 'rgba(124, 58, 237, 0.04)', + '--me-hover': 'rgba(124, 58, 237, 0.08)', + '--me-active-glow': 'rgba(124, 58, 237, 0.18)', + '--me-card-bg': '#ffffff', + '--me-card-border': '#e5e7eb', + '--me-inset': '#f8fafc' + } + }), + outpostPreset({ + id: 'outpost-purple', + name: '紫色', + description: '同壳层、更饱和紫品牌;侧栏深蓝紫', preview: '#7c3aed', previewAccent: '#a855f7', vars: { - // 主色保留紫色(与 FAME 一致),但 hover/accent 使用更明亮的 violet/purple - '--mg-primary': '#7c3aed', - '--mg-primary-rgb': '124, 58, 237', - '--mg-primary-hover': '#8b5cf6', + '--mg-primary': '#7c3aed', + '--mg-primary-rgb': '124, 58, 237', + '--mg-primary-hover': '#8b5cf6', '--mg-primary-hover-rgb': '139, 92, 246', - '--mg-primary-active': '#5b21b6', - '--mg-accent': '#a855f7', - '--mg-accent-rgb': '168, 85, 247', - - '--mg-primary-light-3': '#a78bfa', - '--mg-primary-light-5': '#c4b5fd', - '--mg-primary-light-7': '#ddd6fe', - '--mg-primary-light-8': '#ede9fe', - '--mg-primary-light-9': '#f5f3ff', - '--mg-primary-dark-2': '#6d28d9', - - // 主区背景:浅薰衣草灰(v3:从近白下沉到 ~92% 明度,让白卡片靠阴影清晰浮起) - '--mg-bg-app-1': '#edeaf5', - '--mg-bg-app-2': '#e7e4f1', - '--mg-bg-app-3': '#f1eef8', - '--mg-bg-app-deep-rgb': '231, 228, 241', - - // 侧栏:FAME 深紫渐变 #2d1b69 -> #1a1040 - '--mg-bg-aside-1': '#2d1b69', - '--mg-bg-aside-2': '#1a1040', - '--mg-bg-aside-rgb': '45, 27, 105', - - // 卡片:白底(在 fame-lavender 专属规则中走纯 #fff) - '--mg-bg-card-rgb': '255, 255, 255', - '--mg-bg-card-hi-rgb': '249, 246, 255', - '--mg-bg-card-darker-rgb':'245, 240, 251', - '--mg-shadow-rgb': '124, 58, 237', - - // 文字:深紫标题 #2d1b69, 中灰正文 #606266 - '--mg-text-tint-rgb': '45, 27, 105', - '--mg-text-hi-rgb': '96, 98, 102', - - // 关闭装饰性 orb - '--mg-orb-a-opacity': '0', - '--mg-orb-b-opacity': '0', - '--mg-orb-c-opacity': '0' + '--mg-primary-active': '#5b21b6', + '--mg-accent': '#a855f7', + '--mg-accent-rgb': '168, 85, 247', + '--mg-primary-light-3': '#a78bfa', + '--mg-primary-light-5': '#c4b5fd', + '--mg-primary-light-7': '#ddd6fe', + '--mg-primary-light-8': '#ede9fe', + '--mg-primary-light-9': '#f5f3ff', + '--mg-primary-dark-2': '#6d28d9', + '--mg-bg-app-1': '#f3eefc', + '--mg-bg-app-2': '#edeaf5', + '--mg-bg-app-3': '#f8f5ff', + '--mg-bg-app-deep-rgb': '243, 238, 252', + '--mg-bg-aside-1': '#2d1b69', + '--mg-bg-aside-2': '#1a1040', + '--mg-bg-aside-rgb': '45, 27, 105', + '--mg-shadow-rgb': '124, 58, 237' } - }, + }), + outpostPreset({ + id: 'outpost-blue', + name: '蓝色', + description: '同壳层、蓝科技感;侧栏深蓝', + preview: '#3c78d2', + previewAccent: '#6ea8f0', + vars: { + '--mg-primary': '#3c78d2', + '--mg-primary-rgb': '60, 120, 210', + '--mg-primary-hover': '#4d8ae0', + '--mg-primary-hover-rgb': '77, 138, 224', + '--mg-primary-active': '#2a5fad', + '--mg-accent': '#6ea8f0', + '--mg-accent-rgb': '110, 168, 240', + '--mg-primary-light-3': '#6ea8f0', + '--mg-primary-light-5': '#9bc4f5', + '--mg-primary-light-7': '#c5ddf9', + '--mg-primary-light-8': '#e3effc', + '--mg-primary-light-9': '#f3f7fc', + '--mg-primary-dark-2': '#2a5fad', + '--mg-bg-app-1': '#f3f6fb', + '--mg-bg-app-2': '#eef2f8', + '--mg-bg-app-3': '#f8fafc', + '--mg-bg-app-deep-rgb': '243, 246, 251', + '--mg-bg-aside-1': '#0f2744', + '--mg-bg-aside-2': '#0a1a30', + '--mg-bg-aside-rgb': '15, 39, 68', + '--mg-shadow-rgb': '30, 58, 95' + } + }) +] + +/** 高级兼容:旧深色霓虹主题(用户菜单「兼容主题」) */ +export const LEGACY_THEMES: ThemePreset[] = [ { id: 'industrial-purple', name: '星云紫', - description: '科幻紫主色,辅以蓝紫渐变,磨砂玻璃 · 霓虹辉光', + description: '科幻紫主色 · 磨砂玻璃 · 霓虹辉光(兼容)', preview: '#7c3aed', previewAccent: '#c4b5fd', + outpostShell: false, vars: { - // ── 主色:明亮 violet(紫),主导整个画面 ── - '--mg-primary': '#7c3aed', - '--mg-primary-rgb': '124, 58, 237', - // hover/辉光:偏蓝紫 indigo,带来氛围流动感 - '--mg-primary-hover': '#8b5cf6', + '--mg-primary': '#7c3aed', + '--mg-primary-rgb': '124, 58, 237', + '--mg-primary-hover': '#8b5cf6', '--mg-primary-hover-rgb': '139, 92, 246', - '--mg-primary-active': '#5b21b6', - // accent:浅紫高光,边缘霓虹与点缀 - '--mg-accent': '#c4b5fd', - '--mg-accent-rgb': '196, 181, 253', - + '--mg-primary-active': '#5b21b6', + '--mg-accent': '#c4b5fd', + '--mg-accent-rgb': '196, 181, 253', '--mg-primary-light-3': '#9d6cf2', '--mg-primary-light-5': '#b594f7', '--mg-primary-light-7': '#6f4eab', '--mg-primary-light-8': '#4c357a', '--mg-primary-light-9': '#2d1f55', - '--mg-primary-dark-2': '#4c1d95', - - // ── 背景:深紫黑底,带 indigo 余晖 ── - '--mg-bg-app-1': '#0e0a24', - '--mg-bg-app-2': '#1f1147', - '--mg-bg-app-3': '#06031a', + '--mg-primary-dark-2': '#4c1d95', + '--mg-bg-app-1': '#0e0a24', + '--mg-bg-app-2': '#1f1147', + '--mg-bg-app-3': '#06031a', '--mg-bg-app-deep-rgb': '6, 3, 26', - - '--mg-bg-aside-1': '#0c0820', - '--mg-bg-aside-2': '#03020e', - '--mg-bg-aside-rgb': '10, 6, 30', - - '--mg-bg-card-rgb': '38, 24, 78', - '--mg-bg-card-hi-rgb': '58, 38, 110', + '--mg-bg-aside-1': '#0c0820', + '--mg-bg-aside-2': '#03020e', + '--mg-bg-aside-rgb': '10, 6, 30', + '--mg-bg-card-rgb': '38, 24, 78', + '--mg-bg-card-hi-rgb': '58, 38, 110', '--mg-bg-card-darker-rgb': '20, 12, 48', - '--mg-shadow-rgb': '8, 2, 24', - - '--mg-text-tint-rgb': '242, 232, 255', - '--mg-text-hi-rgb': '220, 200, 252', - - '--mg-orb-a-opacity': '0.55', - '--mg-orb-b-opacity': '0.50', - '--mg-orb-c-opacity': '0.40' + '--mg-shadow-rgb': '8, 2, 24', + '--mg-text-tint-rgb': '242, 232, 255', + '--mg-text-hi-rgb': '220, 200, 252', + '--mg-orb-a-opacity': '0.55', + '--mg-orb-b-opacity': '0.50', + '--mg-orb-c-opacity': '0.40' } }, { id: 'deep-azure', name: '深邃蓝', - description: '科技感深蓝,沉静稳重', + description: '科技感深蓝,沉静稳重(兼容)', preview: '#1565c0', previewAccent: '#82b1ff', + outpostShell: false, vars: { '--mg-primary': '#1565c0', '--mg-primary-rgb': '21, 101, 192', @@ -133,28 +245,23 @@ export const THEMES: ThemePreset[] = [ '--mg-primary-active': '#0d47a1', '--mg-accent': '#82b1ff', '--mg-accent-rgb': '130, 177, 255', - '--mg-primary-light-3': '#5a8fce', '--mg-primary-light-5': '#83aedb', '--mg-primary-light-7': '#adcde7', '--mg-primary-light-8': '#c4dbed', '--mg-primary-light-9': '#e3eef8', '--mg-primary-dark-2': '#0d47a1', - '--mg-bg-app-1': '#051a3a', '--mg-bg-app-2': '#0a2f6b', '--mg-bg-app-3': '#020a1f', '--mg-bg-app-deep-rgb': '2, 8, 26', - '--mg-bg-aside-1': '#06122a', '--mg-bg-aside-2': '#02060f', '--mg-bg-aside-rgb': '8, 18, 42', - '--mg-bg-card-rgb': '12, 36, 80', '--mg-bg-card-hi-rgb': '18, 50, 110', '--mg-bg-card-darker-rgb': '6, 18, 44', '--mg-shadow-rgb': '2, 4, 14', - '--mg-text-tint-rgb': '218, 234, 252', '--mg-text-hi-rgb': '170, 200, 240' } @@ -162,9 +269,10 @@ export const THEMES: ThemePreset[] = [ { id: 'emerald-forge', name: '翡翠绿', - description: '机械翡翠绿,沉稳活力', + description: '机械翡翠绿(兼容)', preview: '#00796b', previewAccent: '#80cbc4', + outpostShell: false, vars: { '--mg-primary': '#00796b', '--mg-primary-rgb': '0, 121, 107', @@ -173,28 +281,23 @@ export const THEMES: ThemePreset[] = [ '--mg-primary-active': '#004d40', '--mg-accent': '#80cbc4', '--mg-accent-rgb': '128, 203, 196', - '--mg-primary-light-3': '#4ca094', '--mg-primary-light-5': '#79b9b0', '--mg-primary-light-7': '#a6d2cc', '--mg-primary-light-8': '#bfdfdb', '--mg-primary-light-9': '#e1f0ee', '--mg-primary-dark-2': '#004d40', - '--mg-bg-app-1': '#06241f', '--mg-bg-app-2': '#0a4538', '--mg-bg-app-3': '#020f0c', '--mg-bg-app-deep-rgb': '2, 12, 10', - '--mg-bg-aside-1': '#061a16', '--mg-bg-aside-2': '#020a08', '--mg-bg-aside-rgb': '8, 26, 22', - '--mg-bg-card-rgb': '10, 50, 42', '--mg-bg-card-hi-rgb': '14, 66, 56', '--mg-bg-card-darker-rgb': '6, 28, 24', '--mg-shadow-rgb': '2, 10, 8', - '--mg-text-tint-rgb': '220, 244, 240', '--mg-text-hi-rgb': '170, 220, 210' } @@ -202,9 +305,10 @@ export const THEMES: ThemePreset[] = [ { id: 'crimson-iron', name: '熔铁赤', - description: '熔铁炉火般的暗红工业风', + description: '暗红工业风(兼容)', preview: '#b71c1c', previewAccent: '#ff8a80', + outpostShell: false, vars: { '--mg-primary': '#b71c1c', '--mg-primary-rgb': '183, 28, 28', @@ -213,28 +317,23 @@ export const THEMES: ThemePreset[] = [ '--mg-primary-active': '#8a0c0c', '--mg-accent': '#ff8a80', '--mg-accent-rgb': '255, 138, 128', - '--mg-primary-light-3': '#c95a5a', '--mg-primary-light-5': '#d68585', '--mg-primary-light-7': '#e4afaf', '--mg-primary-light-8': '#edc8c8', '--mg-primary-light-9': '#f7e6e6', '--mg-primary-dark-2': '#8a0c0c', - '--mg-bg-app-1': '#2a0606', '--mg-bg-app-2': '#4a0c0c', '--mg-bg-app-3': '#150303', '--mg-bg-app-deep-rgb': '20, 4, 4', - '--mg-bg-aside-1': '#200505', '--mg-bg-aside-2': '#0c0202', '--mg-bg-aside-rgb': '36, 6, 6', - '--mg-bg-card-rgb': '60, 10, 10', '--mg-bg-card-hi-rgb': '80, 14, 14', '--mg-bg-card-darker-rgb': '34, 5, 5', '--mg-shadow-rgb': '10, 1, 1', - '--mg-text-tint-rgb': '252, 220, 220', '--mg-text-hi-rgb': '240, 175, 175' } @@ -242,9 +341,10 @@ export const THEMES: ThemePreset[] = [ { id: 'graphite-steel', name: '石墨钢', - description: '中性冷灰,纯粹工业', + description: '中性冷灰(兼容)', preview: '#455a64', previewAccent: '#b0bec5', + outpostShell: false, vars: { '--mg-primary': '#455a64', '--mg-primary-rgb': '69, 90, 100', @@ -253,28 +353,23 @@ export const THEMES: ThemePreset[] = [ '--mg-primary-active': '#263238', '--mg-accent': '#b0bec5', '--mg-accent-rgb': '176, 190, 197', - '--mg-primary-light-3': '#7a8a92', '--mg-primary-light-5': '#9aa6ac', '--mg-primary-light-7': '#bac2c7', '--mg-primary-light-8': '#ccd2d6', '--mg-primary-light-9': '#e8ebed', '--mg-primary-dark-2': '#263238', - '--mg-bg-app-1': '#161e24', '--mg-bg-app-2': '#243038', '--mg-bg-app-3': '#0a0e12', '--mg-bg-app-deep-rgb': '10, 14, 18', - '--mg-bg-aside-1': '#11181d', '--mg-bg-aside-2': '#060a0d', '--mg-bg-aside-rgb': '18, 26, 32', - '--mg-bg-card-rgb': '30, 42, 50', '--mg-bg-card-hi-rgb': '42, 56, 66', '--mg-bg-card-darker-rgb': '16, 22, 28', '--mg-shadow-rgb': '4, 6, 10', - '--mg-text-tint-rgb': '226, 232, 238', '--mg-text-hi-rgb': '186, 198, 210' } @@ -282,9 +377,10 @@ export const THEMES: ThemePreset[] = [ { id: 'amber-forge', name: '琥珀金', - description: '高对比的橙金调,警示与活力并存', + description: '橙金调(兼容)', preview: '#e65100', previewAccent: '#ffb74d', + outpostShell: false, vars: { '--mg-primary': '#e65100', '--mg-primary-rgb': '230, 81, 0', @@ -293,44 +389,69 @@ export const THEMES: ThemePreset[] = [ '--mg-primary-active': '#bf360c', '--mg-accent': '#ffb74d', '--mg-accent-rgb': '255, 183, 77', - '--mg-primary-light-3': '#ec8344', '--mg-primary-light-5': '#f2a474', '--mg-primary-light-7': '#f7c4a3', '--mg-primary-light-8': '#fad6bc', '--mg-primary-light-9': '#fdeadd', '--mg-primary-dark-2': '#bf360c', - '--mg-bg-app-1': '#2a1404', '--mg-bg-app-2': '#4a2a0c', '--mg-bg-app-3': '#150a02', '--mg-bg-app-deep-rgb': '20, 10, 4', - '--mg-bg-aside-1': '#200f04', '--mg-bg-aside-2': '#0c0602', '--mg-bg-aside-rgb': '34, 16, 6', - '--mg-bg-card-rgb': '62, 32, 8', '--mg-bg-card-hi-rgb': '82, 44, 12', '--mg-bg-card-darker-rgb': '34, 16, 4', '--mg-shadow-rgb': '10, 4, 1', - '--mg-text-tint-rgb': '252, 232, 210', '--mg-text-hi-rgb': '240, 196, 140' } } ] -export function findTheme(id: string | undefined | null): ThemePreset { - if (!id) return THEMES[0] - return THEMES.find((t) => t.id === id) ?? THEMES[0] +/** 全量(查找用):精选 + 兼容 */ +export const THEMES: ThemePreset[] = [...PRIMARY_THEMES, ...LEGACY_THEMES] + +/** 旧 id → 新精选 id */ +const THEME_ALIASES: Record = { + 'fame-lavender': 'outpost-purple', + 'outpost-fairyland': 'outpost-light' } -/** 把主题变量写入 :root;多次调用幂等,会覆盖同名属性 */ +export function resolveThemeId(id: string | undefined | null): string { + if (!id) return DEFAULT_THEME_ID + const aliased = THEME_ALIASES[id] ?? id + if (THEMES.some((t) => t.id === aliased)) return aliased + return DEFAULT_THEME_ID +} + +export function findTheme(id: string | undefined | null): ThemePreset { + const resolved = resolveThemeId(id) + return THEMES.find((t) => t.id === resolved) ?? PRIMARY_THEMES[0] +} + +export function isLegacyThemeId(id: string | undefined | null): boolean { + if (!id) return false + const resolved = THEME_ALIASES[id] ?? id + return LEGACY_THEMES.some((t) => t.id === resolved) +} + +/** 把主题变量写入 :root;Outpost 壳写 data-shell="outpost" 供浅色 CSS 命中 */ export function applyThemeVars(preset: ThemePreset): void { const root = document.documentElement - for (const [key, value] of Object.entries(preset.vars)) { + // preset.vars 在 outpostPreset 里已跑过 withMapEditorChrome;再跑一次以补全缺省 --me-* + const vars = withMapEditorChrome({ ...preset.vars }) + for (const [key, value] of Object.entries(vars)) { + if (key === '--me-chrome-mode') continue root.style.setProperty(key, value) } root.setAttribute('data-theme', preset.id) + if (preset.outpostShell) { + root.setAttribute('data-shell', 'outpost') + } else { + root.removeAttribute('data-shell') + } } diff --git a/frontends/apps/simple-platform-vue/src/views/LoginView.vue b/frontends/apps/simple-platform-vue/src/views/LoginView.vue index f0fb998..1d7be0f 100644 --- a/frontends/apps/simple-platform-vue/src/views/LoginView.vue +++ b/frontends/apps/simple-platform-vue/src/views/LoginView.vue @@ -4,17 +4,10 @@
-
-