diff --git a/.gitignore b/.gitignore index e46bc56..d021d26 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/ @@ -34,3 +36,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/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/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/MiGu.Server/Controllers/OpsController.cs b/MiGu.Server/Controllers/OpsController.cs index 6d11299..8f2b2ed 100644 --- a/MiGu.Server/Controllers/OpsController.cs +++ b/MiGu.Server/Controllers/OpsController.cs @@ -133,6 +133,10 @@ public class OpsController : ControllerBase // 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。 if (!string.IsNullOrEmpty(_internalToken.Token)) msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token); + // 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。 + msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1"); + if (!string.IsNullOrWhiteSpace(user)) + msg.Headers.TryAddWithoutValidation("X-Platform-User", user); using var resp = await client.SendAsync(msg); var body = await resp.Content.ReadAsStringAsync(); var success = resp.IsSuccessStatusCode && ParseSuccess(body); 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/Controllers/SimpleFieldController.cs b/MiGu.Server/Controllers/SimpleFieldController.cs index 9f08616..8159ed2 100644 --- a/MiGu.Server/Controllers/SimpleFieldController.cs +++ b/MiGu.Server/Controllers/SimpleFieldController.cs @@ -36,22 +36,24 @@ public sealed class SimpleFieldController : ControllerBase /// 新增单条字段;同车型 + 字段类型下 key 不可重复 /// [HttpPost] + [Authorize(Policy = "PlatformScope")] public Task Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req); /// /// 按 id 更新单条字段 /// [HttpPut("{id:guid}")] + [Authorize(Policy = "PlatformScope")] public Task Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id }); /// /// 按 id 删除单条字段 /// [HttpDelete("{id:guid}")] + [Authorize(Policy = "PlatformScope")] public async Task Delete(Guid id) { await _service.DeleteAsync(id); - return NoContent(); } @@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase /// 返回实际写入条数 { count }。 /// [HttpPost("batch")] + [Authorize(Policy = "PlatformScope")] public async Task SaveBatch([FromBody] SimpleFieldBatchRequest req) { var count = await _service.SaveBatchAsync(req); - return Ok(new { count }); } } 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/Launcher/SimpleLiteLauncher.cs b/MiGu.Server/Launcher/SimpleLiteLauncher.cs index d9055c5..f8bac00 100644 --- a/MiGu.Server/Launcher/SimpleLiteLauncher.cs +++ b/MiGu.Server/Launcher/SimpleLiteLauncher.cs @@ -349,11 +349,10 @@ public sealed class SimpleLiteLauncher : IDisposable /// 写 active-scenes.json 的结果(供向导保存接口回显)。 public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error); - /// - /// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。 - /// - public LaunchResult RestartForUpdate(string launchMode = "webonly") + /// 终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。 + public int StopAll() { + var killed = 0; foreach (var proc in Process.GetProcessesByName("SimpleLite")) { try @@ -361,12 +360,13 @@ public sealed class SimpleLiteLauncher : IDisposable if (!proc.HasExited) { proc.Kill(entireProcessTree: true); - _log.LogInformation("[SimpleLite] restart-for-update: killed pid={Pid}", proc.Id); + killed++; + _log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id); } } catch (Exception ex) { - _log.LogWarning("[SimpleLite] restart-for-update: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message); + _log.LogWarning("[SimpleLite] stop: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message); } finally { @@ -374,7 +374,7 @@ public sealed class SimpleLiteLauncher : IDisposable } } - Thread.Sleep(1500); + if (killed > 0) Thread.Sleep(1500); lock (_sync) { @@ -382,6 +382,23 @@ public sealed class SimpleLiteLauncher : IDisposable _lastLaunchMode = null; } + return killed; + } + + /// 关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。 + public LaunchResult Restart(string launchMode = "webonly") + { + StopAll(); + return MaybeStart(launchMode); + } + + /// + /// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。 + /// + public LaunchResult RestartForUpdate(string launchMode = "webonly") + { + StopAll(); + var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log); var result = MaybeStart(launchMode); if (!synced && result.Warning == null) 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/Program.cs b/MiGu.Server/Program.cs index f7b821c..0cbbeb1 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/Wms/WmsService.cs b/MiGu.Server/Wms/WmsService.cs index 72f1fdf..e39324f 100644 --- a/MiGu.Server/Wms/WmsService.cs +++ b/MiGu.Server/Wms/WmsService.cs @@ -868,6 +868,20 @@ public sealed class WmsService entity.GetType().Name, entity.Id, version); } + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + for (Exception? e = ex; e != null; e = e.InnerException) + { + var msg = e.Message; + if (msg.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase) + || msg.Contains("unique index", StringComparison.OrdinalIgnoreCase) + || msg.Contains("duplicate key", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + private static void EnsureUnlocked(EntityBase entity) { if (entity.IsLock) diff --git a/MiGu.Server/Wms/WmsTransportPlanner.cs b/MiGu.Server/Wms/WmsTransportPlanner.cs index f5feab3..f978bab 100644 --- a/MiGu.Server/Wms/WmsTransportPlanner.cs +++ b/MiGu.Server/Wms/WmsTransportPlanner.cs @@ -70,8 +70,11 @@ public sealed class WmsTransportPlanner .Where(x => x.LocationType == ContainerLocationTypes.Storage).ToListAsync(); var materials = await _db.Materials.AsNoTracking().Where(x => x.Enabled).ToListAsync(); var containerMaterials = await _db.ContainerMaterials.AsNoTracking().ToListAsync(); + var now = DateTimeOffset.UtcNow; var activeReservations = await _db.WmsTransportReservations.AsNoTracking() - .Where(x => x.Status == WmsReservationStatuses.Active).ToListAsync(); + .Where(x => x.Status == WmsReservationStatuses.Active + && (x.ExpiresAt == null || x.ExpiresAt > now)) + .ToListAsync(); var activeTasks = await _db.WmsTransportTasks.AsNoTracking() .Where(x => WmsTransportTaskStatuses.Active.Contains(x.Status)).ToListAsync(); diff --git a/MiGu.Server/Wms/WmsTransportTaskService.cs b/MiGu.Server/Wms/WmsTransportTaskService.cs index 9b65380..fb842f5 100644 --- a/MiGu.Server/Wms/WmsTransportTaskService.cs +++ b/MiGu.Server/Wms/WmsTransportTaskService.cs @@ -166,10 +166,27 @@ public sealed class WmsTransportTaskService public async Task DispatchAsync(Guid taskId, string actor) { - var task = await FindTaskAsync(taskId); - if (task.Status != WmsTransportTaskStatuses.Reserved) - throw new InvalidOperationException("只有 Reserved 状态的任务可以下发"); + // 原子抢占:仅一条 Reserved 且未在下发中的任务能进入 Dispatching,避免双发 + var now = DateTimeOffset.UtcNow; + var claimed = await _db.WmsTransportTasks + .Where(x => x.Id == taskId + && x.Status == WmsTransportTaskStatuses.Reserved + && x.DispatchStatus != "Dispatching" + && x.DispatchStatus != "Dispatched") + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.DispatchStatus, "Dispatching") + .SetProperty(x => x.UpdatedAt, now) + .SetProperty(x => x.UpdatedBy, actor)); + if (claimed == 0) + { + var existing = await FindTaskAsync(taskId); + if (existing.Status != WmsTransportTaskStatuses.Reserved) + throw new InvalidOperationException("只有 Reserved 状态的任务可以下发"); + throw new InvalidOperationException("任务正在下发中,请勿重复操作"); + } + + var task = await FindTaskAsync(taskId); var source = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.SourceStorageId); var target = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.TargetStorageId); var container = await _db.Containers.AsNoTracking().FirstAsync(x => x.Id == task.ContainerId); @@ -195,14 +212,36 @@ public sealed class WmsTransportTaskService return task; } + private async Task ExpireStaleReservationsAsync() + { + var now = DateTimeOffset.UtcNow; + var rows = await _db.WmsTransportReservations + .Where(x => x.Status == WmsReservationStatuses.Active + && x.ExpiresAt != null + && x.ExpiresAt < now) + .ToListAsync(); + if (rows.Count == 0) return; + + foreach (var row in rows) + row.Status = WmsReservationStatuses.Expired; + await _db.SaveChangesAsync(); + } + private async Task EnsureNoActiveReservationAsync(Guid containerId, Guid targetStorageId) { + await ExpireStaleReservationsAsync(); + + var now = DateTimeOffset.UtcNow; if (await _db.WmsTransportReservations.AnyAsync(x => - x.Status == WmsReservationStatuses.Active && x.ContainerId == containerId)) + x.Status == WmsReservationStatuses.Active + && (x.ExpiresAt == null || x.ExpiresAt > now) + && x.ContainerId == containerId)) throw new InvalidOperationException("容器已被其他任务预占"); if (await _db.WmsTransportReservations.AnyAsync(x => - x.Status == WmsReservationStatuses.Active && x.TargetStorageId == targetStorageId)) + x.Status == WmsReservationStatuses.Active + && (x.ExpiresAt == null || x.ExpiresAt > now) + && x.TargetStorageId == targetStorageId)) throw new InvalidOperationException("目标库位已被其他任务预占"); } diff --git a/MiGu.Server/appsettings.json b/MiGu.Server/appsettings.json index e5c7211..c0c9dac 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/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 9a47220..0000000 Binary files a/MiGu.Server/data/platform.db and /dev/null differ 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/plans/2026-07-25-migu-outpost-ui-lock.md b/docs/superpowers/plans/2026-07-25-migu-outpost-ui-lock.md new file mode 100644 index 0000000..7fd69a9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-migu-outpost-ui-lock.md @@ -0,0 +1,23 @@ +# 迷毂 Outpost UI 实现计划(P0–P1) + +> **面向 AI 代理的工作者:** 按任务顺序实现;规格见 `docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md`。 + +## 文件 + +| 文件 | 职责 | +|---|---| +| `src/styles/themes.ts` | PRIMARY 三色 + LEGACY;默认 `outpost-light`;`data-shell` | +| `src/styles/theme.css` | 浅色壳选择器改 `data-shell="outpost"`;默认 token 贴近浅色 | +| `src/stores/ui.ts` | `availableThemes` / `legacyThemes`;未知 id 迁移 | +| `src/components/ThemeSwitcher.vue` | 仅精选 3 | +| `src/layouts/AppShell.vue` | 高级 → 兼容主题 | +| `src/views/LoginView.vue` | 弱化霓虹(P1) | +| 若干 Vue 内 `fame-lavender` 选择器 | 改为 `data-shell="outpost"` | + +## 任务 + +1. 重写 `themes.ts`(精选 + legacy + applyThemeVars 写 `data-shell`) +2. `theme.css` / 组件选择器:`fame-lavender` → `data-shell="outpost"` +3. `ui` store + ThemeSwitcher + AppShell 高级入口 +4. Login 弱化星空主导(能快则做) +5. `pnpm`/`npm` typecheck 或 build 验证 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 内按钮开关;开才检测 | diff --git a/docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md b/docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md new file mode 100644 index 0000000..671ed75 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md @@ -0,0 +1,273 @@ +# 迷毂平台 UI · 锁定 Outpost 浅色设计规格 + +> 状态:待用户审查(主题策略已按反馈修订) +> 日期:2026-07-25 +> 参考站:[Outpost · Fairyland Technology](https://fairylandtech.amerc.ai) +> 决策:方案 1 壳层气质(Outpost)+ **主路径精选 3 套配色(浅色 / 紫色 / 蓝色)**;旧深色霓虹主题**保留并藏入高级** +> 范围前端:`Migu2.0/frontends/apps/simple-platform-vue` +> 不改:SimpleLite ImGui / CycleGUI 桌面壳、后端 API 契约 + +--- + +## 1. 目标与非目标 + +### 1.1 目标 + +- 迷毂 Web(登录 → AppShell → 业务页 → 地图编辑器 chrome)在视觉与壳层气质上对齐 Outpost。 +- **壳层统一为 Outpost 结构**:浅主区 + 深色纹理侧栏 + 纸白卡片 + 柔阴影;无默认霓虹 orb。 +- **主路径提供 3 套精选配色**(可一键切换):浅色(默认)、紫色、蓝色——同结构、不同主色/侧栏色相。 +- 旧深色霓虹主题(星云紫等)**代码保留**,放入「高级 / 兼容主题」,不进主切换列表。 +- 保留调度业务信息架构;**不**把 Outpost 会话列表硬套进调度导航。 +- 状态色(绿/琥珀/红/蓝/灰)与品牌色解耦,语义不变。 + +### 1.2 非目标 + +- 不重写业务逻辑、路由权限、Projection/SSE 协议。 +- 不第一阶段替换 Element Plus(以 CSS 变量 + 皮肤覆盖为主)。 +- 不强制 3D 画布(webVRender)改浅色底。 +- 不把 Outpost 的 Agent 对话 IA 迁移为迷毂主导航。 +- 主路径不堆砌过多主题(精选 3 + 高级兼容即可)。 + +--- + +## 2. 产品决策(已确认) + +| 项 | 决策 | +|---|---| +| 壳层气质 | Outpost:浅主区 + 深侧栏 + 纸白卡 + 柔阴影 | +| 主路径配色 | **3 套精选**:浅色(默认)/ 紫色 / 蓝色 | +| 旧深色主题 | **保留**,藏入用户菜单「高级 → 兼容主题」 | +| ThemeSwitcher | **保留在顶栏**,只列出精选 3 套;兼容主题在高级入口 | +| 登录页 | 跟当前精选主题(默认浅色 Outpost);去掉星空霓虹主导 | +| 地图编辑器 | Chrome 跟精选浅色壳;**3D 画布保持深色** | +| AI 助手抽屉 | 视觉跟 Outpost 对话区,不改后端权限 | + +--- + +## 3. 参考站提炼(Outpost) + +### 3.1 信息架构(借壳不借业务) + +``` +┌────────────┬──────────────────────────────────────┐ +│ Sidebar │ Top context (optional, light) │ +│ ~256px ├──────────────────────────────────────┤ +│ dark plum │ Main workspace │ +│ textured │ paper cards / lists / forms │ +│ brand+nav │ │ +└────────────┴──────────────────────────────────────┘ +``` + +迷毂映射: + +| Outpost | 迷毂 | +|---|---| +| Sidebar brand + list | `AppShell` 侧栏 logo + `el-menu` | +| Workspace | `el-main` + 各业务 View | +| Artifact / card | KPI 卡、列表卡、属性面板、抽屉 | +| Conversation chrome | AI 助手抽屉(仅视觉) | + +### 3.2 设计 Token(锁定值) + +来源:Outpost 线上 CSS(`color-scheme: light`)。 + +| Token | 值 | 用途 | +|---|---|---| +| `--op-page` | `#f6f3fb` | 应用主区底 | +| `--op-paper` | `#fffefd` | 卡片/面板底 | +| `--op-paper-soft` | `#fbf9fd` | 次级表面 | +| `--op-ink` | `#28213a` | 主文字 | +| `--op-muted` | `#756d85` | 次级文字 | +| `--op-faint` | `#9a93a7` | 弱化/占位 | +| `--op-line` | `#e8e2ef` | 分割线 | +| `--op-line-strong` | `#ddd4e8` | 强调边框 | +| `--op-violet` | `#7543e8` | 品牌主色 | +| `--op-violet-strong` | `#5d2ecb` | 主色按下/深 | +| `--op-violet-soft` | `#eee8ff` | 主色浅底 | +| `--op-mint` / `--op-amber` / `--op-danger` / `--op-blue` | 见参考站 | 仅作辅助;**业务状态仍用既有 `--mg-status-*`** | +| `--op-shadow` | `0 12px 35px rgba(54,35,78,.08)` | 默认浮起 | +| `--op-shadow-strong` | `0 22px 65px rgba(46,24,72,.18)` | 弹层/强调卡 | +| `--op-radius` | `16px` | 默认圆角 | +| Sidebar | `linear-gradient(160deg,#3f2454,#2c193c 45%,#241530)` + 细纹理 | 侧栏底 | +| Font | Inter + 系统无衬线;数据位保留 JetBrains Mono | 与现 `--mg-font-*` 对齐 | + +### 3.3 明确禁止(相对当前「星云紫」) + +- 主路径禁用:大面积霓虹 glow、多层 orb 背景、深色玻璃卡片作默认表面。 +- 主路径禁用:把品牌紫当 success/warning。 +- 避免再引入第三套互不兼容的色板命名;Outpost token **映射进**既有 `--mg-*`,业务组件继续只读 `--mg-*`。 + +--- + +## 4. 精选三套主题 + Token 映射 + +三套均遵守同一 Outpost 壳层规则(浅主区 / 纸白卡 / 柔阴影 / orb=0 / radius≈16),仅 **主色 + 侧栏色相 + 主区底轻微染色** 不同。 + +### 4.1 主路径精选(`PRIMARY_THEMES`) + +| id | 显示名 | 定位 | 主色 | 侧栏 | 主区底 | +|---|---|---|---|---|---| +| `outpost-light` | **浅色**(默认) | 最贴近参考站 | `#7543e8` | `#3f2454 → #241530`(plum) | `#f6f3fb` | +| `outpost-purple` | **紫色** | 同结构、更饱和紫品牌 | `#7c3aed` | `#2d1b69 → #1a1040` | `#f3eefc`(略偏紫) | +| `outpost-blue` | **蓝色** | 同结构、蓝科技感 | `#3c78d2` | `#0f2744 → #0a1a30` | `#f3f6fb`(略偏蓝) | + +共用表面: + +| `--mg-*` | 值 | +|---|---| +| `--mg-bg-card-rgb` | paper `255,254,253` | +| `--mg-text` 系 | ink `#28213a` / muted `#756d85`(浅底深字) | +| `--mg-glass-*` | 白卡片 + 细边 + 柔阴影(无强 blur glow) | +| `--mg-radius*` | 基准 16px | +| `--mg-orb-*-opacity` | **0** | +| 状态色 | 继续 `--mg-status-*`,不随主题主色改语义 | + +### 4.2 高级兼容主题(`LEGACY_THEMES`,保留) + +现有深色霓虹预设移入此列表(至少含): + +- `industrial-purple` 星云紫 +- `deep-azure` 深邃蓝 +- `emerald-forge` / `crimson-iron` / `graphite-steel` / `amber-forge`(若仍在代码中) +- 旧 `fame-lavender`:合并进 `outpost-purple` 或标 deprecated 后从主列表移除 + +入口:用户头像菜单 → **高级 → 兼容主题**(折叠)。主顶栏 `ThemeSwitcher` **只显示 3 个精选**。 + +### 4.3 默认与迁移 + +- `DEFAULT_THEME_ID = 'outpost-light'` +- `:root` 默认 CSS 与 `outpost-light` 一致,避免闪屏 +- localStorage 若为未知 / 已 deprecated id → 迁移到 `outpost-light` +- 若为 `LEGACY_THEMES` 中的 id → **尊重用户选择**(不强制踢回浅色) +- 登录页品牌区跟当前主题侧栏色;`--lg-*` 随精选主题切换或登录页固定跟 `outpost-light`(实现时二选一,推荐:**登录固定 outpost-light,进壳后再跟用户主题**) + +--- + +## 5. 壳层与页面改造要点 + +### 5.1 AppShell + +- 侧栏:纹理渐变(随主题色相)、品牌区分隔线、菜单选中用 soft 底或左侧高光条。 +- 主区装饰 orb:`opacity=0`。 +- 顶栏:浅底、细边、弱阴影;breadcrumb + **精选 ThemeSwitcher(3 项)** + 用户区。 +- 用户菜单增加「高级 → 兼容主题」子菜单(列出 `LEGACY_THEMES`)。 +- 底栏:弱化字色或并入顶栏次级信息(可选后续)。 +- `aside-footer`:版本号;可选极简当前主题名。 + +### 5.2 登录 / 配置向导 + +- 布局可保留左右分栏,但: + - 背景:浅底 + 可选极轻纹理,不用星空粒子主导。 + - 左:深色品牌板(默认跟 `outpost-light` 侧栏)。 + - 右:paper 表单卡 + 主色按钮。 +- 配置向导与登录同肤。 + +### 5.3 Element Plus 皮肤 + +统一覆盖(全局): + +- Button primary / default +- Card / Dialog / Drawer / MessageBox +- Table / Pagination +- Menu(侧栏已透明底,补选中态) +- Input / Select / Tabs / Tag + +原则:纸白表面、ink 文字、violet 主按钮、radius≈14–16、阴影用 `--op-shadow`。 + +### 5.4 业务页(渐进,同一皮肤) + +优先级: + +1. Dashboard / 地图监控 / 调度工作台 +2. 任务 / 告警 / 车辆中心 +3. OTA / 配置中心 / 服务状态 +4. AI 助手抽屉 + +统一模式:`page-header` + paper 卡片栅格 + 紫 CTA;清理页面内 `industrial-purple` 专用覆盖或改为仅在「兼容主题」下生效。 + +### 5.5 地图编辑器 + +| 层 | 皮肤 | +|---|---| +| EditTopBar / ToolRail / PropertyPanel / StatusBar / AI 侧栏 | Outpost 浅色 | +| Workspace3D / webVRender 画布 | **保持深色视口** | +| 与壳层交界 | 1px `--op-line`,避免深浅硬切刺眼 | + +### 5.6 主题列表处理 + +| 分组 | 主题 | 处理 | +|---|---|---| +| 精选 | `outpost-light` / `outpost-purple` / `outpost-blue` | 新建或由现有浅色主题改版;顶栏可切换 | +| 合并 | `fame-lavender` | 并入 `outpost-purple` 或启动时 remap | +| 高级兼容 | `industrial-purple`、`deep-azure`、绿/赤/钢/琥珀等 | 保留完整 vars,仅高级菜单可选 | +| 持久化 | 精选或 legacy id | 精选正常读写;未知 id → `outpost-light`;legacy id **不强制迁移** | + +--- + +## 6. 文件影响面(实现时) + +| 文件/目录 | 职责 | +|---|---| +| `src/styles/theme.css` | 默认 token、Element 覆盖、禁 orb | +| `src/styles/themes.ts` | `PRIMARY_THEMES`(3)+ `LEGACY_THEMES`;默认 `outpost-light` | +| `src/stores/ui.ts`(或等价) | 默认主题、未知 id 迁移、legacy 可选 | +| `src/components/ThemeSwitcher.vue` | 只渲染精选 3;样式 Outpost 化 | +| `src/layouts/AppShell.vue` | 侧栏/顶栏;用户菜单挂「兼容主题」 | +| `src/views/LoginView.vue` | 登录换肤 | +| `design-system/MASTER.md` | 产品风格改为 Outpost 浅色工作台 | +| `design-system/outpost.md` | Token 与组件规范(新建) | +| 各 View 内 `:root[data-theme="industrial-purple"]` 块 | 降级为 legacy 或删除 | +| 地图编辑器 chrome 组件 | 浅色适配 | + +--- + +## 7. 分阶段交付 + +| Phase | 内容 | 验收 | +|---|---|---| +| **P0** | 三套精选 token + 默认 `outpost-light` + legacy 分组 + 文档 | 冷启动浅色 Outpost;可切紫/蓝 | +| **P1** | AppShell + 登录 + Element 皮肤 + ThemeSwitcher(3) + 高级兼容入口 | 壳层/登录 Outpost 化;高级仍能开星云紫等 | +| **P2** | Dashboard / 监控 / 调度 / 列表页 | 主业务无霓虹玻璃默认态 | +| **P3** | 地图编辑器 chrome + AI 抽屉 | 外围浅、画布深;助手区纸白气泡 | +| **P4** | 清理 legacy 覆盖、MASTER 更新、回归清单 | 无残留 `industrial-purple` 主路径样式 | + +每阶段可独立合并;**不在本规格内自动改 SimpleLite 原生菜单皮肤**。 + +--- + +## 8. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 深色主题下写死的白字/半透明在浅底不可读 | P0 强制迁移 + P2 逐页扫对比度 | +| 地图页信息密度高,浅色易显脏 | 卡片分区 + 表格紧凑密度,状态色保留 | +| Element 覆盖不全 | 先覆盖高频控件,缺口记入回归清单 | +| 用户 localStorage 旧主题 | 启动迁移 + 一次性提示(可选) | + +--- + +## 9. 验收清单(整站) + +- [ ] 未登录:登录页为 Outpost 浅色气质 +- [ ] 登录后:侧栏深紫纹理 + 主区 `#f6f3fb` + 纸白卡片 +- [ ] 顶栏可切换 **浅色 / 紫色 / 蓝色** 三套精选 +- [ ] 用户菜单「高级 → 兼容主题」仍能选星云紫等旧深色 +- [ ] 监控/调度/OTA/配置:纸白卡、ink 字、无默认霓虹 orb +- [ ] 地图编辑器:工具/属性浅色,3D 画布仍深色可读 +- [ ] 告警/成功/故障色不被品牌色替换 +- [ ] 刷新后主题保持;未知 id 回落 `outpost-light` + +--- + +## 10. 开放项(审查时确认) + +1. ~~旧深色主题~~ → **已确认:保留并藏入高级** +2. **底栏 footer**:保留弱化版,还是删除并入顶栏?(建议:P1 先弱化保留) +3. 实现计划:`Migu2.0/docs/superpowers/plans/2026-07-25-migu-outpost-ui-lock.md`(本规格通过后编写) + +--- + +## 11. 审查结论(请用户勾选) + +- [ ] 同意本规格(含精选三色 + 高级保留旧主题),可编写实现计划并开工 +- [ ] 需要修改:________ 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/components.d.ts b/frontends/apps/simple-platform-vue/components.d.ts index 1892a7c..a7b6129 100644 --- a/frontends/apps/simple-platform-vue/components.d.ts +++ b/frontends/apps/simple-platform-vue/components.d.ts @@ -7,8 +7,10 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AiAssistantDrawer: typeof import('./src/components/assistant/AiAssistantDrawer.vue')['default'] AiAssistantPanel: typeof import('./src/components/map-editor/AiAssistantPanel.vue')['default'] AiGenerateDialog: typeof import('./src/components/map-editor/AiGenerateDialog.vue')['default'] + BetterAddFieldDialog: typeof import('./src/components/map-editor/BetterAddFieldDialog.vue')['default'] ConfigPageBase: typeof import('./src/components/ConfigPageBase.vue')['default'] DataTablePro: typeof import('./src/components/DataTablePro.vue')['default'] EdgeInspector: typeof import('./src/components/workflow/EdgeInspector.vue')['default'] @@ -19,28 +21,19 @@ declare module 'vue' { ElAlert: typeof import('element-plus/es')['ElAlert'] ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] - ElBadge: typeof import('element-plus/es')['ElBadge'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElButton: typeof import('element-plus/es')['ElButton'] - ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] - ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] - ElCol: typeof import('element-plus/es')['ElCol'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElContainer: typeof import('element-plus/es')['ElContainer'] - ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] - ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] - ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] - ElDivider: typeof import('element-plus/es')['ElDivider'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] - ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElFooter: typeof import('element-plus/es')['ElFooter'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] @@ -53,15 +46,10 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] - ElPagination: typeof import('element-plus/es')['ElPagination'] - ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - ElRow: typeof import('element-plus/es')['ElRow'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] - ElSlider: typeof import('element-plus/es')['ElSlider'] - ElStatistic: typeof import('element-plus/es')['ElStatistic'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] @@ -69,14 +57,14 @@ declare module 'vue' { ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] - ElTimeline: typeof import('element-plus/es')['ElTimeline'] - ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] FieldMemberEditor: typeof import('./src/components/orchestration/FieldMemberEditor.vue')['default'] FleetAllocationPanel: typeof import('./src/components/fleet/FleetAllocationPanel.vue')['default'] FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default'] FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.vue')['default'] + JsonFoldNode: typeof import('./src/components/common/JsonFoldNode.vue')['default'] + JsonFoldViewer: typeof import('./src/components/common/JsonFoldViewer.vue')['default'] MapConnectionPanel: typeof import('./src/components/map-manage/MapConnectionPanel.vue')['default'] MapMergePanel: typeof import('./src/components/map-manage/MapMergePanel.vue')['default'] MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.vue')['default'] @@ -87,6 +75,7 @@ declare module 'vue' { PermissionGuard: typeof import('./src/components/PermissionGuard.vue')['default'] PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default'] ProjectBrowseDialog: typeof import('./src/components/map-editor/ProjectBrowseDialog.vue')['default'] + QuickEntryPickerDialog: typeof import('./src/components/dashboard/QuickEntryPickerDialog.vue')['default'] ReflectionKindTable: typeof import('./src/components/orchestration/ReflectionKindTable.vue')['default'] ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -97,6 +86,8 @@ declare module 'vue' { ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default'] ThemeSwitcher: typeof import('./src/components/ThemeSwitcher.vue')['default'] VehicleHealthCard: typeof import('./src/components/fleet/VehicleHealthCard.vue')['default'] + VehicleHealthRow: typeof import('./src/components/fleet/VehicleHealthRow.vue')['default'] + VehicleMaintenanceSelect: typeof import('./src/components/fleet/VehicleMaintenanceSelect.vue')['default'] VehicleMonitorPanel: typeof import('./src/components/workbench/VehicleMonitorPanel.vue')['default'] WorkbenchSidePanel: typeof import('./src/components/workbench/WorkbenchSidePanel.vue')['default'] WorkflowEditor: typeof import('./src/components/workflow/WorkflowEditor.vue')['default'] @@ -105,7 +96,4 @@ declare module 'vue' { Workspace3D: typeof import('./src/components/Workspace3D.vue')['default'] WorkspaceCanvasToolbar: typeof import('./src/components/workspace/WorkspaceCanvasToolbar.vue')['default'] } - export interface ComponentCustomProperties { - vLoading: typeof import('element-plus/es')['ElLoadingDirective'] - } } diff --git a/frontends/apps/simple-platform-vue/design-system/outpost.md b/frontends/apps/simple-platform-vue/design-system/outpost.md new file mode 100644 index 0000000..8be7959 --- /dev/null +++ b/frontends/apps/simple-platform-vue/design-system/outpost.md @@ -0,0 +1,22 @@ +# Outpost 视觉规范(迷毂壳层) + +> 权威决策见:`Migu2.0/docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md` +> 参考:https://fairylandtech.amerc.ai + +## 一句话 + +Outpost 壳层:浅主区 + 深色纹理侧栏 + 纸白卡片 + 柔阴影。 +主路径精选三色:**浅色(默认)/ 紫色 / 蓝色**;旧深色霓虹主题保留在「高级 → 兼容主题」。 + +## 精选主题 id + +| id | 名称 | +|---|---| +| `outpost-light` | 浅色(默认) | +| `outpost-purple` | 紫色 | +| `outpost-blue` | 蓝色 | + +## 例外 + +- webVRender / 地图 3D 画布:保持深色视口。 +- 状态色:继续用 `--mg-status-*`。 diff --git a/frontends/apps/simple-platform-vue/index.html b/frontends/apps/simple-platform-vue/index.html index e4e1248..a632fdb 100644 --- a/frontends/apps/simple-platform-vue/index.html +++ b/frontends/apps/simple-platform-vue/index.html @@ -1,11 +1,14 @@ - + 迷毂 · 智能调度平台 - + + + + 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/assistant.ts b/frontends/apps/simple-platform-vue/src/api/assistant.ts new file mode 100644 index 0000000..10648c1 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/assistant.ts @@ -0,0 +1,208 @@ +import http from './http' + +/** + * AI 助手 API:会话/工具走 axios(带鉴权拦截器);对话走原生 fetch 流式(SSE), + * 因为 EventSource 只能 GET、且无法设置 Authorization header。fetch 这里手动对齐 + * axios 的双轨鉴权(Cookie + Bearer + X-Scope)。后端见 SimpleLite `Web/Assistant/AssistantApi.cs`。 + */ + +const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api' +const REST = '/sl/projection/assistant' + +export interface AssistantSessionMeta { + id: string + title: string + created: string + updated: string + turns: number +} + +export interface AssistantToolMeta { + name: string + description: string + isWrite: boolean +} + +export interface AssistantHistoryTurn { + role: 'user' | 'assistant' | 'tool' + content?: string | null + toolCalls?: { name: string; arguments: string }[] + time: string +} + +export interface AssistantHistory { + id: string + title: string + created: string + updated: string + turns: AssistantHistoryTurn[] +} + +export async function listSessions(): Promise { + const { data } = await http.get(`${REST}/sessions`) + return data ?? [] +} + +export async function getHistory(sessionId: string): Promise { + const { data } = await http.get(`${REST}/history`, { params: { sessionId } }) + return data +} + +export async function createSession(title?: string): Promise<{ id: string; title: string }> { + const { data } = await http.post<{ id: string; title: string }>( + `${REST}/sessions`, + null, + title ? { params: { title } } : undefined + ) + return data +} + +export async function deleteSession(id: string): Promise { + await http.delete(`${REST}/sessions/${encodeURIComponent(id)}`) +} + +export async function listTools(): Promise { + const { data } = await http.get(`${REST}/tools`) + return data ?? [] +} + +export interface AssistantStreamHandlers { + onSession?: (sessionId: string) => void + onToken?: (delta: string) => void + onToolCall?: (name: string, args: unknown) => void + onToolResult?: (name: string, ok: boolean, result: unknown) => void + onError?: (message: string) => void + onDone?: (finishReason: string, usedTools: number) => void +} + +/** + * 发起一轮对话并以 SSE 流式消费。通过 支持中断(停止生成)。 + * 事件协议见后端 §6.2:session / token / tool_call / tool_result / error / done。 + */ +export async function streamChat( + payload: { message: string; sessionId?: string | null; profile?: string }, + handlers: AssistantStreamHandlers, + signal: AbortSignal +): Promise { + const token = localStorage.getItem('simple.auth.token') + const scope = localStorage.getItem('simple.auth.scope') + + let resp: Response + try { + resp = await fetch(`${API_BASE}${REST}/chat`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(scope ? { 'X-Scope': scope } : {}) + }, + body: JSON.stringify({ + message: payload.message, + sessionId: payload.sessionId ?? undefined, + profile: payload.profile ?? 'analysis' + }), + signal + }) + } catch (e) { + if ((e as Error).name === 'AbortError') return + handlers.onError?.('网络错误:无法连接 AI 助手服务。') + handlers.onDone?.('error', 0) + return + } + + if (resp.status === 401) { + handlers.onError?.('登录已失效,请重新登录。') + handlers.onDone?.('error', 0) + return + } + if (!resp.ok || !resp.body) { + let msg = `请求失败(HTTP ${resp.status})` + if (resp.status === 403) msg = '无权使用 AI 助手(需要 Platform 权限)。' + else if (resp.status === 502 || resp.status === 504) msg = 'SimpleLite 未连接:请先启动后端(端口 8222)。' + else { + try { + const t = await resp.text() + if (t) msg = t.slice(0, 500) + } catch { + /* ignore */ + } + } + handlers.onError?.(msg) + handlers.onDone?.('error', 0) + return + } + + const reader = resp.body.getReader() + const decoder = new TextDecoder() + let buf = '' + try { + for (;;) { + const { value, done } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + let sep: number + // 帧之间以空行(\n\n)分隔。 + while ((sep = indexOfFrameBoundary(buf)) >= 0) { + const frame = buf.slice(0, sep) + buf = buf.slice(sep).replace(/^(\r?\n){2}/, '') + dispatchFrame(frame, handlers) + } + } + if (buf.trim()) dispatchFrame(buf, handlers) + } catch (e) { + if ((e as Error).name !== 'AbortError') { + handlers.onError?.((e as Error).message || '读取流失败') + handlers.onDone?.('error', 0) + } + } +} + +function indexOfFrameBoundary(s: string): number { + const a = s.indexOf('\n\n') + const b = s.indexOf('\r\n\r\n') + if (a < 0) return b + if (b < 0) return a + return Math.min(a, b) +} + +function dispatchFrame(raw: string, h: AssistantStreamHandlers): void { + let event = 'message' + const dataLines: string[] = [] + for (const lineRaw of raw.split('\n')) { + const line = lineRaw.replace(/\r$/, '') + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')) + } + const dataStr = dataLines.join('\n') + let data: Record = {} + try { + data = dataStr ? JSON.parse(dataStr) : {} + } catch { + data = { raw: dataStr } + } + + switch (event) { + case 'session': + h.onSession?.(String(data.sessionId ?? '')) + break + case 'token': + h.onToken?.(String(data.delta ?? '')) + break + case 'tool_call': + h.onToolCall?.(String(data.name ?? ''), data.args) + break + case 'tool_result': + h.onToolResult?.(String(data.name ?? ''), Boolean(data.ok), data.result) + break + case 'error': + h.onError?.(String(data.message ?? '未知错误')) + break + case 'done': + h.onDone?.(String(data.finishReason ?? 'stop'), Number(data.usedTools ?? 0)) + break + default: + break + } +} 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/api/mapEdit.ts b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts index a0adbef..8269e65 100644 --- a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts +++ b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts @@ -122,8 +122,17 @@ export interface AssetUploadResult { url: string } +export type SelectTrackMode = 'all' | 'straight' | 'curve' + export interface ViewFilterSnapshot { - selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean } + selectFilter: { + sites: boolean + tracks: boolean + cars: boolean + decor: boolean + /** 路径子类型:all=全部;straight=仅 UITrack;curve=贝塞尔/弧/NURBS */ + trackMode?: SelectTrackMode + } alignSnap: { sites: boolean; cars: boolean; tracks: boolean } showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean } } @@ -195,11 +204,68 @@ export const mapEditApi = { batch: (ops: BatchOp[]) => unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })), + /** + * CAD 相对包围盒对齐(SimpleLite CadAlignService)。 + * mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV + */ + cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) => + unwrap<{ + mode: string + count: number + previous: Array<{ kind: string; id: number; x: number; y: number }> + updated: Array<{ kind: string; id: number; x: number; y: number }> + }>(http.post(`${BASE}/cad/align`, { mode, targets })), + + /** 横向/纵向等距建站(CadBatchGenerateService.PlanLinear)。 */ + cadBatchLinear: (p: { + horizontal: boolean + x1: number + y1: number + x2: number + y2: number + count: number + layer?: string + namePrefix?: string + }) => + unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>( + http.post(`${BASE}/cad/batch-linear`, p) + ), + + /** 矩阵建站(CadBatchGenerateService.PlanMatrix)。 */ + cadBatchMatrix: (p: { + x1: number + y1: number + x2: number + y2: number + rows: number + cols: number + layer?: string + namePrefix?: string + }) => + unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>( + http.post(`${BASE}/cad/batch-matrix`, p) + ), + copyFieldsTo: (kind: MapEditKind | string, id: number, fieldNames: string[], targets: Array<{ kind: string; id: number }>) => unwrap<{ copied: number }>(http.post(`${BASE}/objects/${kind}/${id}/fields/copy-to`, { fieldNames, targets })), - // 拾取 / 仪表盘 - pick: () => unwrap(http.post(`${BASE}/pick`)), + /** 场景已有自定义字段键(Ctrl+I /「复制字段」弹窗候选)。 */ + sceneFieldKeys: () => + unwrap<{ keys: string[] }>(http.get(`${BASE}/scene-field-keys`)), + + /** 对多个目标批量写入同一字段值(对齐桌面 Ctrl+I)。 */ + batchSetField: (key: string, value: string, targets: Array<{ kind: string; id: number }>) => + unwrap<{ affected: number }>(http.post(`${BASE}/batch-set-field`, { key, value, targets })), + + /** 端点落在给定站点上的路径(删站点会级联删除;撤销前需一并快照)。 */ + tracksTouchingSites: (siteIds: number[]) => + unwrap<{ tracks: Array<{ id: number; typeName: string; siteA: number; siteB: number }> }>( + http.post(`${BASE}/tracks-touching-sites`, { siteIds }) + ), + + // 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。 + pick: (opts?: { snap?: boolean }) => + unwrap(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })), dashboardSummary: () => unwrap(http.get(`${BASE}/dashboard/summary`)), 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/api/projection.ts b/frontends/apps/simple-platform-vue/src/api/projection.ts index 26476e7..394d191 100644 --- a/frontends/apps/simple-platform-vue/src/api/projection.ts +++ b/frontends/apps/simple-platform-vue/src/api/projection.ts @@ -4,11 +4,16 @@ import type { Site, Track } from '@/types/map' import type { Car, CarState } from '@/types/car' import type { Mission, MissionStatus } from '@/types/mission' import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server' +import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime' /** 设为 true 时使用本地 Mock;默认走 YARP → SimpleLite :8222。 * 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */ const MOCK = import.meta.env.VITE_USE_MOCK === 'true' +/** 车体 status 缓存:电量 + 告警信号,避免 3s 轮询打爆 reflection。 */ +const STATUS_CACHE_TTL_MS = 4000 +const statusCache = new Map | null }>() + export async function listSites(): Promise { if (MOCK) return mockSites() const { data } = await http.get('/sl/projection/sites') @@ -22,14 +27,7 @@ export async function listTracks(): Promise { } function mapStatusToCarState(status?: string | null): CarState { - if (!status) return 'idle' - const s = status.toLowerCase() - if (/fault|error|failed|故障|异常|失联|超时|检修/.test(s)) return 'fault' - if (/charg|充电/.test(s)) return 'charging' - if (/pause|暂停|挂起/.test(s)) return 'paused' - if (/offline|离线/.test(s)) return 'offline' - if (/run|busy|working|运行|工作|执行|忙/.test(s)) return 'running' - return 'idle' + return deriveCarState({ state: 'idle', lstatus: status ?? undefined }, null) } function mapStatusToMissionStatus(status?: string | null): MissionStatus { @@ -53,10 +51,11 @@ function carFromReflection(row: ReflectionObject): Car { x: 0, y: 0, theta: 0, - batterySoc: 0.8, + batterySoc: 0, state: mapStatusToCarState(row.status), lastUpdate: new Date().toISOString(), - group: row.layer ?? undefined + group: row.layer ?? undefined, + lstatus: row.status ?? undefined } } @@ -82,17 +81,52 @@ async function listMissionsFromReflection(): Promise { return rows.map(missionFromReflection) } +async function loadCarStatus(carId: number): Promise | null> { + const cached = statusCache.get(carId) + const now = Date.now() + if (cached && now - cached.at < STATUS_CACHE_TTL_MS) return cached.rows + try { + const status = await reflectionApi.getStatus('car', carId) + const rows = (status ?? []).map((r) => ({ key: r.key, value: r.value })) + statusCache.set(carId, { at: now, rows }) + return rows + } catch { + statusCache.set(carId, { at: now, rows: cached?.rows ?? null }) + return cached?.rows ?? null + } +} + +/** + * 纠正投影里写死的 batterySoc=0.8,以及把「正常但未初始化」误标成 fault 的 state。 + * 数据源:反射 status(车体_Soc / AlarmLevel / driveStatus)。 + */ +async function enrichCarsRuntime(cars: Car[]): Promise { + return Promise.all( + cars.map(async (car) => { + const id = car.rawId ?? Number(String(car.id).replace(/^C/i, '')) + // 无车体 status 时也先按 lstatus 纠一次(「正常但未初始化」→ idle) + if (!Number.isFinite(id) || id <= 0) { + return applyRuntimeEnrichment(car, null) + } + const rows = await loadCarStatus(id) + return applyRuntimeEnrichment(car, rows) + }) + ) +} + /** 优先走 SimpleLite /projection/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */ export async function listCars(): Promise { if (MOCK) return mockCars() try { const { data } = await http.get('/sl/projection/cars') - if (Array.isArray(data) && data.length > 0) return data + if (Array.isArray(data) && data.length > 0) { + return enrichCarsRuntime(data) + } const fallback = await listCarsFromReflection() - if (fallback.length > 0) return fallback - return Array.isArray(data) ? data : [] + if (fallback.length > 0) return enrichCarsRuntime(fallback) + return Array.isArray(data) ? enrichCarsRuntime(data) : [] } catch { - return listCarsFromReflection() + return enrichCarsRuntime(await listCarsFromReflection()) } } diff --git a/frontends/apps/simple-platform-vue/src/api/reflection.ts b/frontends/apps/simple-platform-vue/src/api/reflection.ts index e5da923..2f64ebf 100644 --- a/frontends/apps/simple-platform-vue/src/api/reflection.ts +++ b/frontends/apps/simple-platform-vue/src/api/reflection.ts @@ -1,4 +1,5 @@ import http from './http' +import { ElMessageBox } from 'element-plus' import { mockReflectionAssemblies, mockReflectionBundle, @@ -64,6 +65,8 @@ export interface ReflectionMethod { returnType: string hasParams: boolean params: ReflectionParam[] + requiresPlatformConfirm?: boolean + confirmMessage?: string | null } export interface ReflectionObject { @@ -225,30 +228,94 @@ export function emptyMonitorVisibilityMap(): MonitorVisibilityMap { // 单独保留命名是为了表达"业务含义不同"——可勾选全集 vs 已勾选白名单。 export const emptyMonitorAvailableMap = emptyMonitorVisibilityMap +export class ReflectionApiError extends Error { + code: number + data: unknown + + constructor(message: string, code: number, data?: unknown) { + super(message) + this.name = 'ReflectionApiError' + this.code = code + this.data = data ?? null + } +} + +export interface ReflectionExecuteResult { + returnValue?: string | null + accepted?: boolean + completed?: boolean +} + +/** 根据 execute 返回区分「已完成」与「已受理(后台继续)」文案。 */ +export function formatReflectionExecuteMessage( + label: string, + result: ReflectionExecuteResult +): string { + if (result.returnValue) return `已执行:${result.returnValue}` + if (result.accepted && result.completed === false) { + return `已受理:${label}(后台继续执行,请稍后在 SimpleLite 查看结果)` + } + if (result.accepted) return `已执行 ${label}` + return `已执行 ${label}` +} + +export interface ReflectionExecuteOptions { + /** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */ + platformConfirmed?: boolean +} + async function get(path: string): Promise { const { data } = await http.get>(`${BASE}${path}`) - if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`) + if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data) return data.data as T } -async function post(path: string, params?: Record): Promise { - const { data } = await http.post>(`${BASE}${path}`, null, { params }) - if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`) +async function post( + path: string, + params?: Record, + headers?: Record +): Promise { + const { data } = await http.post>(`${BASE}${path}`, null, { params, headers }) + if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data) return data.data as T } async function del(path: string): Promise { const { data } = await http.delete>(`${BASE}${path}`) - if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`) + if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data) return data.data as T } async function patchJson(path: string, body: unknown): Promise { const { data } = await http.patch>(`${BASE}${path}`, body) - if (!data?.success) throw new Error(data?.message ?? `reflection PATCH ${path} failed`) + if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection PATCH ${path} failed`, data?.code ?? 500, data?.data) return data.data as T } +async function executeWithPlatformConfirm( + path: string, + params?: Record, + opts?: ReflectionExecuteOptions +): Promise { + const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined + try { + return await post(path, params, headers) + } catch (e) { + if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) { + const confirmMessage = + (e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim() + || '此操作需要确认' + await ElMessageBox.confirm(confirmMessage, '确认', { + type: 'warning', + confirmButtonText: '确定', + cancelButtonText: '取消' + }) + return await post(path, params, { 'X-Platform-Confirmed': '1' }) + } + throw e + } +} + export const reflectionApi = { listKinds: () => MOCK ? Promise.resolve(mockReflectionKinds()) @@ -371,16 +438,23 @@ export const reflectionApi = { fieldList?: ReflectionKv[] }>(`/bundle/${kind}/${id}`), - execute: (kind: ReflectionKind, id: number, method: string, params?: Record) => MOCK + execute: ( + kind: ReflectionKind, + id: number, + method: string, + params?: Record, + opts?: ReflectionExecuteOptions + ) => MOCK ? Promise.resolve(mockReflectionExecute( kind, id, method, Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)])) )) - : post<{ returnValue: string }>( + : executeWithPlatformConfirm( `/execute/${kind}/${id}/${encodeURIComponent(method)}`, - params + params, + opts ), /** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */ diff --git a/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts b/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts index 17363f6..9aed3fa 100644 --- a/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts +++ b/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts @@ -34,10 +34,15 @@ export interface ToolbarRecordingState { isRecording: boolean mode: string isPlaying: boolean + isAutoPlaying?: boolean + speed?: number elapsedMs: number + durationMs?: number frameCount: number + currentFrameIndex?: number droppedFrames: number currentRecordingFile: string | null + currentPlaybackFile?: string | null recordingsDirectory: string entries: ToolbarRecordingEntry[] } @@ -122,6 +127,20 @@ export const workspaceToolbarApi = { stopPlayback: () => unwrap(http.post(`${BASE}/playback/stop`)), + playPause: () => unwrap(http.post(`${BASE}/playback/play-pause`, {})), + + seekElapsed: (elapsedMs: number) => + unwrap(http.post(`${BASE}/playback/seek`, { elapsedMs, ElapsedMs: elapsedMs })), + + seekFrame: (frameIndex: number) => + unwrap(http.post(`${BASE}/playback/seek`, { frameIndex, FrameIndex: frameIndex })), + + step: (delta = 1) => + unwrap(http.post(`${BASE}/playback/step`, { delta, Delta: delta })), + + setSpeed: (speed: number) => + unwrap(http.post(`${BASE}/playback/speed`, { speed, Speed: speed })), + toggleViewMode: () => unwrap(http.post(`${BASE}/view/toggle`)), setCameraFollow: (carId: number | null, enabled: boolean) => 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 @@ + + + {{ fileName || '回放中' }} + + ⏪ + ⏮ + + {{ autoPlaying ? '⏸' : '▶' }} + + ⏭ + ⏩ + + + {{ formatMs(elapsedMs) }} / {{ formatMs(durationMs) }} + + {{ s }}x + + 停止 + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue b/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue index 9ffcb20..f9f5dae 100644 --- a/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue +++ b/frontends/apps/simple-platform-vue/src/components/Workspace3D.vue @@ -291,14 +291,23 @@ defineExpose({ reload, enterFullscreen }) .workspace-3d-mask { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; + /* 画布加载光晕固定深紫(不跟浅色壳白底 token,避免被冲成灰白) */ background: - radial-gradient(ellipse at center, rgba(var(--mg-primary-rgb), 0.35) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.92) 80%), - rgba(var(--mg-bg-app-deep-rgb), 0.85); + radial-gradient( + ellipse at 50% 42%, + rgba(155, 124, 255, 0.42) 0%, + rgba(88, 48, 168, 0.72) 38%, + rgba(45, 27, 105, 0.92) 68%, + rgba(15, 4, 32, 0.98) 100% + ); color: rgba(232, 220, 255, 0.92); font-size: 13px; backdrop-filter: blur(10px); pointer-events: none; } -.workspace-3d-mask .muted { color: rgba(var(--mg-accent-rgb), 0.7); font-size: 12px; } -.workspace-3d-mask .el-icon { color: var(--mg-accent); filter: drop-shadow(0 0 10px rgba(var(--mg-accent-rgb), 0.6)); } +.workspace-3d-mask .muted { color: rgba(196, 181, 253, 0.78); font-size: 12px; } +.workspace-3d-mask .el-icon { + color: #c4b5fd; + filter: drop-shadow(0 0 12px rgba(155, 124, 255, 0.65)); +} diff --git a/frontends/apps/simple-platform-vue/src/components/assistant/AiAssistantDrawer.vue b/frontends/apps/simple-platform-vue/src/components/assistant/AiAssistantDrawer.vue new file mode 100644 index 0000000..eef4e1b --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/assistant/AiAssistantDrawer.vue @@ -0,0 +1,677 @@ + + + + ✦ + + + + + + + + + ✦ + + AI 分析助手 + 故障 / 日志 / 数据分析 · 操作答疑 + + + + + + + + + + + + + + + + + + 历史会话 + + 新建 + + 暂无历史会话 + + + + {{ s.title || '未命名会话' }} + {{ s.turns }} 轮 · {{ formatTime(s.updated) }} + + + + + + + + + + + + ✦ + 我帮你做故障 / 日志 / 数据分析 + 试着这样问: + + {{ ex }} + + + + + + {{ m.content }} + + + + + + + + + + + + {{ t.name }} + {{ t.isWrite ? '写' : '读' }} + + + + 参数 + {{ toJson(t.args) }} + + + 结果 + {{ toJson(t.result) }} + + + + + + + + + + 正在思考… + + + + + + {{ m.error }} + + + + + + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue b/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue index 423465d..38bce1c 100644 --- a/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue @@ -51,9 +51,11 @@ :class="`aap-msg--${m.role}`" > - {{ m.text }} + {{ m.text }} + - 落地对象 {{ m.meta.created }} · 工具调用 {{ m.meta.usedTools }} + + 已落地 {{ m.meta.created }} 个对象 · 工具调用 {{ m.meta.usedTools }} @@ -99,10 +101,11 @@ + + diff --git a/frontends/apps/simple-platform-vue/src/components/map-editor/EditPropertyPanel.vue b/frontends/apps/simple-platform-vue/src/components/map-editor/EditPropertyPanel.vue index bd5fd5b..c2dca9c 100644 --- a/frontends/apps/simple-platform-vue/src/components/map-editor/EditPropertyPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/map-editor/EditPropertyPanel.vue @@ -16,11 +16,8 @@ - - - {{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }} - {{ primary?.typeName ?? '-' }} - + + 基础属性 - {{ deleting ? '⟳' : '🗑' }} + {{ deleting ? '⟳' : '×' }} 删除 + + + + + {{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }} + {{ primary?.typeName ?? '-' }} + @@ -863,17 +867,17 @@ function onDelete() { diff --git a/frontends/apps/simple-platform-vue/src/components/map-editor/EditStatusBar.vue b/frontends/apps/simple-platform-vue/src/components/map-editor/EditStatusBar.vue index c99eab0..d28c1da 100644 --- a/frontends/apps/simple-platform-vue/src/components/map-editor/EditStatusBar.vue +++ b/frontends/apps/simple-platform-vue/src/components/map-editor/EditStatusBar.vue @@ -43,15 +43,15 @@ function formatXY(v: number) { align-items: center; gap: 16px; padding: 4px 14px; - background: rgba(20, 8, 40, 0.85); - border-top: 1px solid rgba(255,255,255,0.08); - color: rgba(232,215,245,0.85); + background: var(--me-chrome-2); + border-top: 1px solid var(--me-border); + color: var(--me-text-muted); font-size: 12px; - font-family: ui-monospace, Menlo, Consolas, monospace; + font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace); height: 28px; } .spacer { flex: 1; } -.seg b { color: var(--mg-accent, #c4a4ff); } +.seg b { color: var(--mg-accent); } .seg .on { color: #67c23a; } .seg .off { color: #a0a0a0; } .seg.ok { color: #67c23a; } diff --git a/frontends/apps/simple-platform-vue/src/components/map-editor/EditToolRail.vue b/frontends/apps/simple-platform-vue/src/components/map-editor/EditToolRail.vue index be3a143..9ab17fb 100644 --- a/frontends/apps/simple-platform-vue/src/components/map-editor/EditToolRail.vue +++ b/frontends/apps/simple-platform-vue/src/components/map-editor/EditToolRail.vue @@ -99,7 +99,6 @@ const groups: ToolGroup[] = [ { id: 'transform.move', label: '移动', glyph: '✥' }, { id: 'transform.rotate', label: '旋转', glyph: '⟳' }, { id: 'transform.scale', label: '缩放', glyph: '⤡' }, - { id: 'transform.duplicate', label: '复制副本', glyph: '⎘', tip: 'Ctrl+D' }, { id: 'transform.copy', label: '复制', glyph: '📋', tip: 'Ctrl+C' }, { id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' }, { id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' }, @@ -152,12 +151,12 @@ function onClick(id: EditToolId) { emit('select-tool', id) } .edit-tool-rail { height: 100%; width: 170px; - background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%); - border-right: 1px solid rgba(255,255,255,0.10); + background: linear-gradient(180deg, var(--me-chrome-1) 0%, var(--me-chrome-2) 100%); + border-right: 1px solid var(--me-border); padding: 4px 0 12px; backdrop-filter: blur(10px); display: flex; flex-direction: column; - box-shadow: inset -1px 0 0 rgba(255,255,255,0.04); + box-shadow: inset -1px 0 0 var(--me-card-border, transparent); } .rail-scroll { height: 100%; } .rail-group { @@ -168,12 +167,12 @@ function onClick(id: EditToolId) { emit('select-tool', id) } display: flex; align-items: center; gap: 6px; font-size: 10px; letter-spacing: 2px; - color: rgba(190, 160, 230, 0.7); + color: var(--me-text-muted); text-transform: uppercase; margin: 4px 0 6px; font-weight: 600; } -.rail-group-bar { flex: 1; height: 1px; background: rgba(190, 160, 230, 0.2); } +.rail-group-bar { flex: 1; height: 1px; background: rgba(var(--mg-primary-rgb), 0.18); } .rail-group-list { display: flex; flex-direction: column; gap: 2px; @@ -181,7 +180,7 @@ function onClick(id: EditToolId) { emit('select-tool', id) } .rail-divider { width: 100%; height: 1px; - background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.0) 100%); + background: linear-gradient(90deg, transparent 0%, var(--me-border) 50%, transparent 100%); margin: 6px 0 2px; } .rail-btn { @@ -192,8 +191,8 @@ function onClick(id: EditToolId) { emit('select-tool', id) } min-height: 32px; padding: 0 8px; border-radius: 6px; - background: rgba(255,255,255,0.04); - color: rgba(232,215,245,0.92); + background: var(--me-surface); + color: var(--me-text); display: flex; align-items: center; gap: 8px; font-size: 12.5px; @@ -202,16 +201,16 @@ function onClick(id: EditToolId) { emit('select-tool', id) } transition: background .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease; } .rail-btn:hover { - background: rgba(150, 90, 230, 0.20); - color: #fff; + background: var(--me-hover); + color: var(--mg-primary); } .rail-btn:active { transform: scale(0.985); } .rail-btn.rail-btn--active { - background: linear-gradient(120deg, rgba(170, 110, 250, 0.92) 0%, rgba(120, 70, 220, 0.92) 100%); + background: var(--mg-primary); color: #fff; - box-shadow: 0 3px 10px rgba(140, 80, 230, 0.45), inset 0 0 0 1px rgba(255,255,255,0.18); + box-shadow: 0 3px 10px var(--me-active-glow), inset 0 0 0 1px rgba(255,255,255,0.18); } .rail-btn-glyph { width: 20px; @@ -236,21 +235,21 @@ function onClick(id: EditToolId) { emit('select-tool', id) } position: absolute; left: 8px; right: 8px; top: 0; height: 1px; - background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(220, 140, 230, 0.35) 50%, rgba(255,255,255,0.0) 100%); + background: linear-gradient(90deg, transparent 0%, rgba(var(--mg-accent-rgb), 0.4) 50%, transparent 100%); } .rail-btn--ai { - background: linear-gradient(135deg, rgba(120, 70, 220, 0.55) 0%, rgba(255, 90, 200, 0.55) 100%); + background: var(--mg-primary); color: #fff; font-weight: 600; - box-shadow: 0 2px 10px rgba(220, 90, 220, 0.30), inset 0 0 0 1px rgba(255,255,255,0.20); + box-shadow: 0 2px 10px var(--me-active-glow), inset 0 0 0 1px rgba(255,255,255,0.20); } .rail-btn--ai:hover { - background: linear-gradient(135deg, rgba(150, 90, 240, 0.85) 0%, rgba(255, 110, 220, 0.85) 100%); + background: var(--mg-primary-hover); color: #fff; - box-shadow: 0 4px 14px rgba(230, 110, 230, 0.55), inset 0 0 0 1px rgba(255,255,255,0.30); + box-shadow: 0 4px 14px var(--me-active-glow), inset 0 0 0 1px rgba(255,255,255,0.30); } .rail-btn--ai .rail-btn-glyph { font-size: 16px; - text-shadow: 0 0 8px rgba(255, 200, 250, 0.6); + text-shadow: 0 0 8px rgba(var(--mg-accent-rgb), 0.55); } diff --git a/frontends/apps/simple-platform-vue/src/components/map-editor/EditTopBar.vue b/frontends/apps/simple-platform-vue/src/components/map-editor/EditTopBar.vue index 49d780d..81b91cb 100644 --- a/frontends/apps/simple-platform-vue/src/components/map-editor/EditTopBar.vue +++ b/frontends/apps/simple-platform-vue/src/components/map-editor/EditTopBar.vue @@ -148,16 +148,16 @@ function mark(on: boolean): string { align-items: center; gap: 4px; padding: 8px 14px; - background: linear-gradient(180deg, rgba(50, 22, 88, 0.95) 0%, rgba(34, 14, 64, 0.95) 100%); - border-bottom: 1px solid rgba(255,255,255,0.12); + background: linear-gradient(180deg, var(--me-chrome-1) 0%, var(--me-chrome-2) 100%); + border-bottom: 1px solid var(--me-border); backdrop-filter: blur(10px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + box-shadow: 0 2px 8px rgba(var(--mg-shadow-rgb, 22, 58, 74), 0.08); } .spacer { flex: 1; } -.el-divider--vertical { margin: 0 6px; background: rgba(255,255,255,0.15); } +.el-divider--vertical { margin: 0 6px; background: var(--me-border); } .topbar-btn { - color: #fdf6ff !important; + color: var(--me-text) !important; font-weight: 600; font-size: 13.5px; padding: 6px 12px; @@ -166,22 +166,23 @@ function mark(on: boolean): string { transition: background .15s ease, color .15s ease; } .topbar-btn:hover { - color: #fff !important; - background: rgba(170, 110, 250, 0.28) !important; + color: var(--mg-primary) !important; + background: var(--me-hover) !important; } .topbar-btn:focus { - background: rgba(170, 110, 250, 0.30) !important; + background: var(--me-hover) !important; } .topbar-icon-btn { - color: rgba(253, 246, 255, 0.92) !important; - background: rgba(255,255,255,0.05) !important; - border: 1px solid rgba(255,255,255,0.15) !important; + color: var(--me-text) !important; + background: var(--me-surface) !important; + border: 1px solid var(--me-border) !important; font-size: 15px; } .topbar-icon-btn:hover:not(.is-disabled) { - background: rgba(170, 110, 250, 0.30) !important; - border-color: rgba(170, 110, 250, 0.55) !important; + background: var(--me-hover) !important; + border-color: rgba(var(--mg-primary-rgb), 0.55) !important; + color: var(--mg-primary) !important; } .topbar-icon-btn.is-disabled { opacity: 0.4; @@ -194,11 +195,19 @@ function mark(on: boolean): string { height: auto; border-radius: 6px; margin-right: 4px; + --el-button-bg-color: var(--mg-primary) !important; + --el-button-border-color: var(--mg-primary) !important; + --el-button-hover-bg-color: var(--mg-primary-hover) !important; + --el-button-hover-border-color: var(--mg-primary-hover) !important; + --el-button-active-bg-color: var(--mg-primary-active) !important; + --el-button-active-border-color: var(--mg-primary-active) !important; + --el-button-text-color: #fff !important; + --el-button-hover-text-color: #fff !important; } .topbar-filter-menu :deep(.topbar-filter-header) { font-size: 11px; - color: rgba(232, 215, 245, 0.55); + color: var(--me-text-muted); cursor: default; pointer-events: none; padding-top: 6px; diff --git a/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue b/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue index 0a3781c..b4aad05 100644 --- a/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue @@ -137,10 +137,13 @@ defineExpose({ refresh }) 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" > - + {{ shortSite(row.srcLabel) }} @@ -103,7 +103,7 @@ import { const props = defineProps<{ deliveries: DeliveryTask[] - selectedId?: number | null + selectedId?: string | null loading?: boolean }>() @@ -234,7 +234,10 @@ function hideCtx() { const actionLabels: Record = { cancel: '取消任务', resend: '重发任务', - 'force-complete': '强制完成' + 'force-complete': '强制完成', + pause: '暂停任务', + resume: '恢复任务', + 'change-car': '更换车辆' } async function runAction(action: DeliveryAction) { @@ -384,10 +387,12 @@ onUnmounted(() => document.removeEventListener('click', onDocClick)) z-index: 9000; min-width: 128px; padding: 4px 0; - background: #1e1e2e; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + background: #fffefd; + border: 1px solid rgba(40, 33, 58, 0.1); + border-radius: 12px; + box-shadow: + 0 1px 2px rgba(40, 33, 58, 0.06), + 0 12px 28px rgba(54, 35, 78, 0.14); } .ctx-item { display: block; @@ -395,16 +400,17 @@ onUnmounted(() => document.removeEventListener('click', onDocClick)) padding: 8px 14px; border: none; background: transparent; - color: #fff; + color: #28213a; font-size: 13px; text-align: left; cursor: pointer; } .ctx-item:hover:not(:disabled) { - background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.2); + background: rgba(var(--mg-primary-rgb, 117, 67, 232), 0.1); + color: var(--mg-primary, #7543e8); } .ctx-item:disabled { - color: rgba(255, 255, 255, 0.35); + color: #b8b0c8; cursor: not-allowed; } diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue index 6b5528c..7f2fac1 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue @@ -212,6 +212,11 @@ import { OPS_WHITELIST, type OpsAction } from '@/types/ops' import { executeOp } from '@/api/ops' import { useAuthStore } from '@/stores/auth' import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache' +import { + extractSocFromFieldMap, + extractSocFromKv, + socToPercent +} from '@/utils/batterySoc' import type { Car } from '@/types/car' import type { Mission } from '@/types/mission' import type { SelectedObjectRef } from '@/types/workbench' @@ -374,16 +379,13 @@ function carHasOccupiedTag(): boolean { } function formatBatteryText(): { text: string; low: boolean } { + // 真实车电量在反射 status「车体_Soc」;投影 batterySoc 常被写死成 0.8,不能优先用。 + const fromStatus = extractSocFromKv(statusRows.value) + const fromFields = extractSocFromFieldMap(fieldMap.value) const car = findCarInList() - const raw = pickField('batterySoc', 'battery', '电量') - let pct: number | null = null - if (raw) { - const n = Number(raw) - if (Number.isFinite(n)) pct = n <= 1 ? Math.round(n * 100) : Math.round(n) - } else if (car != null) { - pct = Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc) - } - if (pct == null) return { text: '—', low: false } + const ratio = fromStatus ?? fromFields ?? (car != null && car.batterySoc > 0 ? car.batterySoc : null) + if (ratio == null || !Number.isFinite(ratio) || ratio <= 0) return { text: '—', low: false } + const pct = socToPercent(ratio) return { text: `${pct}%`, low: pct < 20 } } @@ -593,12 +595,10 @@ function applyCarListPreview(): boolean { bundleName.value = car.name bundleTypeName.value = car.typeName ?? '' bundleFullTypeName.value = car.typeName ?? '' - const pct = Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc) fieldMap.value = { x: String(car.x), y: String(car.y), - th: String(car.theta), - batterySoc: String(pct) + th: String(car.theta) } statusRows.value = [] allMethods.value = [] @@ -906,11 +906,11 @@ watch( /* fame-lavender 浅紫主题:goto(前往站点)按钮在白底下需实色琥珀 + 白字, 否则半透明棕底叠白底→浅棕 + 白字不可读。 */ -:root[data-theme="fame-lavender"] .action-btn--goto { +:root[data-shell="outpost"] .action-btn--goto { border-color: #b45309; background: #b45309; } -:root[data-theme="fame-lavender"] .action-btn--goto:hover:not(:disabled) { +:root[data-shell="outpost"] .action-btn--goto:hover:not(:disabled) { border-color: #92400e; background: #92400e; } diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue index c0005a6..ec2f4e1 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue @@ -103,6 +103,7 @@ import { getSelectionDetail } from '@/api/workbench' import { getConfig } from '@/api/config' import { reflectionApi, + formatReflectionExecuteMessage, type ReflectionKv, type ReflectionKind, type ReflectionMethod @@ -399,9 +400,7 @@ async function onExecute(m: ReflectionMethod) { executing.value = m.methodName try { const r = await reflectionApi.execute(rk, idNum, m.methodName, params) - ElMessage.success( - r.returnValue ? `执行成功:${r.returnValue}` : `已执行 ${m.label || m.methodName}` - ) + ElMessage.success(formatReflectionExecuteMessage(m.label || m.methodName, r)) } catch (e) { ElMessage.error(`执行失败:${(e as Error).message}`) } finally { diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue index 400721d..8c92f4b 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue @@ -1,6 +1,6 @@ - - + + {{ cars.length }} 总数 @@ -28,10 +28,11 @@ v-model="search" size="small" clearable - placeholder="搜索 ID / 名称 / 任务" + :placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'" class="search" /> - + + + {{ chip.label }} + {{ chip.count }} + + + + + + 车辆编号 + 状态 + 电量 + 当前位置 + + + + {{ shortId(car) }} + + + {{ stateLabel(car.state) }} + + {{ batteryText(car) }} + {{ positionText(car) }} + + 无匹配车辆 + + + + () const emit = defineEmits<{ @@ -128,6 +177,8 @@ const emit = defineEmits<{ const search = ref('') const filterState = ref('') +type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault' +const quickFilter = ref('all') const stateOptions: { value: CarState; label: string }[] = [ { value: 'idle', label: '空闲' }, @@ -206,6 +257,16 @@ const onlineCount = computed(() => props.cars.filter((c) => c.state !== 'offline const runningCount = computed(() => props.cars.filter((c) => c.state === 'running').length) const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length) const faultCount = computed(() => props.cars.filter((c) => c.state === 'fault').length) +const idleCount = computed(() => props.cars.filter((c) => c.state === 'idle').length) +const offlineCount = computed(() => props.cars.filter((c) => c.state === 'offline').length) + +const filterChips = computed(() => [ + { key: 'all' as const, label: '全部', count: props.cars.length }, + { key: 'running' as const, label: '运行中', count: runningCount.value }, + { key: 'idle' as const, label: '空闲', count: idleCount.value }, + { key: 'offline' as const, label: '离线', count: offlineCount.value }, + { key: 'fault' as const, label: '异常', count: faultCount.value } +]) const missionByCarId = computed(() => { const order = (s: MissionStatus) => @@ -232,10 +293,16 @@ function missionForCar(car: Car): Mission | undefined { function batteryPct(car: Car): number { const raw = car.batterySoc ?? 0 + if (!raw) return 0 const pct = raw > 1 ? raw : raw * 100 return Math.max(0, Math.min(100, Math.round(pct))) } +function batteryText(car: Car): string { + const pct = batteryPct(car) + return pct > 0 ? `${pct}%` : '—' +} + function batteryColor(p: number): string { if (p < 20) return '#f56c6c' if (p < 50) return '#e6a23c' @@ -248,11 +315,29 @@ function batteryLevel(p: number): 'low' | 'mid' | 'high' { return 'high' } +function shortId(car: Car): string { + if (car.rawId != null) return String(car.rawId).padStart(3, '0').slice(-3) + const digits = car.id.replace(/\D/g, '') + return digits ? digits.slice(-3) : car.id +} + +function positionText(car: Car): string { + if (!Number.isFinite(car.x) || !Number.isFinite(car.y)) return '-' + return `${Math.round(car.x)}, ${Math.round(car.y)}` +} + const filteredCars = computed(() => { const q = search.value.trim().toLowerCase() const tokens = q ? q.split(/\s+/).filter(Boolean) : [] return props.cars.filter((c) => { - if (filterState.value && c.state !== filterState.value) return false + if (props.table) { + if (quickFilter.value === 'running' && c.state !== 'running') return false + if (quickFilter.value === 'idle' && c.state !== 'idle') return false + if (quickFilter.value === 'offline' && c.state !== 'offline') return false + if (quickFilter.value === 'fault' && c.state !== 'fault') return false + } else if (filterState.value && c.state !== filterState.value) { + return false + } if (!tokens.length) return true const mission = missionForCar(c) const hay = [ @@ -263,7 +348,8 @@ const filteredCars = computed(() => { c.ip ?? '', stateLabel(c.state), mission?.name ?? '', - mission ? missionStatusLabel(mission.status) : '' + mission ? missionStatusLabel(mission.status) : '', + positionText(c) ] .join(' ') .toLowerCase() @@ -290,6 +376,24 @@ function detailIdForCar(car: Car): string { height: 100%; overflow: hidden; } +.vehicle-monitor.is-compact { gap: 8px; } +.vehicle-monitor.is-table { gap: 8px; } + +.vehicle-monitor.is-compact .vehicle-row { + border-radius: 8px; + border-color: transparent; + background: transparent; + padding: 8px 10px; +} +.vehicle-monitor.is-compact .vehicle-row:hover { + background: var(--mg-veil-2); + border-color: transparent; +} +.vehicle-monitor.is-compact .vehicle-row.is-selected { + background: rgba(var(--mg-primary-rgb), 0.1); + border-color: transparent; + box-shadow: inset 3px 0 0 var(--mg-primary); +} .overview-row { display: grid; @@ -315,10 +419,10 @@ function detailIdForCar(car: Car): string { color: var(--mg-text-light); line-height: 1.2; } -.overview-num.online { color: var(--mg-accent); text-shadow: 0 0 12px rgba(var(--mg-accent-rgb), 0.55); } -.overview-num.running { color: var(--mg-status-success); text-shadow: 0 0 12px rgba(var(--mg-status-success-rgb), 0.45); } -.overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); } -.overview-num.fault { color: var(--mg-status-danger); text-shadow: 0 0 12px rgba(var(--mg-status-danger-rgb), 0.5); } +.overview-num.online { color: var(--mg-accent); } +.overview-num.running { color: var(--mg-status-success); } +.overview-num.charging { color: var(--mg-primary-hover); } +.overview-num.fault { color: var(--mg-status-danger); } .overview-label { font-size: 11px; color: var(--mg-text-muted); @@ -333,6 +437,143 @@ function detailIdForCar(car: Car): string { .toolbar .search { flex: 1; min-width: 0; } .toolbar .filter { width: 100px; flex: none; } +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex: none; +} +.chip { + appearance: none; + border: 1px solid var(--mg-veil-border); + background: transparent; + color: var(--mg-text-muted); + font-size: 11px; + font-weight: 550; + height: 26px; + padding: 0 8px; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease; +} +.chip:hover { + color: var(--mg-text-light); + border-color: var(--mg-veil-border-hi); +} +.chip.is-active { + background: rgba(var(--mg-primary-rgb), 0.12); + border-color: rgba(var(--mg-primary-rgb), 0.45); + color: var(--mg-primary); +} +.chip-n { + font-family: var(--mg-font-mono); + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.table-wrap { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.table-head, +.table-row { + display: grid; + grid-template-columns: 56px 64px 48px minmax(0, 1fr); + gap: 6px; + align-items: center; + padding: 0 8px; +} +.table-head { + flex: none; + height: 28px; + font-size: 11px; + font-weight: 600; + color: var(--mg-text-muted); + border-bottom: 1px solid var(--mg-veil-border); +} +.table-body { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + gap: 0; + padding-right: 0; +} +.table-row { + appearance: none; + width: 100%; + border: 0; + border-bottom: 1px solid var(--mg-veil-border); + background: transparent; + color: var(--mg-text-light); + text-align: left; + height: 40px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s ease; +} +.table-row:hover { + background: var(--mg-veil-1); +} +.table-row.is-selected { + background: rgba(var(--mg-primary-rgb), 0.1); + box-shadow: inset 3px 0 0 var(--mg-primary); +} +.col-id { + font-family: var(--mg-font-mono); + font-variant-numeric: tabular-nums; + font-weight: 650; + font-size: 12px; +} +.col-state { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--mg-text-muted); + white-space: nowrap; +} +.col-bat { + font-family: var(--mg-font-mono); + font-variant-numeric: tabular-nums; + font-size: 11px; + font-weight: 600; +} +.col-bat.low { color: var(--mg-status-danger); } +.col-bat.mid { color: var(--mg-status-warning); } +.col-bat.high { color: var(--mg-status-success); } +.col-pos { + font-size: 11px; + color: var(--mg-text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex: none; + background: currentColor; +} +.state-dot.dot-running, +.dot.dot-running { color: var(--mg-status-success); background: var(--mg-status-success); } +.state-dot.dot-idle, +.dot.dot-idle { color: var(--mg-status-info); background: var(--mg-status-info); } +.state-dot.dot-offline, +.dot.dot-offline { color: var(--mg-text-faint); background: var(--mg-text-faint); } +.state-dot.dot-fault, +.dot.dot-fault { color: var(--mg-status-danger); background: var(--mg-status-danger); } +.state-dot.dot-charging, +.dot.dot-charging { color: var(--mg-primary); background: var(--mg-primary); } +.state-dot.dot-paused, +.dot.dot-paused { color: var(--mg-status-warning); background: var(--mg-status-warning); } + .vehicle-list { flex: 1 1 0; min-height: 0; @@ -400,17 +641,12 @@ function detailIdForCar(car: Car): string { border-radius: 50%; background: currentColor; } -.dot-running { color: #6fcd45; box-shadow: 0 0 8px #6fcd45; animation: pulse 1.5s infinite; } -.dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); } -.dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; } -.dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; } +.dot-running { color: #6fcd45; } +.dot-charging { color: var(--mg-primary-hover); } +.dot-paused { color: #e6a23c; } +.dot-fault { color: #ff7396; } .dot-offline { color: var(--mg-text-faint); } -.dot-idle { color: var(--mg-accent); box-shadow: 0 0 6px rgba(var(--mg-accent-rgb), 0.6); } - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.35; } -} +.dot-idle { color: var(--mg-accent); } .vehicle-sub { display: flex; diff --git a/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue b/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue index f1b61de..d17b443 100644 --- a/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue +++ b/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue @@ -249,6 +249,16 @@ const props = withDefaults( { followCarId: null } ) +const emit = defineEmits<{ + (e: 'state-changed', state: ToolbarState): void + (e: 'follow-changed', payload: { enabled: boolean; carId: number | null }): void +}>() + +function publishState(next: ToolbarState | null) { + if (!next) return + emit('state-changed', next) +} + /** * 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。 * @@ -318,6 +328,7 @@ const followCarId = computed(() => { async function refresh() { try { state.value = await workspaceToolbarApi.getState() + publishState(state.value) } catch (err) { ElMessage.error(`同步工具栏状态失败:${(err as Error).message}`) } @@ -328,6 +339,7 @@ async function toggleAlign(key: AlignKey) { busy.align = true try { state.value = await workspaceToolbarApi.setAlign(key, !state.value.align[key]) + publishState(state.value) } catch (err) { ElMessage.error(`切换对齐失败:${(err as Error).message}`) } finally { @@ -340,6 +352,7 @@ async function toggleSelect(key: SelectKey) { busy.select = true try { state.value = await workspaceToolbarApi.setSelect(key, !state.value.select[key]) + publishState(state.value) } catch (err) { ElMessage.error(`切换选择过滤失败:${(err as Error).message}`) } finally { @@ -352,6 +365,7 @@ async function toggleDisplay(key: DisplayKey) { busy.display = true try { state.value = await workspaceToolbarApi.setDisplay(key, !state.value.display[key]) + publishState(state.value) } catch (err) { ElMessage.error(`切换显示内容失败:${(err as Error).message}`) } finally { @@ -363,6 +377,7 @@ async function toggleLayer(name: string, visible: boolean) { busy.layer = true try { state.value = await workspaceToolbarApi.setLayerVisibility(name, visible) + publishState(state.value) } catch (err) { ElMessage.error(`切换图层失败:${(err as Error).message}`) } finally { @@ -467,6 +482,8 @@ async function toggleCameraFollow() { try { const enable = !cameraFollowing.value state.value = await workspaceToolbarApi.setCameraFollow(carId, enable) + publishState(state.value) + emit('follow-changed', { enabled: enable, carId: enable ? carId : null }) if (enable) { ElMessage.success({ message: `已开始跟随车辆 #${carId}`, duration: 1500, grouping: true }) } else { diff --git a/frontends/apps/simple-platform-vue/src/composables/useAssistantChat.ts b/frontends/apps/simple-platform-vue/src/composables/useAssistantChat.ts new file mode 100644 index 0000000..cc1250e --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useAssistantChat.ts @@ -0,0 +1,227 @@ +import { ref, computed } from 'vue' +import { + streamChat, + listSessions, + getHistory, + deleteSession, + listTools, + type AssistantSessionMeta +} from '@/api/assistant' + +export interface ToolInvocation { + name: string + isWrite: boolean + args?: unknown + status: 'running' | 'ok' | 'error' + result?: unknown +} + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string + tools: ToolInvocation[] + streaming?: boolean + error?: string | null +} + +const CURRENT_KEY = 'assistant.currentSessionId' + +function uid(): string { + return (globalThis.crypto?.randomUUID?.() ?? `id-${Date.now()}-${Math.random().toString(36).slice(2)}`) +} + +/** + * AI 助手对话状态机:维护消息列表 + 会话列表,封装发送/中断/历史加载/会话切换。 + * 单例式(模块级状态),让抽屉在路由切换后仍保留当前对话。 + */ +const messages = ref([]) +const sessions = ref([]) +const currentSessionId = ref(localStorage.getItem(CURRENT_KEY)) +const status = ref<'idle' | 'streaming'>('idle') +const toolIsWrite = ref>({}) +let initialized = false +let abort: AbortController | null = null + +const isStreaming = computed(() => status.value === 'streaming') +const canSend = computed(() => status.value === 'idle') + +function persistCurrent(): void { + try { + if (currentSessionId.value) localStorage.setItem(CURRENT_KEY, currentSessionId.value) + else localStorage.removeItem(CURRENT_KEY) + } catch { + /* ignore */ + } +} + +async function refreshSessions(): Promise { + try { + sessions.value = await listSessions() + } catch { + /* 列表失败不阻塞对话 */ + } +} + +async function ensureInit(): Promise { + if (initialized) return + initialized = true + try { + const tools = await listTools() + const map: Record = {} + for (const t of tools) map[t.name] = t.isWrite + toolIsWrite.value = map + } catch { + /* 工具清单失败:tool 卡片仍可展示,仅缺少读写标识 */ + } + await refreshSessions() + if (currentSessionId.value) await loadSession(currentSessionId.value) +} + +async function loadSession(id: string): Promise { + if (status.value === 'streaming') return + try { + const h = await getHistory(id) + const list: ChatMessage[] = [] + const turns = h.turns ?? [] + for (let i = 0; i < turns.length; i++) { + const t = turns[i] + if (t.role === 'user') { + list.push({ id: uid(), role: 'user', content: t.content ?? '', tools: [] }) + } else if (t.role === 'assistant') { + const tools: ToolInvocation[] = [] + const calls = t.toolCalls ?? [] + for (const c of calls) { + // 历史中工具结果是紧随其后的 tool 轮,按顺序取回。 + let result: unknown + const next = turns[i + 1] + if (next && next.role === 'tool') { + try { + result = JSON.parse(next.content ?? 'null') + } catch { + result = next.content + } + i++ + } + let args: unknown + try { + args = JSON.parse(c.arguments || '{}') + } catch { + args = c.arguments + } + tools.push({ name: c.name, isWrite: toolIsWrite.value[c.name] ?? false, args, status: 'ok', result }) + } + list.push({ id: uid(), role: 'assistant', content: t.content ?? '', tools }) + } + // 落单的 tool 轮(理论上已被上面消费)跳过。 + } + messages.value = list + currentSessionId.value = id + persistCurrent() + } catch { + /* ignore */ + } +} + +function newSession(): void { + if (status.value === 'streaming') stop() + messages.value = [] + currentSessionId.value = null + persistCurrent() +} + +async function removeSession(id: string): Promise { + try { + await deleteSession(id) + } catch { + /* ignore */ + } + await refreshSessions() + if (currentSessionId.value === id) newSession() +} + +async function send(text: string): Promise { + const msg = text.trim() + if (!msg || status.value === 'streaming') return + + messages.value.push({ id: uid(), role: 'user', content: msg, tools: [] }) + const assistant: ChatMessage = { id: uid(), role: 'assistant', content: '', tools: [], streaming: true, error: null } + messages.value.push(assistant) + status.value = 'streaming' + abort = new AbortController() + + await streamChat( + { message: msg, sessionId: currentSessionId.value, profile: 'analysis' }, + { + onSession: (sid) => { + if (sid) { + currentSessionId.value = sid + persistCurrent() + } + }, + onToken: (delta) => { + assistant.content += delta + }, + onToolCall: (name, args) => { + assistant.tools.push({ name, isWrite: toolIsWrite.value[name] ?? false, args, status: 'running' }) + }, + onToolResult: (name, ok, result) => { + const card = [...assistant.tools].reverse().find((t) => t.name === name && t.status === 'running') + if (card) { + card.status = ok ? 'ok' : 'error' + card.result = result + } else { + assistant.tools.push({ name, isWrite: toolIsWrite.value[name] ?? false, status: ok ? 'ok' : 'error', result }) + } + }, + onError: (message) => { + assistant.error = message + }, + onDone: () => { + assistant.streaming = false + status.value = 'idle' + abort = null + void refreshSessions() + } + }, + abort.signal + ) + + // 兜底:若流意外结束未触发 done。 + if (assistant.streaming) { + assistant.streaming = false + status.value = 'idle' + abort = null + } +} + +function stop(): void { + if (abort) { + abort.abort() + abort = null + } + const last = messages.value[messages.value.length - 1] + if (last && last.role === 'assistant' && last.streaming) { + last.streaming = false + if (!last.content && last.tools.length === 0) last.content = '(已停止)' + } + status.value = 'idle' +} + +export function useAssistantChat() { + return { + messages, + sessions, + currentSessionId, + status, + isStreaming, + canSend, + ensureInit, + refreshSessions, + loadSession, + newSession, + removeSession, + send, + stop + } +} diff --git a/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts b/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts index 41bb84b..6eae460 100644 --- a/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts +++ b/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts @@ -12,11 +12,13 @@ import { ref, computed } from 'vue' */ export type EditToolId = - // 选择组 + // 选择组(左侧仅保留「仅*」过滤;单选/框选等由画布默认交互承担) | 'select.single' | 'select.rect' | 'select.lasso' | 'select.byType.site' + | 'select.byType.straight' + | 'select.byType.curve' | 'select.byType.bezier' | 'select.byType.polyline' | 'select.byType.arc' @@ -28,6 +30,7 @@ export type EditToolId = | 'draw.site' | 'draw.sites.continuous' | 'draw.track.polyline' + | 'draw.tracks.continuous' | 'draw.track.bezier' | 'draw.track.arc' | 'draw.track.nurbs' @@ -41,7 +44,6 @@ export type EditToolId = | 'transform.move' | 'transform.rotate' | 'transform.scale' - | 'transform.duplicate' | 'transform.copy' | 'transform.paste' | 'transform.delete' @@ -82,7 +84,8 @@ export interface SnapState { } export function useEditTool() { - const activeTool = ref('transform.move') + // 空闲默认:不落在已移除的「移动」上,侧栏无高亮(画布仍可点选)。 + const activeTool = ref('select.single') const snap = ref({ grid: false, gridSizeMm: 100, object: true, endpoint: true, midpoint: false, toAngleDeg: undefined }) const showControlPoints = ref(true) const showControlHandles = ref(true) @@ -105,17 +108,20 @@ export function useEditTool() { 'select.single': '单选', 'select.rect': '矩形框选', 'select.lasso': '圈选', - 'select.byType.site': '按类型选 - 站点', - 'select.byType.bezier': '按类型选 - 贝塞尔', - 'select.byType.polyline': '按类型选 - 折线', - 'select.byType.arc': '按类型选 - 弧', - 'select.byType.decor': '按类型选 - 装饰物', - 'select.byType.currentLayer': '按类型选 - 当前图层', + 'select.byType.site': '仅站点', + 'select.byType.straight': '仅直线', + 'select.byType.curve': '仅曲线', + 'select.byType.bezier': '仅曲线', + 'select.byType.polyline': '仅直线', + 'select.byType.arc': '仅曲线', + 'select.byType.decor': '仅装饰', + 'select.byType.currentLayer': '当前图层', 'select.invert': '反选', 'select.clear': '清空选中', 'draw.site': '添加站点', 'draw.sites.continuous': '连续添加站点', - 'draw.track.polyline': '添加折线路径', + 'draw.track.polyline': '添加直线路径', + 'draw.tracks.continuous': '连续添加直线路径', 'draw.track.bezier': '添加贝塞尔路径', 'draw.track.arc': '添加弧形路径', 'draw.track.nurbs': '添加 NURBS 路径', @@ -128,7 +134,6 @@ export function useEditTool() { 'transform.move': '移动', 'transform.rotate': '旋转', 'transform.scale': '缩放', - 'transform.duplicate': '复制副本', 'transform.copy': '复制', 'transform.paste': '粘贴', 'transform.delete': '删除', diff --git a/frontends/apps/simple-platform-vue/src/composables/useHistory.ts b/frontends/apps/simple-platform-vue/src/composables/useHistory.ts index 382ab39..f57cae3 100644 --- a/frontends/apps/simple-platform-vue/src/composables/useHistory.ts +++ b/frontends/apps/simple-platform-vue/src/composables/useHistory.ts @@ -98,5 +98,19 @@ export function useHistory(opts: UseHistoryOptions = {}) { } } - return { undoStack, redoStack, busy, canUndo, canRedo, run, undo, redo, clear, composite } + return { + undoStack, + redoStack, + busy, + canUndo, + canRedo, + maxDepth, + undoCount: computed(() => undoStack.value.length), + redoCount: computed(() => redoStack.value.length), + run, + undo, + redo, + clear, + composite + } } 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/composables/useVehicleCardState.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts index 56ba4ca..0e0d90d 100644 --- a/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts +++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts @@ -74,15 +74,17 @@ export function useVehicleCardState(opts: VehicleCardStateOptions) { const latencyLabel = computed(() => { const ms = vehicle.value.latencyMs + if (vehicle.value.reachable === false && ms == null) return '不可达' if (ms == null) return '—' - if (vehicle.value.reachable === false) return '超时' return `${ms} ms` }) const latencyClass = computed(() => { const ms = vehicle.value.latencyMs if (vehicle.value.reachable === false) return 'val-danger' - if (ms != null && ms > 80) return 'val-warn' + // WatchDog TCP RTT:局域网正常多在几十毫秒;>150 警示,>400 危险 + if (ms != null && ms > 400) return 'val-danger' + if (ms != null && ms > 150) return 'val-warn' return '' }) diff --git a/frontends/apps/simple-platform-vue/src/config/navMenu.ts b/frontends/apps/simple-platform-vue/src/config/navMenu.ts index 79bf9b0..1bfd9ae 100644 --- a/frontends/apps/simple-platform-vue/src/config/navMenu.ts +++ b/frontends/apps/simple-platform-vue/src/config/navMenu.ts @@ -1,7 +1,28 @@ import { - Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Files, - Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Odometer, - Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera, View + Bell, + Collection, + Connection, + Cpu, + Document, + DocumentCopy, + EditPen, + Files, + Histogram, + Link, + List, + MapLocation, + Monitor, + Notebook, + OfficeBuilding, + Operation, + Promotion, + SetUp, + Setting, + Tools, + User, + Van, + VideoCamera, + View } from '@element-plus/icons-vue' import type { Component } from 'vue' @@ -16,19 +37,26 @@ export interface NavMenuItem { export const ADMIN_MENU: NavMenuItem[] = [ { path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' }, - { path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' }, { - path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排', + path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览', children: [ - { path: '/admin/maps', label: '地图管理', icon: Files, key: 'admin-maps', group: '设计与编排' }, - { path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' }, - { path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' }, - { path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' }, - { path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编排' }, - { path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编排' }, - { path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编排' }, - { path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' }, - { path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编排' } + { path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' }, + { path: '/admin/tasks', label: '任务管理', icon: List, key: 'admin-tasks', group: '概览' }, + { path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' } + ] + }, + { + path: '/admin/design', label: '设计与编辑', icon: Tools, group: '设计与编辑', + children: [ + { path: '/admin/maps', label: '地图管理', icon: Files, key: 'admin-maps', group: '设计与编辑' }, + { path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编辑' }, + { path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编辑' }, + { path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编辑' }, + { path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编辑' }, + { path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编辑' }, + { path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编辑' }, + { path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编辑' }, + { path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' } ] }, { diff --git a/frontends/apps/simple-platform-vue/src/config/quickEntries.ts b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts index 3222ae3..a71ae8a 100644 --- a/frontends/apps/simple-platform-vue/src/config/quickEntries.ts +++ b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts @@ -22,7 +22,7 @@ export interface QuickEntryDef { /** 当前页即总览,不作为快捷入口候选 */ const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard']) -/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */ +/** 旧版别名 key -> 菜单 key */ const LEGACY_KEY_ALIASES: Record = { 'platform-config': 'admin-map-editor', mission: 'admin-task-templates', @@ -33,7 +33,7 @@ const LEGACY_KEY_ALIASES: Record = { tasks: 'admin-config-strategy' } -/** Platform 域下固定保留、不可删除的快捷入口(顺序即展示优先级) */ +/** Platform 域下固定保留、不可删除的快捷入口 */ export const MANDATORY_PLATFORM_QUICK_KEYS = [ 'admin-maps', 'admin-map-editor', @@ -43,7 +43,7 @@ export const MANDATORY_PLATFORM_QUICK_KEYS = [ 'admin-task-templates' ] as const -/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */ +/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐 */ export const DEFAULT_PLATFORM_QUICK_KEYS = [ ...MANDATORY_PLATFORM_QUICK_KEYS, 'admin-config-system-center', @@ -76,7 +76,7 @@ export function isMandatoryQuickKey(key: string, scope: Scope): boolean { return (MANDATORY_PLATFORM_QUICK_KEYS as readonly string[]).includes(normalizeQuickKey(key)) } -/** 保证固定四项始终存在且排在最前(其余项保持原顺序) */ +/** 保证固定项始终存在且排在最前 */ export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] { const normalized = normalizeQuickKeys(keys) if (scope !== 'Platform') return normalized diff --git a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue index 26ba643..24525c2 100644 --- a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue +++ b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue @@ -53,7 +53,7 @@ @@ -82,6 +82,15 @@ 配置向导 服务状态 + 高级 · 兼容主题 + + + {{ t.name }} + 退出登录 @@ -97,10 +106,13 @@ - {{ scopeLabel }} · webVRender :{{ vrPort }} · API {{ apiBase }} + {{ scopeLabel }} · Projection :{{ slPort }} · API {{ apiBase }} + + + @@ -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/mock/data/alarms.ts b/frontends/apps/simple-platform-vue/src/mock/data/alarms.ts new file mode 100644 index 0000000..f12f89d --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/mock/data/alarms.ts @@ -0,0 +1,27 @@ +import type { VehicleAlarm } from '@/types/alarm' + +function minsAgo(m: number): string { + return new Date(Date.now() - m * 60_000).toISOString() +} + +export async function mockAlarms(): Promise { + await new Promise((r) => setTimeout(r, 60)) + return [ + { + id: 'a1', carId: 309, carName: 'AGV-309', info: '导航失联', level: 2, + status: 'active', firstAt: minsAgo(6), lastAt: minsAgo(0), resolvedAt: null, durationSecs: null, acknowledged: false + }, + { + id: 'a2', carId: 415, carName: 'Kiva-415', info: '急停触发', level: 3, + status: 'active', firstAt: minsAgo(2), lastAt: minsAgo(0), resolvedAt: null, durationSecs: null, acknowledged: false + }, + { + id: 'a3', carId: 572, carName: 'AGV-572', info: '电量低', level: 1, + status: 'cleared', firstAt: minsAgo(120), lastAt: minsAgo(95), resolvedAt: minsAgo(95), durationSecs: 1500, acknowledged: false + }, + { + id: 'a4', carId: 888, carName: 'Kiva-888', info: '放货点被占用', level: 1, + status: 'cleared', firstAt: minsAgo(1440), lastAt: minsAgo(1420), resolvedAt: minsAgo(1420), durationSecs: 1200, acknowledged: false + } + ] +} diff --git a/frontends/apps/simple-platform-vue/src/mock/data/deliveries.ts b/frontends/apps/simple-platform-vue/src/mock/data/deliveries.ts index 85447bf..65cac01 100644 --- a/frontends/apps/simple-platform-vue/src/mock/data/deliveries.ts +++ b/frontends/apps/simple-platform-vue/src/mock/data/deliveries.ts @@ -1,13 +1,18 @@ import type { DeliveryTask } from '@/types/delivery' +function hoursAgo(h: number): string { + return new Date(Date.now() - h * 3600_000).toISOString() +} + export async function mockDeliveries(): Promise { await new Promise((r) => setTimeout(r, 60)) return [ { - id: 101, + id: 'a7Kp3', + taskId: 'WMS-20260720-001', missionId: 1, - missionName: '链式搬运', - missionTypeName: 'FengTianChainedDeliveryMission', + missionName: '搬运任务进程', + missionTypeName: 'TransportMission', srcSiteId: 1, srcLabel: '1-取货台 A', dstSiteId: 8, @@ -17,14 +22,18 @@ export async function mockDeliveries(): Promise { carId: 1, carName: 'AGV-01', priority: 1, - createTime: new Date().toISOString(), + createTime: hoursAgo(0.5), + startTime: hoursAgo(0.4), + finishTime: null, + stuckReason: null, overdue: false }, { - id: 102, + id: 'b2Xq9', + taskId: null, missionId: 1, - missionName: '链式搬运', - missionTypeName: 'FengTianChainedDeliveryMission', + missionName: '搬运任务进程', + missionTypeName: 'TransportMission', srcSiteId: 3, srcLabel: '3-缓存区', dstSiteId: 12, @@ -34,14 +43,39 @@ export async function mockDeliveries(): Promise { carId: null, carName: null, priority: 0, - createTime: new Date(Date.now() - 45 * 60_000).toISOString(), + createTime: hoursAgo(0.8), + startTime: null, + finishTime: null, + stuckReason: '无可用车辆', overdue: true }, { - id: 99, + id: 'c9Lm4', + taskId: 'WMS-20260720-003', missionId: 1, - missionName: '链式搬运', - missionTypeName: 'FengTianChainedDeliveryMission', + missionName: '搬运任务进程', + missionTypeName: 'TransportMission', + srcSiteId: 4, + srcLabel: '4-线边库', + dstSiteId: 9, + dstLabel: '9-包装台', + status: '放货中', + statusCode: 'Putting', + carId: 3, + carName: 'Kiva-03', + priority: 2, + createTime: hoursAgo(1.2), + startTime: hoursAgo(1.0), + finishTime: null, + stuckReason: null, + overdue: false + }, + { + id: 'd1Nb7', + taskId: 'WMS-20260719-088', + missionId: 1, + missionName: '搬运任务进程', + missionTypeName: 'TransportMission', srcSiteId: 2, srcLabel: '2-原料区', dstSiteId: 5, @@ -51,8 +85,53 @@ export async function mockDeliveries(): Promise { carId: 2, carName: 'AGV-02', priority: 1, - createTime: new Date(Date.now() - 3600_000).toISOString(), + createTime: hoursAgo(6), + startTime: hoursAgo(5.8), + finishTime: hoursAgo(5.4), + stuckReason: null, overdue: false + }, + { + id: 'e5Rt2', + taskId: null, + missionId: 1, + missionName: '搬运任务进程', + missionTypeName: 'TransportMission', + srcSiteId: 6, + srcLabel: '6-暂存', + dstSiteId: 7, + dstLabel: '7-发运', + status: '已取消', + statusCode: 'Canceled', + carId: 4, + carName: 'Kiva-04', + priority: 0, + createTime: hoursAgo(26), + startTime: null, + finishTime: hoursAgo(25.5), + stuckReason: null, + overdue: false + }, + { + id: 'f8Wy6', + taskId: 'WMS-20260720-077', + missionId: 1, + missionName: '搬运任务进程', + missionTypeName: 'TransportMission', + srcSiteId: 10, + srcLabel: '10-入库口', + dstSiteId: 11, + dstLabel: '11-立体库', + status: '任务异常', + statusCode: 'Error', + carId: 5, + carName: 'Kiva-05', + priority: 3, + createTime: hoursAgo(3), + startTime: hoursAgo(2.8), + finishTime: null, + stuckReason: '放货点被占用', + overdue: true } ] } diff --git a/frontends/apps/simple-platform-vue/src/mock/rbac.ts b/frontends/apps/simple-platform-vue/src/mock/rbac.ts index 6c34632..9a6ddf6 100644 --- a/frontends/apps/simple-platform-vue/src/mock/rbac.ts +++ b/frontends/apps/simple-platform-vue/src/mock/rbac.ts @@ -12,6 +12,8 @@ import type { const PAGES: PageDef[] = [ { key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' }, { key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' }, + { key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' }, + { key: 'admin-alarms', label: '报警管理', group: '概览', scope: 'Platform' }, { key: 'admin-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' }, { key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' }, { key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' }, diff --git a/frontends/apps/simple-platform-vue/src/router/index.ts b/frontends/apps/simple-platform-vue/src/router/index.ts index 0dc1469..4521811 100644 --- a/frontends/apps/simple-platform-vue/src/router/index.ts +++ b/frontends/apps/simple-platform-vue/src/router/index.ts @@ -30,6 +30,8 @@ const routes: RouteRecordRaw[] = [ children: [ { path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } }, { path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } }, + { path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } }, + { path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } }, { path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.vue'), meta: { title: '地图管理' } }, { path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } }, { path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } }, @@ -51,8 +53,8 @@ const routes: RouteRecordRaw[] = [ // ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ── { path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } }, { path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' }, - { path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'maintenance' } } }, - { path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'fleet' } } }, + { path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } }, + { path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } }, { path: 'config/routing', redirect: { path: '/admin/config/strategy', query: { tab: 'routing' } } }, { path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } }, { path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } }, @@ -137,7 +139,8 @@ router.beforeEach(async (to) => { // 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。 // 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。 const needScope: 'Platform' | 'RCSMonitor' | null = - to.path.startsWith('/admin') ? 'Platform' + to.path.startsWith('/admin') + ? 'Platform' : to.path.startsWith('/monitor') ? 'RCSMonitor' : null if (needScope && auth.scope !== needScope) { @@ -153,7 +156,8 @@ router.beforeEach(async (to) => { // RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。 // 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。 const name = typeof to.name === 'string' ? to.name : '' - if (name && MANAGED_PAGE_NAMES.has(name) && !auth.hasPage(name)) { + const pageKey = typeof to.meta.pageKey === 'string' ? to.meta.pageKey : name + if (pageKey && MANAGED_PAGE_NAMES.has(pageKey) && !auth.hasPage(pageKey)) { const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES const fallback = list.find((p) => auth.hasPage(p.name))?.path if (fallback && fallback !== to.path) { 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/types/alarm.ts b/frontends/apps/simple-platform-vue/src/types/alarm.ts new file mode 100644 index 0000000..fa68410 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/types/alarm.ts @@ -0,0 +1,24 @@ +/** 车辆报警记录(平台侧 vehicle_alarms 投影) */ +export interface VehicleAlarm { + id: string + carId: number + carName: string + /** 报警文案(车体_AlarmInfo) */ + info: string + /** 报警级别(车体_AlarmLevel,未知 0) */ + level: number + /** active | cleared */ + status: string + firstAt: string + lastAt: string + resolvedAt?: string | null + durationSecs?: number | null + acknowledged?: boolean +} + +/** 报警订阅结果:来自平台库(离线可读 + 历史保留)。 */ +export interface AlarmFeed { + online: boolean + lastSyncAt: string | null + alarms: VehicleAlarm[] +} diff --git a/frontends/apps/simple-platform-vue/src/types/car.ts b/frontends/apps/simple-platform-vue/src/types/car.ts index 5773369..0eb0276 100644 --- a/frontends/apps/simple-platform-vue/src/types/car.ts +++ b/frontends/apps/simple-platform-vue/src/types/car.ts @@ -25,7 +25,7 @@ export interface Car { lstatus?: string } -/** 车队健康探测行(GET /sl/projection/fleet/health) */ +/** 车队健康探测行(优先 GET /api/fleet/health;延迟为 WatchDog TCP RTT) */ export interface FleetHealthRow { carId: number carName?: string @@ -40,6 +40,8 @@ export interface FleetHealthRow { isAlarmActive?: boolean cpuPercent?: number memPercent?: number + /** watchdog | onboard | none */ + latencySource?: string } /** 车辆运维卡片合并模型 */ diff --git a/frontends/apps/simple-platform-vue/src/types/delivery.ts b/frontends/apps/simple-platform-vue/src/types/delivery.ts index b71f0ec..bed9e5e 100644 --- a/frontends/apps/simple-platform-vue/src/types/delivery.ts +++ b/frontends/apps/simple-platform-vue/src/types/delivery.ts @@ -1,6 +1,9 @@ /** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */ export interface DeliveryTask { - id: number + /** 内部任务号(StandardScene 为 Base62 雪花串) */ + id: string + /** 外部系统单号(可空) */ + taskId?: string | null missionId: number missionName: string missionTypeName: string @@ -14,7 +17,25 @@ export interface DeliveryTask { carName?: string | null priority: number createTime?: string | null + startTime?: string | null + finishTime?: string | null + stuckReason?: string | null overdue?: boolean } -export type DeliveryAction = 'cancel' | 'resend' | 'force-complete' +export type DeliveryAction = + | 'cancel' + | 'resend' + | 'force-complete' + | 'pause' + | 'resume' + | 'change-car' + +/** 新建搬运任务入参 */ +export interface CreateDeliveryPayload { + src: number + dst: number + priority?: number + taskId?: string + carType?: string +} 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/utils/batterySoc.ts b/frontends/apps/simple-platform-vue/src/utils/batterySoc.ts new file mode 100644 index 0000000..03f1f08 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/batterySoc.ts @@ -0,0 +1,58 @@ +/** 从反射 KV / 投影数值解析电量,统一成 0–1 比例(未知返回 null)。 */ + +const SOC_KEY_RE = /(^|_)(Soc|SOC|batterySoc|BatterySoc|电量)$/i + +export function parseSocNumber(raw: unknown): number | null { + if (raw == null || raw === '') return null + if (typeof raw === 'number') { + if (!Number.isFinite(raw)) return null + return normalizeSocRatio(raw) + } + const s = String(raw).trim().replace(/%$/, '') + const n = Number(s) + if (!Number.isFinite(n)) return null + return normalizeSocRatio(n) +} + +/** 接受 0–1 或 0–100,输出 0–1。 */ +export function normalizeSocRatio(n: number): number { + const ratio = n > 1 ? n / 100 : n + return Math.max(0, Math.min(1, ratio)) +} + +export function socToPercent(ratio: number): number { + return Math.max(0, Math.min(100, Math.round(ratio <= 1 ? ratio * 100 : ratio))) +} + +export function extractSocFromKv( + rows: Array<{ key?: string | null; value?: string | null }> | null | undefined +): number | null { + if (!rows?.length) return null + const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量'] + for (const key of preferred) { + const hit = rows.find((r) => r.key === key) + const parsed = parseSocNumber(hit?.value) + if (parsed != null) return parsed + } + for (const r of rows) { + if (!r.key || !SOC_KEY_RE.test(r.key)) continue + const parsed = parseSocNumber(r.value) + if (parsed != null) return parsed + } + return null +} + +export function extractSocFromFieldMap(map: Record | null | undefined): number | null { + if (!map) return null + const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量', 'battery'] + for (const key of preferred) { + const parsed = parseSocNumber(map[key]) + if (parsed != null) return parsed + } + for (const [key, value] of Object.entries(map)) { + if (!SOC_KEY_RE.test(key)) continue + const parsed = parseSocNumber(value) + if (parsed != null) return parsed + } + return null +} diff --git a/frontends/apps/simple-platform-vue/src/utils/carRuntime.ts b/frontends/apps/simple-platform-vue/src/utils/carRuntime.ts new file mode 100644 index 0000000..2bd7df3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/carRuntime.ts @@ -0,0 +1,82 @@ +import type { Car, CarState } from '@/types/car' +import { extractSocFromKv, normalizeSocRatio, parseSocNumber } from '@/utils/batterySoc' + +type Kv = { key?: string | null; value?: string | null } + +function kv(rows: Kv[] | null | undefined, ...keys: string[]): string { + if (!rows?.length) return '' + for (const key of keys) { + const hit = rows.find((r) => r.key === key) + if (hit?.value != null && hit.value !== '') return String(hit.value) + } + return '' +} + +/** + * SimpleLite /projection/cars 会把「正常但未初始化」「导航失联」等一律标成 fault, + * 与地图标签/车体 AlarmLevel 不一致。这里用 lstatus + 车体 status 重新推导。 + */ +export function deriveCarState(car: Pick, statusRows?: Kv[] | null): CarState { + const lstatus = (car.lstatus ?? '').trim() + const alarmLevelRaw = kv(statusRows, '车体_AlarmLevel', 'AlarmLevel') + const alarmInfo = kv(statusRows, '车体_AlarmInfo', 'AlarmInfo').trim() + const riskAlarm = kv(statusRows, '车体_RiskPositionAlarm', 'RiskPositionAlarm') + const drive = kv(statusRows, '车体_driveStatus', 'driveStatus') + const openCharge = kv(statusRows, '车体_OpenChargeByClumsy', 'OpenChargeByClumsy') + + const alarmLevel = Number(alarmLevelRaw) + const hasAlarm = + (Number.isFinite(alarmLevel) && alarmLevel > 0) || + (/true/i.test(riskAlarm)) || + (alarmInfo.length > 0 && !/^\/$|^-$|^none$/i.test(alarmInfo)) + + // 明确健康:以「正常」开头(含「正常但未初始化」)→ 绝不是故障 + if (/^正常/.test(lstatus)) { + if (/充电/.test(lstatus)) return 'charging' + if (/暂停|挂起/.test(lstatus)) return 'paused' + if (/离线/.test(lstatus)) return 'offline' + if (hasAlarm) return 'fault' + if (/charg|充电/i.test(openCharge) && /true/i.test(openCharge)) return 'charging' + if (/Drive(Run|Move|Busy)|Running|运行/i.test(drive)) return 'running' + if (/DriveStop|Idle|空闲|停止/i.test(drive)) return 'idle' + // 未初始化但仍「正常」:视为空闲,而不是故障 + return 'idle' + } + + if (/离线|失联/.test(lstatus)) return 'offline' + if (/充电/.test(lstatus)) return 'charging' + if (/暂停|挂起/.test(lstatus)) return 'paused' + if (hasAlarm || (/故障|异常|检修/.test(lstatus) && !/正常/.test(lstatus))) return 'fault' + if (/运行|工作|执行|忙/.test(lstatus)) return 'running' + if (/空闲|待机/.test(lstatus)) return 'idle' + + // 有车体 status 时,别盲信投影里的 fault + if (statusRows?.length) { + if (hasAlarm) return 'fault' + if (/Drive(Run|Move|Busy)|Running/i.test(drive)) return 'running' + if (/DriveStop|Idle/i.test(drive)) return 'idle' + } + + // 投影已给非 fault 时保留;fault 且无佐证则降为 idle,避免「全员故障」误报 + if (car.state && car.state !== 'fault') return car.state + return 'idle' +} + +export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car { + const socFromStatus = extractSocFromKv(statusRows) + let batterySoc: number + if (socFromStatus != null) { + batterySoc = normalizeSocRatio(socFromStatus) + } else if (statusRows != null) { + // 已拉到 status 但无 Soc(如纯模拟车)→ 清掉投影写死的 0.8,避免假电量 + batterySoc = 0 + } else { + batterySoc = parseSocNumber(car.batterySoc) ?? 0 + } + + return { + ...car, + batterySoc, + state: deriveCarState(car, statusRows) + } +} diff --git a/frontends/apps/simple-platform-vue/src/utils/dateTime.ts b/frontends/apps/simple-platform-vue/src/utils/dateTime.ts new file mode 100644 index 0000000..1af32e5 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/dateTime.ts @@ -0,0 +1,43 @@ +export const dateShortcuts = [ + { text: '今天', value: () => { const s = formatDate(new Date()); return [s, s] as [string, string] } }, + { text: '近 7 天', value: () => rangeDays(6) }, + { text: '近 30 天', value: () => rangeDays(29) } +] + +export function rangeDays(n: number): [string, string] { + const end = new Date() + const start = new Date() + start.setDate(end.getDate() - n) + return [formatDate(start), formatDate(end)] +} + +export function formatDate(d: Date): string { + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +} + +export function parseTime(raw?: string | null): number | null { + if (!raw) return null + const t = Date.parse(raw) + return Number.isFinite(t) ? t : null +} + +export function formatTime(raw?: string | null): string { + const t = parseTime(raw) + if (t == null) return '—' + const d = new Date(t) + const p = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` +} + +export function sortByTime(a?: string | null, b?: string | null) { + return (parseTime(a) ?? 0) - (parseTime(b) ?? 0) +} + +export function isToday(raw?: string | null): boolean { + const t = parseTime(raw) + if (t == null) return false + const d = new Date(t) + const now = new Date() + return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate() +} diff --git a/frontends/apps/simple-platform-vue/src/utils/miniMarkdown.ts b/frontends/apps/simple-platform-vue/src/utils/miniMarkdown.ts new file mode 100644 index 0000000..575fde6 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/miniMarkdown.ts @@ -0,0 +1,136 @@ +/** + * 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法: + * 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。 + * 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。 + * 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。 + */ + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function safeUrl(url: string): string | null { + const u = url.trim() + if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#')) return u + return null +} + +/** 行内格式:行内代码先占位保护,再处理链接/粗体/斜体,最后还原代码。入参须已 HTML 转义。 */ +function inline(escaped: string): string { + const codes: string[] = [] + let s = escaped.replace(/`([^`]+)`/g, (_m, c: string) => { + codes.push(`${c}`) + return `\u0001${codes.length - 1}\u0001` + }) + + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, url: string) => { + const safe = safeUrl(url) + if (!safe) return `[${text}](${url})` + return `${text}` + }) + + s = s.replace(/\*\*([^*]+)\*\*/g, '$1') + s = s.replace(/__([^_]+)__/g, '$1') + s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1$2') + s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1$2') + + s = s.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => codes[Number(i)] ?? '') + return s +} + +export function renderMarkdown(src: string): string { + if (!src) return '' + const text = src.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + const blocks: string[] = [] + const withTokens = text.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => { + const cls = (lang || '').trim() + const body = escapeHtml(code.replace(/\n$/, '')) + const clsAttr = cls ? ` class="language-${escapeHtml(cls)}"` : '' + blocks.push(`${body}`) + return `\u0000${blocks.length - 1}\u0000` + }) + + const lines = withTokens.split('\n') + const out: string[] = [] + let para: string[] = [] + let i = 0 + + const flushPara = (): void => { + if (para.length) { + out.push(`${inline(escapeHtml(para.join(' ')))}`) + para = [] + } + } + + while (i < lines.length) { + const line = lines[i] + + const codeToken = line.match(/^\u0000(\d+)\u0000\s*$/) + if (codeToken) { + flushPara() + out.push(blocks[Number(codeToken[1])] ?? '') + i++ + continue + } + + if (/^\s*$/.test(line)) { + flushPara() + i++ + continue + } + + const h = line.match(/^(#{1,6})\s+(.*)$/) + if (h) { + flushPara() + const lvl = h[1].length + const tag = lvl <= 2 ? 'h3' : lvl === 3 ? 'h4' : 'h5' + out.push(`<${tag} class="mmd-h">${inline(escapeHtml(h[2]))}${tag}>`) + i++ + continue + } + + if (/^\s*>\s?/.test(line)) { + flushPara() + const items: string[] = [] + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + items.push(lines[i].replace(/^\s*>\s?/, '')) + i++ + } + out.push(`${inline(escapeHtml(items.join(' ')))}`) + continue + } + + if (/^\s*[-*+]\s+/.test(line)) { + flushPara() + const items: string[] = [] + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\s*[-*+]\s+/, '')) + i++ + } + out.push(`${items.map((it) => `${inline(escapeHtml(it))}`).join('')}`) + continue + } + + if (/^\s*\d+\.\s+/.test(line)) { + flushPara() + const items: string[] = [] + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\s*\d+\.\s+/, '')) + i++ + } + out.push(`${items.map((it) => `${inline(escapeHtml(it))}`).join('')}`) + continue + } + + para.push(line) + i++ + } + flushPara() + + return out.join('\n') +} 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 @@ - - - - - - - @@ -212,25 +205,6 @@ const rules: FormRules = { const year = computed(() => new Date().getFullYear()) -// 生成 n 个随机分布、随机大小与闪烁节奏的小白点,营造梦幻星点。 -function makeStars(n: number): Record[] { - return Array.from({ length: n }, () => { - const size = 1.5 + Math.random() * 2.8 - return { - top: `${Math.random() * 100}%`, - left: `${Math.random() * 100}%`, - width: `${size}px`, - height: `${size}px`, - opacity: `${0.35 + Math.random() * 0.5}`, - animationDelay: `${-Math.random() * 6}s`, - animationDuration: `${2.6 + Math.random() * 3.4}s` - } as Record - }) -} -// 背景层(卡片之外)+ 品牌区(卡片内深色区)两组星点 -const starStyles = makeStars(48) -const heroStarStyles = makeStars(22) - async function submit() { if (!formRef.value) return await formRef.value.validate(async (ok) => { @@ -269,13 +243,8 @@ async function submit() { diff --git a/frontends/apps/simple-platform-vue/src/views/admin/AlarmManagementView.vue b/frontends/apps/simple-platform-vue/src/views/admin/AlarmManagementView.vue new file mode 100644 index 0000000..4b74e4b --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/AlarmManagementView.vue @@ -0,0 +1,373 @@ + +
{{ toJson(t.args) }}
{{ toJson(t.result) }}
${c}
${body}
${inline(escapeHtml(para.join(' ')))}
${inline(escapeHtml(items.join(' ')))}