Compare commits

...
9 Commits
Author SHA1 Message Date
zhaowei.huangandCursor beb6563060 完善 SimpleLite 启停控制与服务状态页。
拆分 StopAll/Restart,运维代理透传确认头与用户信息,服务状态页可更稳地重启进程。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:32:30 +08:00
zhaowei.huangandCursor 8299c3dc5c 增强地图编辑 CAD 能力与反射对象管理体验。
补充对齐/批量建站 API,并优化反射面板、历史栈与字段管理相关交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:32:30 +08:00
zhaowei.huangandCursor b1dd4f9005 新增平台 AI 助手抽屉与地图编辑助手对接。
统一流式问答入口,并用轻量 markdown 渲染助手回复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:32:29 +08:00
zhaowei.huangandCursor ef63dda228 完善 WMS 搬运任务与库位状态相关接口。
补齐运输规划与任务服务在状态/优先级字段上的读写与调度衔接。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:32:29 +08:00
zhaowei.huangandCursor 95c1a74122 锁定 Outpost 主路径主题,并优化地图编辑属性面板浅色对比。
统一 outpost-light/purple/blue 壳层 token,修复扩展字段与操作按钮在浅色下的可读性。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:31:53 +08:00
zhaowei.huangandCursor 6986370e9d 新增任务管理与报警管理前端,并接入车队健康数据。
运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:31:52 +08:00
zhaowei.huangandCursor 7056cf0872 新增车辆运维 OTA 工作台前端。
支持包管理、车辆同步、任务与自定义文件下发,并按 ops.ota* 对齐可写权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:31:52 +08:00
zhaowei.huangandCursor 4223a572c5 新增 OTA WatchDog 编排与车队健康/报警后端。
覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:31:51 +08:00
zhaowei.huangandCursor f4f4cd1d1b 忽略 OTA 运行时数据与临时产物,停止跟踪本地 db/json。
这些文件本就属于本机运行态,继续入库会把环境数据带进仓库。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 11:30:13 +08:00
112 changed files with 14115 additions and 2045 deletions
+7
View File
@@ -13,6 +13,8 @@ MiGu.Server/data/*.db
MiGu.Server/data/*.db-shm
MiGu.Server/data/*.db-wal
MiGu.Server/data/.internal-token
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录,*.json 上面那行不递归)
MiGu.Server/data/ota/
# 前端构建产物
MiGu.Server/wwwroot/
@@ -32,3 +34,8 @@ Thumbs.db
Desktop.ini
/.cursor/rules
/MiGu.Server/wwwroot
/MiGu.Server/data/platform.db
# 临时脚本 / 工具产物(不应入库)
.codex-temp/
frontends/apps/simple-platform-vue/imgui.ini
+2
View File
@@ -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),
+123
View File
@@ -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;
/// <summary>车队运维健康探针(延迟走 WatchDog TCP)+ CDM 任务平台侧快照读取。</summary>
[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<ActionResult<List<FleetHealthRowDto>>> Health(CancellationToken ct)
=> Ok(await _health.GetAsync(ct));
/// <summary>
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
/// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// </summary>
[HttpGet("tasks")]
public async Task<ActionResult<object>> 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
});
}
/// <summary>
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录,
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
/// </summary>
[HttpGet("alarms")]
public async Task<ActionResult<object>> 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
});
}
}
@@ -34,6 +34,26 @@ public class HealthController : ControllerBase
[Authorize]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/stop")]
[Authorize(Policy = "PlatformScope")]
public IActionResult StopSimpleLite()
{
var killed = _launcher.StopAll();
var diag = _launcher.GetDiagnostics();
return Ok(new { killed, diagnostics = diag });
}
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
[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 });
}
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
+4
View File
@@ -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);
+515
View File
@@ -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<OtaOptions> 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<OtaSettings> GetSettings() => _store.GetSettings();
[HttpPut("settings")]
public ActionResult<OtaSettings> 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<object> 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<List<OtaPackageInfo>> ListPackages() => _store.ListPackages();
[HttpGet("packages/{id}")]
public ActionResult<OtaPackageInfo> 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<OtaTarget> 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<ActionResult<object>> 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<ActionResult<OtaPackageInfo>> 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<ActionResult<List<OtaVehicleRow>>> 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<ActionResult<object>> Latency(CancellationToken ct)
{
var settings = _store.GetSettings();
if (!settings.LatencyEnabled)
return Ok(new { enabled = false, items = Array.Empty<object>() });
var cars = await _vehicles.ListCarsAsync(ct);
var items = new List<object>();
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<List<OtaJob>> ListJobs([FromQuery] int take = 100) => _store.ListJobs(take);
[HttpGet("jobs/{id}")]
public ActionResult<OtaJob> 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<OtaJob> 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<OtaJob> 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<ActionResult<object>> 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<OtaJob> 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<ActionResult<OtaJob>> CustomFile(
[FromForm] string carIds,
[FromForm] string remotePath,
[FromForm] string? restartOps,
[FromForm] int? restartOp,
[FromForm] List<IFormFile>? files,
IFormFile? file,
CancellationToken ct)
{
if (DenyWrite(out var denied)) return denied;
var uploadFiles = new List<IFormFile>();
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<int>();
if (!string.IsNullOrWhiteSpace(restartOps))
{
try
{
ops = System.Text.Json.JsonSerializer.Deserialize<List<int>>(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<OtaCustomFileItem>();
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<string> ParseCarIds(string carIds)
{
var trimmed = carIds.Trim();
if (trimmed.StartsWith("[", StringComparison.Ordinal))
{
try
{
var ids = System.Text.Json.JsonSerializer.Deserialize<List<string>>(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<string, string> BuildMatch(OtaTarget target, OtaAppVersions? m, OtaAppVersions? d, OtaAppVersions? c)
{
var map = new Dictionary<string, string>(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));
}
}
@@ -0,0 +1,108 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Ota;
namespace MiGu.Server.Controllers;
/// <summary>
/// WatchDog 回传包接收端。
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
/// </summary>
[ApiController]
[AllowAnonymous]
public class OtaReceiveController : ControllerBase
{
private readonly OtaStore _store;
private readonly ILogger<OtaReceiveController> _log;
public OtaReceiveController(OtaStore store, ILogger<OtaReceiveController> log)
{
_store = store;
_log = log;
}
[HttpGet("/hello")]
[HttpGet("/api/ota/receive/hello")]
public IActionResult Hello() => Ok("ok");
/// <summary>WatchDog 官方路径:/upload-mdcs/{Medullaexe|...}</summary>
[HttpPost("/upload-mdcs/{routeKey}")]
[HttpPost("/api/ota/receive/upload-mdcs/{routeKey}")]
[HttpPost("/api/ota/receive/upload-mdcs{routeKey}")]
[RequestSizeLimit(512_000_000)]
public Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IFormFile?> 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;
}
}
@@ -36,22 +36,24 @@ public sealed class SimpleFieldController : ControllerBase
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
/// </summary>
[HttpPost]
[Authorize(Policy = "PlatformScope")]
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
/// <summary>
/// 按 id 更新单条字段
/// </summary>
[HttpPut("{id:guid}")]
[Authorize(Policy = "PlatformScope")]
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
/// <summary>
/// 按 id 删除单条字段
/// </summary>
[HttpDelete("{id:guid}")]
[Authorize(Policy = "PlatformScope")]
public async Task<IActionResult> Delete(Guid id)
{
await _service.DeleteAsync(id);
return NoContent();
}
@@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase
/// 返回实际写入条数 <c>{ count }</c>。
/// </summary>
[HttpPost("batch")]
[Authorize(Policy = "PlatformScope")]
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
{
var count = await _service.SaveBatchAsync(req);
return Ok(new { count });
}
}
+251
View File
@@ -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;
/// <summary>
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
/// </summary>
public sealed class AlarmCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<AlarmCollector> _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<SimpleLiteOptions> sl,
InternalTokenStore token,
ILogger<AlarmCollector> 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<bool> 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<int, CurrentAlarm>();
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<PlatformDbContext>();
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<List<CarRow>?> 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<CarRow>();
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<int, CurrentAlarm> current, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
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;
}
}
/// <summary>后台循环:定时采集车辆报警到 platform.db。</summary>
public sealed class AlarmCollectorService : BackgroundService
{
private readonly AlarmCollector _collector;
private readonly ILogger<AlarmCollectorService> _log;
public AlarmCollectorService(AlarmCollector collector, ILogger<AlarmCollectorService> 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; }
}
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace MiGu.Server.Fleet;
/// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史,
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
/// </summary>
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; }
/// <summary>下发/开始/结束时间:直接存投影返回的 ISO 字符串(可空)。</summary>
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; }
/// <summary>平台首次/最近一次同步到该任务的时间。</summary>
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
}
+205
View File
@@ -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;
/// <summary>投影 /projection/deliveries 返回的单行(camelCase)。</summary>
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; }
}
/// <summary>
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// </summary>
public sealed class CdmTaskSyncer
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<CdmTaskSyncer> _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<SimpleLiteOptions> sl,
InternalTokenStore token,
ILogger<CdmTaskSyncer> log)
{
_scopeFactory = scopeFactory;
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
/// <summary>拉取 + 落库一次。并发调用时若已有同步在进行则直接跳过(返回当前在线状态)。</summary>
public async Task<bool> 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<PlatformDbContext>();
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<List<CdmTaskDto>?> 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<List<CdmTaskDto>>(text, JsonOpt) ?? new List<CdmTaskDto>();
}
catch (Exception ex)
{
_log.LogDebug(ex, "cdm fetch failed");
return null;
}
}
private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList<CdmTaskDto> 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;
}
}
/// <summary>后台循环:定时把 CDM 任务同步进 platform.db,保证无人打开页面时也能捕获终态历史。</summary>
public sealed class CdmTaskSyncService : BackgroundService
{
private readonly CdmTaskSyncer _syncer;
private readonly ILogger<CdmTaskSyncService> _log;
public CdmTaskSyncService(CdmTaskSyncer syncer, ILogger<CdmTaskSyncService> 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; }
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace MiGu.Server.Fleet;
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
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; }
/// <summary>latency 探测通道:watchdog | onboard | none</summary>
public string? LatencySource { get; set; }
}
+169
View File
@@ -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;
/// <summary>
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// </summary>
public sealed class FleetHealthService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly InternalTokenStore _token;
private readonly WatchDogClient _wd;
private readonly ILogger<FleetHealthService> _log;
private static readonly JsonSerializerOptions JsonOpt = new()
{
PropertyNameCaseInsensitive = true
};
public FleetHealthService(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStore token,
WatchDogClient wd,
ILogger<FleetHealthService> log)
{
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_wd = wd;
_log = log;
}
public async Task<List<FleetHealthRowDto>> 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<List<FleetHealthRowDto>> 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<List<FleetHealthRowDto>>(text, JsonOpt);
return list ?? new();
}
catch (Exception ex)
{
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
return new();
}
}
private async Task<List<FleetHealthRowDto>> 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<FleetHealthRowDto>();
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();
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace MiGu.Server.Fleet;
/// <summary>
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
/// </summary>
public sealed class VehicleAlarmRecord
{
public string Id { get; set; } = Guid.NewGuid().ToString("D");
public int CarId { get; set; }
public string CarName { get; set; } = "";
/// <summary>报警文案(车体_AlarmInfo)。</summary>
public string Info { get; set; } = "";
/// <summary>报警级别(车体_AlarmLevel,未知为 0)。</summary>
public int Level { get; set; }
/// <summary>active | cleared</summary>
public string Status { get; set; } = "active";
public DateTimeOffset FirstAt { get; set; }
public DateTimeOffset LastAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
/// <summary>持续时长(秒),恢复后写入。</summary>
public long? DurationSecs { get; set; }
/// <summary>预留:平台侧确认(不代表车端消警)。</summary>
public bool Acknowledged { get; set; }
public DateTimeOffset? AcknowledgedAt { get; set; }
public string? AcknowledgedBy { get; set; }
}
+24 -7
View File
@@ -349,11 +349,10 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
/// <summary>
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// </summary>
public LaunchResult RestartForUpdate(string launchMode = "webonly")
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
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;
}
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
public LaunchResult Restart(string launchMode = "webonly")
{
StopAll();
return MaybeStart(launchMode);
}
/// <summary>
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// </summary>
public LaunchResult RestartForUpdate(string launchMode = "webonly")
{
StopAll();
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var result = MaybeStart(launchMode);
if (!synced && result.Warning == null)
+26
View File
@@ -0,0 +1,26 @@
using System.Security.Cryptography;
namespace MiGu.Server.Ota;
public static class OtaHash
{
/// <summary>与参考 OTA 工具一致:MD5 → Base64,并去掉 '-'。</summary>
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<byte> 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];
}
}
+433
View File
@@ -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<OtaJobRunner> _log;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new();
public OtaJobRunner(OtaStore store, WatchDogClient wd, OtaVehicleSource vehicles, ILogger<OtaJobRunner> 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<OtaCustomFileItem> 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<int> { 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<string> { "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<string> { $"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<int> { 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<OtaCustomFileItem> 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<OtaCustomFileItem>
{
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<int> { 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";
}
}
+144
View File
@@ -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;
/// <summary>skip | confirm</summary>
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<string, OtaFileArtifact> 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<string, OtaFileArtifact> 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<string, string>? 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; } = "";
/// <summary>sync | customFile | configPush</summary>
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<string> CarIds { get; set; } = new();
public List<string> Components { get; set; } = new();
public bool RequireLatencyCheck { get; set; }
public int DoneSteps { get; set; }
public int TotalSteps { get; set; }
public List<OtaJobStep> 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<OtaCustomFileItem> CustomFiles { get; set; } = new();
public List<int> CustomRestartOps { get; set; } = new() { -1 };
// configPush
public string? ConfigApp { get; set; }
public string? ConfigJson { get; set; }
}
public sealed class CreateSyncJobRequest
{
public List<string> CarIds { get; set; } = new();
public List<string>? Components { get; set; }
public bool? RequireLatencyCheck { get; set; }
}
public sealed class PullPackageRequest
{
public string CarId { get; set; } = "";
}
public sealed class CreateCustomFileJobRequest
{
public List<string> CarIds { get; set; } = new();
public string RemotePath { get; set; } = "";
/// <summary>兼容旧单值;优先用 RestartOps。</summary>
public int RestartOp { get; set; } = -1;
/// <summary>-1 不重启;0 Medulla1 Clumsy2 Detour3 WatchDog。可多选。</summary>
public List<int>? 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<string> CarIds { get; set; } = new();
public string App { get; set; } = "";
public string Json { get; set; } = "";
public bool? RequireLatencyCheck { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace MiGu.Server.Ota;
public sealed class OtaOptions
{
/// <summary>相对 ContentRoot 或绝对路径;默认 data/ota</summary>
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;
/// <summary>
/// WatchDog 回传监听端口(写死连 :8000)。MiGu 会额外监听该端口并挂 /upload-mdcs/*。
/// </summary>
public int ReceivePort { get; set; } = 8000;
/// <summary>
/// 可选:本机对车辆可见的管理面基址(如 http://192.168.1.10:8080)。
/// 注意:现网 WatchDog 忽略 getmdcsexe 的 server 参数,仍回传到 config.serverIP:ReceivePort。
/// </summary>
public string? PublicBaseUrl { get; set; }
}
+53
View File
@@ -0,0 +1,53 @@
namespace MiGu.Server.Ota;
/// <summary>MDC 组件文件映射(对齐参考工具 MDCPath.json)。</summary>
public static class OtaPathMap
{
public static readonly IReadOnlyDictionary<string, string[]> Default = new Dictionary<string, string[]>(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;
}
}
+368
View File
@@ -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<OtaStore> _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<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> 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<OtaSettings>(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<OtaTarget>(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<OtaSettings>(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;
}
}
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
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<OtaPackageInfo> ListPackages()
{
if (!Directory.Exists(PackagesDir)) return new();
var list = new List<OtaPackageInfo>();
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<OtaJob>(File.ReadAllText(path), _json);
}
catch (Exception ex)
{
_log.LogWarning(ex, "job load failed {Id}", id);
return null;
}
}
public List<OtaJob> ListJobs(int take = 100)
{
if (!Directory.Exists(JobsDir)) return new();
return Directory.GetFiles(JobsDir, "*.json")
.Select(f =>
{
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
catch { return null; }
})
.Where(j => j != null)
.Cast<OtaJob>()
.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;
}
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
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;
}
}
+141
View File
@@ -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<OtaVehicleSource> _log;
public OtaVehicleSource(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
InternalTokenStoreAccessor token,
ILogger<OtaVehicleSource> log)
{
_httpFactory = httpFactory;
_sl = sl.Value;
_token = token;
_log = log;
}
public async Task<List<OtaVehicleRow>> 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<List<OtaVehicleRow>> 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<List<OtaVehicleRow>> 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<OtaVehicleRow>();
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<OtaVehicleRow> 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<OtaVehicleRow>();
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;
}
}
/// <summary>避免 Ota 层直接依赖 Auth 命名空间循环;薄包装 InternalTokenStore。</summary>
public sealed class InternalTokenStoreAccessor
{
private readonly Auth.InternalTokenStore _store;
public InternalTokenStoreAccessor(Auth.InternalTokenStore store) => _store = store;
public string Token => _store.Token;
}
+289
View File
@@ -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<WatchDogClient> _log;
private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
public WatchDogClient(IHttpClientFactory httpFactory, IOptions<OtaOptions> opt, ILogger<WatchDogClient> 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<int?> 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<string, string> { ["path"] = remotePath }, ct);
}
private async Task UploadFileAsync(string url, string localPath, string fileName, int bandwidthKbps, Dictionary<string, string>? 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<string> 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}");
}
}
/// <summary>简易限速流:按字节/秒节流读取。</summary>
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<int> 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);
}
}
}
@@ -27,6 +27,8 @@ public sealed class PlatformDbContext : DbContext
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
public DbSet<MiGu.Server.Fleet.CdmTaskRecord> CdmTasks => Set<MiGu.Server.Fleet.CdmTaskRecord>();
public DbSet<MiGu.Server.Fleet.VehicleAlarmRecord> VehicleAlarms => Set<MiGu.Server.Fleet.VehicleAlarmRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -85,6 +87,12 @@ public sealed class PlatformDbContext : DbContext
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
// 库位占用 1:1:同一 Storage LocationId 同时只能有一条未删除记录(Car 等其它类型不限)
modelBuilder.Entity<ContainerLocation>()
.HasIndex(x => x.LocationId)
.IsUnique()
.HasFilter("LocationType = 'Storage' AND IsDeleted = 0")
.HasDatabaseName("IX_wms_container_locations_StorageLocationId");
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.ContainerId);
@@ -103,6 +111,61 @@ public sealed class PlatformDbContext : DbContext
ConfigureSimpleField(modelBuilder);
ConfigureUserDashboardShortcut(modelBuilder);
ConfigureCdmTask(modelBuilder);
ConfigureVehicleAlarm(modelBuilder);
}
private static void ConfigureVehicleAlarm(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<MiGu.Server.Fleet.VehicleAlarmRecord>();
e.ToTable("vehicle_alarms");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36);
e.Property(x => x.CarId).HasColumnName("car_id");
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128);
e.Property(x => x.Info).HasColumnName("info").HasColumnType("text");
e.Property(x => x.Level).HasColumnName("level");
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16);
e.Property(x => x.FirstAt).HasColumnName("first_at");
e.Property(x => x.LastAt).HasColumnName("last_at");
e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false);
e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false);
e.Property(x => x.Acknowledged).HasColumnName("acknowledged");
e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false);
e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false);
e.HasIndex(x => new { x.CarId, x.Status });
e.HasIndex(x => x.Status);
e.HasIndex(x => x.FirstAt);
}
private static void ConfigureCdmTask(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<MiGu.Server.Fleet.CdmTaskRecord>();
e.ToTable("cdm_tasks");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64);
e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false);
e.Property(x => x.MissionId).HasColumnName("mission_id");
e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128);
e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128);
e.Property(x => x.SrcSiteId).HasColumnName("src_site_id");
e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256);
e.Property(x => x.DstSiteId).HasColumnName("dst_site_id");
e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256);
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32);
e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32);
e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false);
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false);
e.Property(x => x.Priority).HasColumnName("priority");
e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false);
e.Property(x => x.Overdue).HasColumnName("overdue");
e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at");
e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at");
e.HasIndex(x => x.StatusCode);
e.HasIndex(x => x.CreateTime);
}
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
@@ -59,6 +59,8 @@ public static class PlatformPersistence
await EnsureUserDashboardShortcutsTableAsync(db);
await EnsureWmsTransportSchemaAsync(db);
await EnsureWmsStructureSchemaAsync(db);
await EnsureCdmTasksTableAsync(db);
await EnsureVehicleAlarmsTableAsync(db);
await MigrateWmsLegacyAsync(scope.ServiceProvider);
}
@@ -185,6 +187,83 @@ public static class PlatformPersistence
await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''");
}
/// <summary>为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。</summary>
private static async Task EnsureVehicleAlarmsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS vehicle_alarms (
id TEXT NOT NULL CONSTRAINT PK_vehicle_alarms PRIMARY KEY,
car_id INTEGER NOT NULL DEFAULT 0,
car_name TEXT NOT NULL DEFAULT '',
info TEXT NOT NULL DEFAULT '',
level INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
first_at TEXT NOT NULL,
last_at TEXT NOT NULL,
resolved_at TEXT,
duration_secs INTEGER,
acknowledged INTEGER NOT NULL DEFAULT 0,
acknowledged_at TEXT,
acknowledged_by TEXT
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_car_status ON vehicle_alarms (car_id, status);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_status ON vehicle_alarms (status);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_first_at ON vehicle_alarms (first_at);");
return;
}
if (!await TableExistsAsync(db, "vehicle_alarms"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。</summary>
private static async Task EnsureCdmTasksTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS cdm_tasks (
id TEXT NOT NULL CONSTRAINT PK_cdm_tasks PRIMARY KEY,
task_id TEXT,
mission_id INTEGER NOT NULL DEFAULT 0,
mission_name TEXT NOT NULL DEFAULT '',
mission_type TEXT NOT NULL DEFAULT '',
src_site_id INTEGER NOT NULL DEFAULT 0,
src_label TEXT NOT NULL DEFAULT '',
dst_site_id INTEGER NOT NULL DEFAULT 0,
dst_label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
status_code TEXT NOT NULL DEFAULT '',
car_id INTEGER,
car_name TEXT,
priority INTEGER NOT NULL DEFAULT 0,
create_time TEXT,
start_time TEXT,
finish_time TEXT,
stuck_reason TEXT,
overdue INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_status_code ON cdm_tasks (status_code);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_create_time ON cdm_tasks (create_time);");
return;
}
if (!await TableExistsAsync(db, "cdm_tasks"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
{
@@ -339,10 +418,33 @@ public static class PlatformPersistence
SnapshotJson TEXT NOT NULL
);
""");
// 库位占用唯一约束(幂等);若库内已有重复占用会创建失败,不阻断启动
try
{
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_container_locations_StorageLocationId
ON wms_container_locations (LocationId)
WHERE LocationType = 'Storage' AND IsDeleted = 0;
""");
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[WMS] 无法创建库位占用唯一索引 IX_wms_container_locations_StorageLocationId(可能已有重复占用): {ex.Message}");
}
}
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
{
// 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白。
static bool IsSafeIdent(string s) =>
!string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_');
if (!IsSafeIdent(table) || !IsSafeIdent(column)
|| definition.Contains(';') || definition.Contains("--")
|| !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$"))
throw new ArgumentException("unsafe sqlite migration identifier");
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync();
+59 -5
View File
@@ -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")))
// 管理面默认 :8080WatchDog 拉包回传写死 :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-historyWatchDog 写死回传)。
// 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<Microsoft.AspNetCore.Http.Features.FormOptions>(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<InternalTokenStore>();
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<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
builder.Services.AddSingleton<OtaStore>();
builder.Services.AddSingleton<InternalTokenStoreAccessor>();
builder.Services.AddSingleton<OtaVehicleSource>();
builder.Services.AddSingleton<WatchDogClient>();
builder.Services.AddSingleton<OtaJobRunner>();
builder.Services.AddSingleton<MiGu.Server.Fleet.FleetHealthService>();
builder.Services.AddSingleton<MiGu.Server.Fleet.CdmTaskSyncer>();
builder.Services.AddHostedService<MiGu.Server.Fleet.CdmTaskSyncService>();
builder.Services.AddSingleton<MiGu.Server.Fleet.AlarmCollector>();
builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
builder.Services.AddHttpClient(nameof(WatchDogClient));
var app = builder.Build();
await app.Services.EnsurePlatformDatabaseAsync();
+1 -1
View File
@@ -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"
}
+22 -1
View File
@@ -499,7 +499,14 @@ public sealed class WmsService
});
await AddContainerMoveEventAsync(req.ContainerId, fromStorageId, toStorageId, actor, req.Reason.TrimOr(""), now);
await _db.SaveChangesAsync();
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
throw new InvalidOperationException("目标库位已被其他容器占用,库位与容器为 1 对 1");
}
await SyncOccupancyStatus(containerId: req.ContainerId, storageId: fromStorageId);
await SyncOccupancyStatus(storageId: toStorageId);
return current;
@@ -857,6 +864,20 @@ public sealed class WmsService
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
}
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) throw new InvalidOperationException("数据已锁定,不能修改");
+4 -1
View File
@@ -69,8 +69,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();
+44 -5
View File
@@ -165,10 +165,27 @@ public sealed class WmsTransportTaskService
public async Task<WmsTransportTask> 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);
@@ -194,14 +211,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("目标库位已被其他任务预占");
}
+18
View File
@@ -44,6 +44,15 @@
"Dispatch": {
}
},
"_comment_Ota": "车辆 OTA:包与任务落盘 DataRootWatchDog: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",
-20
View File
@@ -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"
}
}
Binary file not shown.
@@ -0,0 +1,117 @@
# 迷榖 OTAWatchDog)实现计划
> **状态:** 核心任务已落地(后端 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` | Taboverview \| 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` 通过。
### 任务 2WatchDogClient + 车辆源 + 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 通过。
### 任务 3JobRunner + 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 编排 | 23 |
| 包库/目标/任务/设置/延迟 | 3、5 |
| 配置同步/自定义文件 | 3、5 |
| 审计 | 3 |
| 验收 §9 | 6 |
@@ -0,0 +1,23 @@
# 迷毂 Outpost UI 实现计划(P0P1
> **面向 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 验证
@@ -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 |
- 版本标识:文件 **MD5Base64,去除 `-`**,与 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}` | 单车 JSONmedulla |
| POST | `/api/ota/config/push` | JSON 多车下发(走 job |
| GET | `/api/ota/latency` | 按需批量 RTT(受设置开关约束) |
权限:JWT/RBACPlatform 全量写;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 内按钮开关;开才检测 |
@@ -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 + **精选 ThemeSwitcher3 项)** + 用户区。
- 用户菜单增加「高级 → 兼容主题」子菜单(列出 `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≈1416、阴影用 `--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. 审查结论(请用户勾选)
- [ ] 同意本规格(含精选三色 + 高级保留旧主题),可编写实现计划并开工
- [ ] 需要修改:________
@@ -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
+7 -19
View File
@@ -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']
}
}
@@ -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-*`
@@ -1,11 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" data-shell="outpost" data-theme="outpost-light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>迷毂 · 智能调度平台</title>
<meta name="theme-color" content="#2e3f8a" />
<meta name="theme-color" content="#7543e8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
@@ -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<AlarmFeed> {
if (MOCK) {
const { mockAlarms } = await import('@/mock/data/alarms')
return { online: true, lastSyncAt: new Date().toISOString(), alarms: await mockAlarms() }
}
const { data } = await http.get<AlarmFeed>('/fleet/alarms', { params: { limit } })
return {
online: !!data?.online,
lastSyncAt: data?.lastSyncAt ?? null,
alarms: Array.isArray(data?.alarms) ? data.alarms : []
}
}
@@ -0,0 +1,208 @@
import http from './http'
/**
* AI API/ axios fetch SSE
* EventSource GET Authorization headerfetch
* 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<AssistantSessionMeta[]> {
const { data } = await http.get<AssistantSessionMeta[]>(`${REST}/sessions`)
return data ?? []
}
export async function getHistory(sessionId: string): Promise<AssistantHistory> {
const { data } = await http.get<AssistantHistory>(`${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<void> {
await http.delete(`${REST}/sessions/${encodeURIComponent(id)}`)
}
export async function listTools(): Promise<AssistantToolMeta[]> {
const { data } = await http.get<AssistantToolMeta[]>(`${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 <paramref name="signal"/>
* §6.2session / token / tool_call / tool_result / error / done
*/
export async function streamChat(
payload: { message: string; sessionId?: string | null; profile?: string },
handlers: AssistantStreamHandlers,
signal: AbortSignal
): Promise<void> {
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<string, unknown> = {}
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
}
}
@@ -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<void> {
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<void> {
if (MOCK) return
await http.post(`${BASE}/${id}/resend`)
/**
* /fleet/tasks线 +
* 退 /sl/projection/deliveries
*/
export async function fetchCdmTaskFeed(limit = 1000): Promise<CdmTaskFeed> {
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<CdmTaskFeed>('/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<void> {
export async function cancelDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${id}/force-complete`)
await http.post(`${BASE}/${encodeURIComponent(id)}/cancel`)
}
export async function resendDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/resend`)
}
export async function forceCompleteDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/force-complete`)
}
export async function pauseDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/pause`)
}
export async function resumeDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/resume`)
}
export async function changeCarDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/change-car`)
}
export async function setDeliveryPriority(id: string, value: number): Promise<void> {
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 ?? '' }
}
@@ -32,6 +32,14 @@ export async function fetchFleetHealth(): Promise<FleetHealthRow[]> {
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<FleetHealthRow[]>('/fleet/health')
if (Array.isArray(data) && data.length > 0) return data
} catch {
/* fall through */
}
const { data } = await http.get<FleetHealthRow[]>('/sl/projection/fleet/health')
return Array.isArray(data) ? data : []
}
@@ -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<HealthInfo>('/health')
}
export function getSimpleLiteDiagnostics() {
return http.get<SimpleLiteDiagnostics>('/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'
}
@@ -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=仅 UITrackcurve=贝塞尔/弧/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<PickResult>(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<PickResult>(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })),
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
@@ -0,0 +1,123 @@
import http from './http'
import type {
OtaJob,
OtaPackageInfo,
OtaSettings,
OtaTarget,
OtaVehicleRow
} from '@/types/ota'
export async function getOtaSettings(): Promise<OtaSettings> {
const { data } = await http.get<OtaSettings>('/ota/settings')
return data
}
export async function putOtaSettings(settings: OtaSettings): Promise<OtaSettings> {
const { data } = await http.put<OtaSettings>('/ota/settings', settings)
return data
}
export async function getOtaTarget(): Promise<{ target: OtaTarget | null; summary?: Record<string, string> }> {
const { data } = await http.get<{ target: OtaTarget | null; summary?: Record<string, string> }>('/ota/target')
return data
}
export async function listOtaPackages(): Promise<OtaPackageInfo[]> {
const { data } = await http.get<OtaPackageInfo[]>('/ota/packages')
return data
}
export async function activateOtaPackage(id: string, name?: string): Promise<OtaTarget> {
const { data } = await http.post<OtaTarget>(`/ota/packages/${encodeURIComponent(id)}/activate`, null, {
params: name ? { name } : undefined
})
return data
}
export async function deleteOtaPackage(id: string): Promise<void> {
await http.delete(`/ota/packages/${encodeURIComponent(id)}`)
}
export async function pullOtaPackage(carId: string): Promise<OtaPackageInfo> {
const { data } = await http.post<OtaPackageInfo>('/ota/packages/pull', { carId }, { timeout: 120000 })
return data
}
export async function uploadOtaPackage(file: File): Promise<OtaPackageInfo> {
const form = new FormData()
form.append('file', file)
const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000
})
return data
}
export async function listOtaVehicles(latency?: boolean): Promise<OtaVehicleRow[]> {
const { data } = await http.get<OtaVehicleRow[]>('/ota/vehicles', {
params: latency === undefined ? undefined : { latency },
timeout: 60000
})
return data
}
export async function listOtaJobs(take = 100): Promise<OtaJob[]> {
const { data } = await http.get<OtaJob[]>('/ota/jobs', { params: { take } })
return data
}
export async function getOtaJob(id: string): Promise<OtaJob> {
const { data } = await http.get<OtaJob>(`/ota/jobs/${encodeURIComponent(id)}`)
return data
}
export async function createOtaSyncJob(body: {
carIds: string[]
components?: string[]
requireLatencyCheck?: boolean
}): Promise<OtaJob> {
const { data } = await http.post<OtaJob>('/ota/jobs', body)
return data
}
export async function cancelOtaJob(id: string): Promise<void> {
await http.post(`/ota/jobs/${encodeURIComponent(id)}/cancel`)
}
export async function retryOtaJob(id: string): Promise<OtaJob> {
const { data } = await http.post<OtaJob>(`/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<OtaJob> {
const { data } = await http.post<OtaJob>('/ota/config/push', body)
return data
}
export async function pushOtaCustomFile(opts: {
carIds: string[]
remotePath: string
restartOps: number[]
files: File[]
}): Promise<OtaJob> {
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<OtaJob>('/ota/custom-file', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000
})
return data
}
@@ -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<number, { at: number; rows: Array<{ key: string; value: string }> | null }>()
export async function listSites(): Promise<Site[]> {
if (MOCK) return mockSites()
const { data } = await http.get<Site[]>('/sl/projection/sites')
@@ -22,14 +27,7 @@ export async function listTracks(): Promise<Track[]> {
}
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<Mission[]> {
return rows.map(missionFromReflection)
}
async function loadCarStatus(carId: number): Promise<Array<{ key: string; value: string }> | 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<Car[]> {
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/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
export async function listCars(): Promise<Car[]> {
if (MOCK) return mockCars()
try {
const { data } = await http.get<Car[]>('/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())
}
}
@@ -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<T>(path: string): Promise<T> {
const { data } = await http.get<ReflectionEnvelope<T>>(`${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<T>(path: string, params?: Record<string, string | number | boolean>): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params })
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
async function post<T>(
path: string,
params?: Record<string, string | number | boolean>,
headers?: Record<string, string>
): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${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<T>(path: string): Promise<T> {
const { data } = await http.delete<ReflectionEnvelope<T>>(`${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<T>(path: string, body: unknown): Promise<T> {
const { data } = await http.patch<ReflectionEnvelope<T>>(`${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<T>(
path: string,
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
): Promise<T> {
const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined
try {
return await post<T>(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<T>(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<string, string | number | boolean>) => MOCK
execute: (
kind: ReflectionKind,
id: number,
method: string,
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
) => MOCK
? Promise.resolve(mockReflectionExecute(
kind,
id,
method,
Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)]))
))
: post<{ returnValue: string }>(
: executeWithPlatformConfirm<ReflectionExecuteResult>(
`/execute/${kind}/${id}/${encodeURIComponent(method)}`,
params
params,
opts
),
/** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */
@@ -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<ToolbarState>(http.post(`${BASE}/playback/stop`)),
playPause: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/play-pause`, {})),
seekElapsed: (elapsedMs: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/seek`, { elapsedMs, ElapsedMs: elapsedMs })),
seekFrame: (frameIndex: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/seek`, { frameIndex, FrameIndex: frameIndex })),
step: (delta = 1) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/step`, { delta, Delta: delta })),
setSpeed: (speed: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/speed`, { speed, Speed: speed })),
toggleViewMode: () => unwrap<ToolbarState>(http.post(`${BASE}/view/toggle`)),
setCameraFollow: (carId: number | null, enabled: boolean) =>
@@ -0,0 +1,161 @@
<template>
<div v-if="playing" class="pb-bar">
<div class="pb-file" :title="fileName">{{ fileName || '回放中' }}</div>
<div class="pb-controls">
<button type="button" class="pb-btn" title="-5s" @click="seekRel(-5000)"></button>
<button type="button" class="pb-btn" title="上一帧" @click="step(-1)"></button>
<button type="button" class="pb-btn pb-play" @click="togglePlay">
{{ autoPlaying ? '⏸' : '▶' }}
</button>
<button type="button" class="pb-btn" title="下一帧" @click="step(1)"></button>
<button type="button" class="pb-btn" title="+5s" @click="seekRel(5000)"></button>
</div>
<input
class="pb-slider"
type="range"
min="0"
:max="Math.max(durationMs, 1)"
:value="elapsedMs"
@input="onSlider"
/>
<div class="pb-time">{{ formatMs(elapsedMs) }} / {{ formatMs(durationMs) }}</div>
<select class="pb-speed" :value="speed" @change="onSpeed">
<option v-for="s in speeds" :key="s" :value="s">{{ s }}x</option>
</select>
<button type="button" class="pb-btn pb-stop" @click="stop">停止</button>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { workspaceToolbarApi } from '@/api/workspaceToolbar'
const speeds = [0.25, 0.5, 1, 2, 4, 8]
const playing = ref(false)
const autoPlaying = ref(false)
const elapsedMs = ref(0)
const durationMs = ref(0)
const speed = ref(1)
const fileName = ref('')
let timer: ReturnType<typeof setInterval> | null = null
let seeking = false
function formatMs(ms: number) {
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
const ss = String(s % 60).padStart(2, '0')
const mm = String(m % 60).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
}
async function refresh() {
try {
const st = await workspaceToolbarApi.getState()
const rec = st.recording as typeof st.recording & {
isAutoPlaying?: boolean
speed?: number
durationMs?: number
currentPlaybackFile?: string | null
}
playing.value = !!rec.isPlaying
autoPlaying.value = !!rec.isAutoPlaying
if (!seeking) elapsedMs.value = rec.elapsedMs ?? 0
durationMs.value = rec.durationMs ?? 0
speed.value = rec.speed ?? 1
fileName.value = rec.currentPlaybackFile ?? ''
} catch {
//
}
}
async function togglePlay() {
await workspaceToolbarApi.playPause()
await refresh()
}
async function stop() {
await workspaceToolbarApi.stopPlayback()
await refresh()
}
async function step(delta: number) {
await workspaceToolbarApi.step(delta)
await refresh()
}
async function seekRel(deltaMs: number) {
const target = Math.max(0, Math.min(durationMs.value, elapsedMs.value + deltaMs))
await workspaceToolbarApi.seekElapsed(target)
await refresh()
}
async function onSlider(e: Event) {
const v = Number((e.target as HTMLInputElement).value)
seeking = true
elapsedMs.value = v
try {
await workspaceToolbarApi.seekElapsed(v)
} finally {
seeking = false
}
}
async function onSpeed(e: Event) {
const v = Number((e.target as HTMLSelectElement).value)
await workspaceToolbarApi.setSpeed(v)
await refresh()
}
onMounted(() => {
void refresh()
timer = setInterval(() => void refresh(), 250)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
defineExpose({ refresh })
</script>
<style scoped>
.pb-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(20, 24, 30, 0.92);
border-top: 1px solid #2a303c;
color: #eceff1;
font-size: 12px;
}
.pb-file {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #90a4ae;
}
.pb-controls { display: flex; gap: 2px; }
.pb-btn {
background: #2a303c;
border: 1px solid #3a4150;
color: #eceff1;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
}
.pb-btn:hover { background: #3a4150; }
.pb-play { min-width: 36px; }
.pb-stop { color: #ef9a9a; }
.pb-slider { flex: 1; min-width: 80px; }
.pb-time { font-variant-numeric: tabular-nums; color: #b0bec5; }
.pb-speed {
background: #2a303c;
border: 1px solid #3a4150;
color: #eceff1;
border-radius: 4px;
padding: 2px 4px;
}
</style>
@@ -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));
}
</style>
@@ -0,0 +1,677 @@
<template>
<!-- 悬浮唤起按钮所有界面右下角常驻 -->
<button
v-show="!open"
class="ai-fab"
type="button"
title="AI 分析助手(故障 / 日志 / 数据分析)"
@click="toggle(true)"
>
<span class="ai-fab-glyph"></span>
</button>
<!-- 右侧抽屉 -->
<section
class="ai-drawer"
:class="{ 'is-open': open }"
:style="{ width: width + 'px' }"
role="complementary"
aria-label="AI 助手"
>
<div
class="ai-resizer"
title="拖拽调整宽度"
@pointerdown="onResizeStart"
@pointermove="onResizeMove"
@pointerup="onResizeEnd"
@pointercancel="onResizeEnd"
/>
<header class="ai-head">
<div class="ai-head-title">
<span class="ai-head-glyph"></span>
<div class="ai-head-text">
<div class="ai-head-main">AI 分析助手</div>
<div class="ai-head-sub">故障 / 日志 / 数据分析 · 操作答疑</div>
</div>
</div>
<div class="ai-head-actions">
<button class="ai-iconbtn" title="新建会话" @click="onNew">
<el-icon><Plus /></el-icon>
</button>
<button class="ai-iconbtn" :class="{ active: showSessions }" title="历史会话" @click="toggleSessions">
<el-icon><ChatLineSquare /></el-icon>
</button>
<button class="ai-iconbtn" title="收起" @click="toggle(false)">
<el-icon><Close /></el-icon>
</button>
</div>
<!-- 历史会话面板 -->
<div v-if="showSessions" class="ai-sessions">
<div class="ai-sessions-head">
<span>历史会话</span>
<button class="ai-link" @click="onNew">+ 新建</button>
</div>
<div v-if="sessions.length === 0" class="ai-sessions-empty">暂无历史会话</div>
<ul v-else class="ai-sessions-list">
<li
v-for="s in sessions"
:key="s.id"
class="ai-session-item"
:class="{ active: s.id === currentSessionId }"
@click="onPickSession(s.id)"
>
<div class="ai-session-main">
<div class="ai-session-title">{{ s.title || '未命名会话' }}</div>
<div class="ai-session-meta">{{ s.turns }} · {{ formatTime(s.updated) }}</div>
</div>
<button class="ai-session-del" title="删除" @click.stop="onDeleteSession(s.id)">
<el-icon><Delete /></el-icon>
</button>
</li>
</ul>
</div>
</header>
<div ref="listRef" class="ai-body" @click="showSessions = false">
<div v-if="messages.length === 0" class="ai-empty">
<div class="ai-empty-glyph"></div>
<div class="ai-empty-title">我帮你做故障 / 日志 / 数据分析</div>
<div class="ai-empty-tip">试着这样问</div>
<button v-for="(ex, i) in examples" :key="i" class="ai-example" @click="useExample(ex)">
{{ ex }}
</button>
</div>
<div v-for="m in messages" :key="m.id" class="ai-msg" :class="`ai-msg--${m.role}`">
<div class="ai-bubble">
<div v-if="m.role === 'user'" class="ai-user-text">{{ m.content }}</div>
<template v-else>
<!-- 工具调用卡片 -->
<div v-if="m.tools.length" class="ai-tools">
<details v-for="(t, ti) in m.tools" :key="ti" class="ai-tool" :class="`is-${t.status}`">
<summary class="ai-tool-sum">
<span class="ai-tool-ic">
<el-icon v-if="t.status === 'running'" class="is-loading"><Loading /></el-icon>
<el-icon v-else-if="t.status === 'ok'"><Select /></el-icon>
<el-icon v-else><WarningFilled /></el-icon>
</span>
<span class="ai-tool-name">{{ t.name }}</span>
<span class="ai-tool-badge" :class="t.isWrite ? 'write' : 'read'">{{ t.isWrite ? '写' : '读' }}</span>
</summary>
<div class="ai-tool-body">
<div v-if="t.args !== undefined" class="ai-tool-kv">
<span class="ai-tool-k">参数</span>
<pre class="ai-tool-pre">{{ toJson(t.args) }}</pre>
</div>
<div v-if="t.result !== undefined" class="ai-tool-kv">
<span class="ai-tool-k">结果</span>
<pre class="ai-tool-pre">{{ toJson(t.result) }}</pre>
</div>
</div>
</details>
</div>
<!-- 助手正文Markdown -->
<div v-if="m.content" class="ai-md" v-html="render(m.content)" />
<span v-if="m.streaming && m.content" class="ai-caret" />
<div v-if="m.streaming && !m.content && m.tools.length === 0" class="ai-thinking">
<el-icon class="is-loading"><Loading /></el-icon><span>正在思考</span>
</div>
<!-- 错误 -->
<div v-if="m.error" class="ai-error">
<el-icon><WarningFilled /></el-icon>
<span>{{ m.error }}</span>
</div>
</template>
</div>
</div>
</div>
<footer class="ai-foot">
<el-input
v-model="draft"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
resize="none"
placeholder="描述你想分析的问题,例如:分析当前报警车辆的原因(Enter 发送,Shift+Enter 换行)"
@keydown="onKeydown"
/>
<div class="ai-foot-row">
<span class="ai-foot-hint">{{ isStreaming ? 'AI 正在响应…' : '故障 / 日志 / 数据分析 · 操作答疑(只读)' }}</span>
<el-button v-if="isStreaming" type="danger" plain size="small" @click="onStop">停止</el-button>
<el-button v-else type="primary" size="small" :disabled="!draft.trim()" @click="onSend">发送</el-button>
</div>
</footer>
</section>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue'
import {
Plus,
Close,
Delete,
Loading,
Select,
WarningFilled,
ChatLineSquare
} from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import { renderMarkdown } from '@/utils/miniMarkdown'
import { useAssistantChat } from '@/composables/useAssistantChat'
const OPEN_KEY = 'assistant.drawer.open'
const WIDTH_KEY = 'assistant.drawer.width'
const MIN_W = 340
const MAX_W = 760
const { messages, sessions, currentSessionId, isStreaming, ensureInit, loadSession, newSession, removeSession, send, stop } =
useAssistantChat()
const open = ref(localStorage.getItem(OPEN_KEY) === '1')
const width = ref(clampWidth(Number(localStorage.getItem(WIDTH_KEY)) || 420))
const draft = ref('')
const showSessions = ref(false)
const listRef = ref<HTMLElement | null>(null)
const examples = [
'现在有哪些车辆在报警?帮我分析原因和处理建议',
'汇总最近的诊断日志,有哪些异常?',
'分析一下当前车队的健康状况',
'1 号车为什么停了?帮我排查'
]
function clampWidth(w: number): number {
return Math.min(MAX_W, Math.max(MIN_W, w))
}
function toggle(v: boolean): void {
open.value = v
try {
localStorage.setItem(OPEN_KEY, v ? '1' : '0')
} catch {
/* ignore */
}
if (v) void ensureInit()
}
function toggleSessions(): void {
showSessions.value = !showSessions.value
}
function onNew(): void {
newSession()
showSessions.value = false
draft.value = ''
}
async function onPickSession(id: string): Promise<void> {
showSessions.value = false
if (id === currentSessionId.value) return
await loadSession(id)
void scrollToBottom()
}
async function onDeleteSession(id: string): Promise<void> {
try {
await ElMessageBox.confirm('确定删除该会话?', '删除会话', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
} catch {
return
}
await removeSession(id)
}
function useExample(ex: string): void {
draft.value = ex
}
function onKeydown(e: Event | KeyboardEvent): void {
if (!(e instanceof KeyboardEvent)) return
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
void onSend()
}
}
async function onSend(): Promise<void> {
const text = draft.value.trim()
if (!text || isStreaming.value) return
draft.value = ''
void scrollToBottom()
await send(text)
}
function onStop(): void {
stop()
}
function toJson(v: unknown): string {
try {
const s = JSON.stringify(v, null, 2)
return s.length > 4000 ? s.slice(0, 4000) + '\n… (已截断)' : s
} catch {
return String(v)
}
}
function render(s: string): string {
return renderMarkdown(s)
}
function formatTime(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function scrollToBottom(): Promise<void> {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
}
// /
watch(
messages,
() => {
void scrollToBottom()
},
{ deep: true }
)
// 沿
let resizing = false
let startX = 0
let startW = 0
function onResizeStart(e: PointerEvent): void {
resizing = true
startX = e.clientX
startW = width.value
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
e.preventDefault()
}
function onResizeMove(e: PointerEvent): void {
if (!resizing) return
width.value = clampWidth(startW + (startX - e.clientX))
}
function onResizeEnd(e: PointerEvent): void {
if (!resizing) return
resizing = false
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
try {
localStorage.setItem(WIDTH_KEY, String(width.value))
} catch {
/* ignore */
}
}
onMounted(() => {
if (open.value) void ensureInit()
})
</script>
<style scoped>
/* ─────────────── FAB ─────────────── */
.ai-fab {
position: fixed;
right: 22px;
bottom: 28px;
z-index: 1800;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.5);
background: linear-gradient(135deg, var(--mg-primary, #7c3aed) 0%, var(--mg-primary-active, #5b21b6) 100%);
color: #fff;
cursor: pointer;
box-shadow: 0 10px 28px rgba(91, 33, 182, 0.5), 0 0 0 0 rgba(124, 58, 237, 0.5);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
animation: ai-fab-pulse 3.6s ease-in-out infinite;
}
.ai-fab:hover {
transform: translateY(-2px) scale(1.06);
box-shadow: 0 14px 36px rgba(124, 58, 237, 0.6);
}
.ai-fab-glyph {
font-size: 24px;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.6);
}
@keyframes ai-fab-pulse {
0%, 100% { box-shadow: 0 10px 28px rgba(91, 33, 182, 0.5), 0 0 0 0 rgba(124, 58, 237, 0.45); }
50% { box-shadow: 0 10px 28px rgba(91, 33, 182, 0.5), 0 0 0 12px rgba(124, 58, 237, 0); }
}
/* ─────────────── Drawer ─────────────── */
.ai-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 1900;
display: flex;
flex-direction: column;
max-width: 96vw;
background: linear-gradient(180deg, rgba(28, 14, 56, 0.99) 0%, rgba(16, 7, 34, 0.99) 100%);
border-left: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.28);
box-shadow: -14px 0 40px rgba(8, 2, 16, 0.6);
color: rgba(236, 224, 250, 0.95);
transform: translateX(100%);
opacity: 0;
visibility: hidden;
transition: transform 0.28s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.28s ease, visibility 0.28s;
}
.ai-drawer.is-open {
transform: translateX(0);
opacity: 1;
visibility: visible;
}
.ai-resizer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 5;
touch-action: none;
transition: background 0.15s ease;
}
.ai-resizer:hover {
background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.45);
}
/* ─────────────── Header ─────────────── */
.ai-head {
position: relative;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.55) 0%, rgba(168, 85, 247, 0.4) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.ai-head-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.ai-head-glyph { font-size: 19px; color: #fff; text-shadow: 0 0 10px rgba(255, 200, 250, 0.7); flex: none; }
.ai-head-text { min-width: 0; }
.ai-head-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.ai-head-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; white-space: nowrap; }
.ai-head-actions { display: flex; align-items: center; gap: 6px; flex: none; }
.ai-iconbtn {
appearance: none;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 28px;
height: 28px;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: background 0.15s ease;
}
.ai-iconbtn:hover, .ai-iconbtn.active { background: rgba(255, 255, 255, 0.26); }
/* Sessions popover */
.ai-sessions {
position: absolute;
top: 56px;
right: 12px;
width: 280px;
max-height: 360px;
overflow: auto;
background: rgba(22, 11, 44, 0.99);
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.3);
border-radius: 12px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
z-index: 20;
padding: 8px;
}
.ai-sessions-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: rgba(210, 188, 240, 0.8);
padding: 4px 6px 8px;
}
.ai-link { appearance: none; border: 0; background: transparent; color: var(--mg-accent, #c4b5fd); cursor: pointer; font-size: 12px; }
.ai-sessions-empty { padding: 16px; text-align: center; color: rgba(210, 188, 240, 0.5); font-size: 12px; }
.ai-sessions-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-session-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.ai-session-item:hover { background: rgba(255, 255, 255, 0.06); }
.ai-session-item.active { background: rgba(124, 58, 237, 0.28); }
.ai-session-main { min-width: 0; flex: 1; }
.ai-session-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ai-session-meta { font-size: 11px; color: rgba(210, 188, 240, 0.6); margin-top: 2px; }
.ai-session-del {
appearance: none; border: 0; background: transparent; color: rgba(255, 160, 180, 0.7);
cursor: pointer; width: 24px; height: 24px; border-radius: 6px; flex: none;
display: inline-flex; align-items: center; justify-content: center;
}
.ai-session-del:hover { background: rgba(255, 90, 110, 0.2); color: #ff8aa0; }
/* ─────────────── Body ─────────────── */
.ai-body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.3) transparent;
}
.ai-body::-webkit-scrollbar { width: 6px; }
.ai-body::-webkit-scrollbar-thumb { background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.28); border-radius: 3px; }
.ai-empty { text-align: center; padding: 28px 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.ai-empty-glyph { font-size: 34px; color: var(--mg-accent, #c4b5fd); text-shadow: 0 0 20px rgba(196, 181, 253, 0.5); }
.ai-empty-title { font-size: 15px; font-weight: 600; color: #fff; }
.ai-empty-tip { font-size: 12px; color: rgba(210, 188, 240, 0.65); margin-top: 4px; }
.ai-example {
appearance: none;
width: 100%;
text-align: left;
border: 1px dashed rgba(var(--mg-accent-rgb, 196, 181, 253), 0.4);
background: rgba(255, 255, 255, 0.04);
color: rgba(232, 215, 245, 0.92);
border-radius: 9px;
padding: 9px 11px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-example:hover { background: rgba(150, 90, 230, 0.22); border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.7); color: #fff; }
.ai-msg { display: flex; }
.ai-msg--user { justify-content: flex-end; }
.ai-msg--assistant { justify-content: flex-start; }
.ai-bubble {
max-width: 90%;
padding: 10px 12px;
border-radius: 13px;
font-size: 13.5px;
line-height: 1.6;
}
.ai-msg--user .ai-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.96) 0%, rgba(120, 70, 220, 0.96) 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.ai-msg--assistant .ai-bubble {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(236, 224, 250, 0.96);
border-bottom-left-radius: 4px;
}
.ai-user-text { white-space: pre-wrap; word-break: break-word; }
.ai-thinking { display: inline-flex; align-items: center; gap: 8px; color: rgba(210, 188, 240, 0.85); font-size: 13px; }
.ai-caret {
display: inline-block;
width: 7px;
height: 15px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--mg-accent, #c4b5fd);
animation: ai-blink 1s steps(2) infinite;
}
@keyframes ai-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
.ai-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 90, 110, 0.14);
border: 1px solid rgba(255, 90, 110, 0.35);
color: #ffb3c0;
font-size: 12.5px;
}
/* ─────────────── Tool cards ─────────────── */
.ai-tools { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.ai-tool {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9px;
background: rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.ai-tool.is-running { border-color: rgba(196, 181, 253, 0.5); }
.ai-tool.is-ok { border-color: rgba(80, 220, 160, 0.4); }
.ai-tool.is-error { border-color: rgba(255, 120, 120, 0.5); }
.ai-tool-sum {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
cursor: pointer;
list-style: none;
font-size: 12.5px;
}
.ai-tool-sum::-webkit-details-marker { display: none; }
.ai-tool-ic { display: inline-flex; align-items: center; color: rgba(220, 205, 250, 0.9); }
.ai-tool.is-ok .ai-tool-ic { color: #4fe0a0; }
.ai-tool.is-error .ai-tool-ic { color: #ff8a8a; }
.ai-tool-name { font-family: var(--mg-font-mono, monospace); color: #fff; font-weight: 600; flex: 1; min-width: 0; }
.ai-tool-badge {
flex: none;
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
font-weight: 600;
}
.ai-tool-badge.read { background: rgba(96, 165, 250, 0.2); color: #93c5fd; border: 1px solid rgba(96, 165, 250, 0.4); }
.ai-tool-badge.write { background: rgba(251, 146, 60, 0.2); color: #fdba74; border: 1px solid rgba(251, 146, 60, 0.45); }
.ai-tool-body { padding: 0 10px 8px; }
.ai-tool-kv { margin-top: 6px; }
.ai-tool-k { font-size: 11px; color: rgba(210, 188, 240, 0.65); }
.ai-tool-pre {
margin: 3px 0 0;
padding: 7px 9px;
background: rgba(0, 0, 0, 0.35);
border-radius: 7px;
font-size: 11.5px;
font-family: var(--mg-font-mono, monospace);
color: rgba(220, 230, 245, 0.92);
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow: auto;
}
/* ─────────────── Markdown ─────────────── */
.ai-md :deep(p) { margin: 0 0 8px; }
.ai-md :deep(p:last-child) { margin-bottom: 0; }
.ai-md :deep(.mmd-h) { margin: 10px 0 6px; font-weight: 700; color: #fff; line-height: 1.3; }
.ai-md :deep(h3.mmd-h) { font-size: 15px; }
.ai-md :deep(h4.mmd-h) { font-size: 14px; }
.ai-md :deep(h5.mmd-h) { font-size: 13px; }
.ai-md :deep(.mmd-ul), .ai-md :deep(.mmd-ol) { margin: 4px 0 8px; padding-left: 20px; }
.ai-md :deep(li) { margin: 2px 0; }
.ai-md :deep(.mmd-code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 1px 5px;
}
.ai-md :deep(.mmd-pre) {
margin: 6px 0;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
overflow: auto;
max-height: 320px;
}
.ai-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
color: rgba(220, 230, 245, 0.95);
white-space: pre;
}
.ai-md :deep(.mmd-link) { color: var(--mg-accent, #c4b5fd); text-decoration: underline; }
.ai-md :deep(.mmd-quote) {
margin: 6px 0;
padding: 4px 12px;
border-left: 3px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.6);
color: rgba(220, 205, 250, 0.85);
}
.ai-md :deep(strong) { color: #fff; }
/* ─────────────── Footer ─────────────── */
.ai-foot {
flex: none;
padding: 10px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-foot :deep(.el-textarea__inner) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.25);
color: #fff;
box-shadow: none;
}
.ai-foot :deep(.el-textarea__inner:focus) {
border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.6);
}
.ai-foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ai-foot-hint { font-size: 11px; color: rgba(210, 188, 240, 0.6); }
@media (max-width: 560px) {
.ai-drawer { width: 100vw !important; }
}
</style>
@@ -51,9 +51,11 @@
:class="`aap-msg--${m.role}`"
>
<div class="aap-bubble">
<div class="aap-bubble-text">{{ m.text }}</div>
<div v-if="m.role === 'user'" class="aap-bubble-text">{{ m.text }}</div>
<div v-else class="aap-md" v-html="renderMd(m.text)"></div>
<div v-if="m.role === 'assistant' && m.meta" class="aap-meta">
落地对象 <b>{{ m.meta.created }}</b> · 工具调用 <b>{{ m.meta.usedTools }}</b>
<el-icon class="aap-meta-ic"><CircleCheck /></el-icon>
已落地 <b>{{ m.meta.created }}</b> 个对象 · 工具调用 <b>{{ m.meta.usedTools }}</b>
</div>
</div>
</div>
@@ -99,10 +101,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { Loading, CircleCheck } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
import { renderMarkdown } from '@/utils/miniMarkdown'
const props = defineProps<{
/** 面板是否展开(停靠在右侧)。 */
@@ -198,6 +201,10 @@ function useExample(ex: string) {
draft.value = ex
}
function renderMd(s: string): string {
return renderMarkdown(s)
}
function onKeydown(e: Event | KeyboardEvent) {
// el-input keydown Event | KeyboardEvent
// Enter Shift+Enter isComposing
@@ -387,6 +394,48 @@ async function send() {
color: rgba(210, 188, 240, 0.8);
}
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
.aap-meta-ic { color: #6ee7b7; margin-right: 4px; vertical-align: -2px; }
/* assistant 消息 Markdown 渲染 */
.aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: rgba(236, 224, 250, 0.95); }
.aap-md :deep(p) { margin: 0 0 7px; }
.aap-md :deep(p:last-child) { margin-bottom: 0; }
.aap-md :deep(.mmd-h) { margin: 8px 0 5px; font-weight: 700; color: #fff; }
.aap-md :deep(h3.mmd-h) { font-size: 14px; }
.aap-md :deep(h4.mmd-h) { font-size: 13px; }
.aap-md :deep(.mmd-ul), .aap-md :deep(.mmd-ol) { margin: 4px 0 7px; padding-left: 18px; }
.aap-md :deep(li) { margin: 2px 0; }
.aap-md :deep(.mmd-code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 1px 5px;
}
.aap-md :deep(.mmd-pre) {
margin: 6px 0;
padding: 9px 11px;
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: auto;
max-height: 280px;
}
.aap-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
color: rgba(220, 230, 245, 0.95);
white-space: pre;
}
.aap-md :deep(.mmd-link) { color: var(--mg-accent, #c4a4ff); text-decoration: underline; }
.aap-md :deep(.mmd-quote) {
margin: 6px 0;
padding: 3px 10px;
border-left: 3px solid rgba(190, 140, 240, 0.6);
color: rgba(220, 205, 250, 0.85);
}
.aap-md :deep(strong) { color: #fff; }
.aap-bubble--loading {
display: inline-flex;
align-items: center;
@@ -0,0 +1,168 @@
<template>
<el-dialog
:model-value="open"
title="选择字段增加"
width="520px"
append-to-body
destroy-on-close
class="better-add-field-dialog"
@update:model-value="onOpenChange"
>
<p class="baf-hint">
目标{{ targetSummary }}选择场景已有字段再输入要批量写入的值对齐 Ctrl+I
</p>
<el-input
v-model="search"
clearable
placeholder="搜索字段名"
class="baf-search"
/>
<div v-loading="loadingKeys" class="baf-list">
<button
v-for="key in filteredKeys"
:key="key"
type="button"
class="baf-key"
:class="{ 'is-active': key === pickedKey }"
@click="pickedKey = key"
>
{{ key }}
</button>
<div v-if="!loadingKeys && filteredKeys.length === 0" class="baf-empty">
{{ keys.length === 0 ? '当前场景还没有任何自定义字段可选' : '无匹配字段' }}
</div>
</div>
<el-input
v-model="value"
:disabled="!pickedKey"
:placeholder="pickedKey ? `输入字段 ${pickedKey} 的值` : '先选择字段'"
class="baf-value"
@keydown.enter.prevent="confirm"
/>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="applying" :disabled="!pickedKey" @click="confirm">
写入选中对象
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { mapEditApi } from '@/api/mapEdit'
const props = defineProps<{
open: boolean
targets: Array<{ kind: string; id: number }>
targetSummary: string
}>()
const emit = defineEmits<{
'update:open': [v: boolean]
applied: [payload: { key: string; value: string; affected: number }]
}>()
const keys = ref<string[]>([])
const loadingKeys = ref(false)
const search = ref('')
const pickedKey = ref('')
const value = ref('')
const applying = ref(false)
const filteredKeys = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return keys.value
return keys.value.filter((k) => k.toLowerCase().includes(q))
})
watch(
() => props.open,
async (v) => {
if (!v) return
search.value = ''
pickedKey.value = ''
value.value = ''
loadingKeys.value = true
try {
const r = await mapEditApi.sceneFieldKeys()
keys.value = r.keys ?? []
} catch (err) {
keys.value = []
ElMessage.error(`加载字段列表失败:${(err as Error).message}`)
} finally {
loadingKeys.value = false
}
}
)
function onOpenChange(v: boolean) {
emit('update:open', v)
}
function close() {
emit('update:open', false)
}
async function confirm() {
if (!pickedKey.value || props.targets.length === 0) return
applying.value = true
try {
const r = await mapEditApi.batchSetField(pickedKey.value, value.value, props.targets)
ElMessage.success(`已对 ${r.affected}/${props.targets.length} 个对象设置 ${pickedKey.value}`)
emit('applied', { key: pickedKey.value, value: value.value, affected: r.affected })
close()
} catch (err) {
ElMessage.error(`写入失败:${(err as Error).message}`)
} finally {
applying.value = false
}
}
</script>
<style scoped>
.baf-hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.45;
color: var(--el-text-color-secondary);
}
.baf-search { margin-bottom: 10px; width: 100%; }
.baf-list {
min-height: 180px;
max-height: 280px;
overflow: auto;
border: 1px solid var(--el-border-color);
border-radius: 8px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 12px;
}
.baf-key {
appearance: none;
border: 0;
background: transparent;
text-align: left;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
}
.baf-key:hover { background: var(--el-fill-color-light); }
.baf-key.is-active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 600;
}
.baf-empty {
padding: 28px 12px;
text-align: center;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.baf-value { width: 100%; }
</style>
@@ -16,11 +16,8 @@
<div v-else class="object-pane">
<!-- Header cardID / 类型 / 名称 / 图层 / 删除按钮 -->
<section class="prop-card prop-card--header">
<div class="prop-card-row">
<div class="prop-id-block">
<span class="prop-id-tag">{{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }}</span>
<span class="prop-type-tag">{{ primary?.typeName ?? '-' }}</span>
</div>
<header class="prop-card-header">
<span class="prop-card-title">基础属性</span>
<div class="prop-header-actions">
<el-tag
v-if="primary?.status"
@@ -42,11 +39,18 @@
:disabled="deleting"
@click="onDelete"
>
<span class="prop-delete-icon">{{ deleting ? '⟳' : '🗑' }}</span>
<span class="prop-delete-icon">{{ deleting ? '⟳' : '×' }}</span>
<span>删除</span>
</button>
</el-tooltip>
</div>
</header>
<div class="prop-card-row prop-id-row">
<div class="prop-id-block">
<span class="prop-id-tag">{{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }}</span>
<span class="prop-type-tag">{{ primary?.typeName ?? '-' }}</span>
</div>
</div>
<div class="prop-card-row prop-name-row">
@@ -863,17 +867,17 @@ function onDelete() {
</script>
<style scoped>
/* Style 2 分组卡片 + Style 5 主题色相(--me-* / --mg-primary */
.edit-property-panel {
height: 100%;
width: 360px;
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
border-left: 1px solid rgba(255,255,255,0.10);
background: linear-gradient(180deg, var(--me-chrome-1) 0%, var(--me-chrome-2) 100%);
border-left: 1px solid var(--me-border);
padding: 0;
backdrop-filter: blur(10px);
overflow: hidden;
display: flex;
flex-direction: column;
color: rgba(232,215,245,0.92);
color: var(--me-text);
}
.prop-tabs {
height: 100%;
@@ -882,146 +886,205 @@ function onDelete() {
}
.prop-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.prop-tabs :deep(.el-tabs__nav-wrap)::after {
background: transparent;
padding: 6px 10px 0;
border-bottom: 1px solid var(--me-border);
background: var(--me-surface);
}
.prop-tabs :deep(.el-tabs__nav-wrap)::after { background: transparent; }
.prop-tabs :deep(.el-tabs__item) {
color: rgba(220, 200, 240, 0.7);
color: var(--me-text-muted);
font-weight: 500;
font-size: 13px;
height: 36px;
line-height: 36px;
height: 34px;
line-height: 34px;
padding: 0 14px !important;
}
.prop-tabs :deep(.el-tabs__item.is-active) {
color: #fff;
color: var(--mg-primary);
font-weight: 700;
}
.prop-tabs :deep(.el-tabs__active-bar) {
background: linear-gradient(90deg, rgba(170, 110, 250, 1) 0%, rgba(255, 110, 220, 1) 100%);
background: var(--mg-primary);
height: 2px;
border-radius: 2px;
}
.prop-tabs :deep(.el-tabs__content) {
flex: 1;
overflow: auto;
padding: 10px 12px 14px;
padding: 12px;
}
.empty-hint {
display: flex; align-items: center; gap: 6px;
padding: 56px 16px; text-align: center;
color: rgba(220, 200, 240, 0.55);
display: flex;
align-items: center;
gap: 8px;
padding: 48px 16px;
text-align: center;
color: var(--me-text-muted);
flex-direction: column;
}
.empty-icon { font-size: 28px; color: rgba(180, 130, 220, 0.45); }
.empty-title { font-size: 13px; color: rgba(232, 215, 245, 0.75); font-weight: 500; }
.empty-desc { font-size: 11.5px; color: rgba(220, 200, 240, 0.5); }
.empty-icon { font-size: 28px; color: rgba(var(--mg-primary-rgb), 0.45); }
.empty-title { font-size: 13px; color: var(--me-text); font-weight: 600; }
.empty-desc { font-size: 11.5px; color: var(--me-text-muted); max-width: 220px; line-height: 1.45; }
.loading-hint {
display: flex; align-items: center; gap: 8px;
padding: 32px 16px; text-align: center;
color: rgba(232,215,245,0.65); font-size: 12.5px;
display: flex;
align-items: center;
gap: 8px;
padding: 32px 16px;
justify-content: center;
color: var(--me-text-muted);
font-size: 12.5px;
}
.object-pane {
.object-pane,
.defaults-pane {
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
/* 分组卡片:跟 --me-card-*Harbor 浅壳 / 紫蓝深壳共用) */
.prop-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.20);
position: relative;
background: var(--me-card-bg);
border: 1px solid var(--me-card-border);
border-radius: 12px;
padding: 12px 12px 10px 14px;
box-shadow: 0 4px 14px rgba(var(--mg-shadow-rgb, 22, 58, 74), 0.08);
}
.prop-card::before {
content: '';
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 0 3px 3px 0;
background: rgba(var(--mg-primary-rgb), 0.55);
}
.prop-card--header {
background: linear-gradient(140deg, rgba(170, 110, 250, 0.18) 0%, rgba(70, 30, 110, 0.10) 100%);
border-color: rgba(170, 110, 250, 0.28);
background: linear-gradient(
135deg,
rgba(var(--mg-primary-rgb), 0.12) 0%,
var(--me-card-bg) 55%
);
border-color: rgba(var(--mg-primary-rgb), 0.28);
box-shadow: 0 4px 16px rgba(var(--mg-primary-rgb), 0.08);
}
.prop-card--geom {
background: linear-gradient(140deg, rgba(80, 140, 220, 0.10) 0%, rgba(40, 70, 130, 0.06) 100%);
border-color: rgba(120, 170, 230, 0.22);
.prop-card--header::before {
background: var(--mg-primary);
top: 8px;
bottom: 8px;
width: 3px;
}
.prop-card--style {
background: linear-gradient(140deg, rgba(220, 110, 200, 0.10) 0%, rgba(150, 70, 130, 0.06) 100%);
border-color: rgba(230, 130, 200, 0.22);
.prop-card--geom,
.prop-card--style,
.prop-card--custom,
.prop-card--status,
.prop-card--actions {
background: var(--me-card-bg);
border-color: var(--me-card-border);
}
.prop-card--custom {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(170, 110, 250, 0.18);
}
.prop-card--status { background: rgba(80, 140, 220, 0.06); border-color: rgba(120, 170, 230, 0.18); }
.prop-card--actions { background: rgba(140, 80, 200, 0.06); border-color: rgba(170, 110, 250, 0.18); }
.prop-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
min-height: 22px;
padding-bottom: 8px;
border-bottom: 1px solid var(--me-card-border);
}
.prop-card-title {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 1px;
color: rgba(220, 180, 250, 0.85);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--me-text);
}
.prop-card-meta {
font-size: 10.5px;
color: rgba(200, 180, 220, 0.5);
margin-left: auto;
font-size: 11px;
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
color: var(--me-text-muted);
background: var(--me-inset);
border: 1px solid var(--me-card-border);
border-radius: 999px;
padding: 1px 8px;
}
.prop-card-flag {
display: inline-flex; align-items: center; gap: 4px;
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
color: rgba(180, 200, 240, 0.6);
padding: 2px 7px;
border-radius: 10px;
background: rgba(120, 160, 220, 0.10);
transition: background .25s ease, color .25s ease, box-shadow .25s ease;
color: var(--me-text-muted);
padding: 2px 8px;
border-radius: 999px;
background: var(--me-surface);
border: 1px solid var(--me-card-border);
margin-left: auto;
}
.prop-card-flag.is-synced {
background: linear-gradient(120deg, rgba(80, 200, 130, 0.32), rgba(120, 220, 200, 0.32));
color: rgba(220, 255, 230, 0.95);
box-shadow: 0 0 8px rgba(80, 200, 130, 0.45);
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.35);
color: #15803d;
box-shadow: none;
}
.prop-card-flag-dot {
width: 6px; height: 6px; border-radius: 50%;
background: rgba(180, 200, 240, 0.6);
transition: background .25s ease, box-shadow .25s ease;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--me-text-muted);
}
.prop-card-flag.is-synced .prop-card-flag-dot {
background: #6ce7a8;
box-shadow: 0 0 6px rgba(108, 231, 168, 0.85);
background: #4ade80;
box-shadow: none;
}
.prop-card-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
gap: 8px;
}
.prop-card-row:last-child { margin-bottom: 0; }
.prop-id-row { margin-bottom: 10px; }
.prop-id-block { display: flex; align-items: center; gap: 6px; }
.prop-id-block {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.prop-id-tag {
background: rgba(170, 110, 250, 0.30);
background: var(--mg-primary);
color: #fff;
padding: 2px 8px;
font-size: 11.5px;
font-weight: 600;
border-radius: 4px;
letter-spacing: 0.5px;
padding: 3px 9px;
font-size: 12px;
font-weight: 700;
border-radius: 6px;
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
}
.prop-type-tag {
color: rgba(232, 215, 245, 0.75);
font-size: 11.5px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--me-text-muted);
font-size: 12px;
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
background: var(--me-inset);
border: 1px solid var(--me-card-border);
border-radius: 6px;
padding: 2px 8px;
}
.prop-status-tag { font-weight: 500; }
.prop-status-tag { font-weight: 600; }
.prop-header-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.prop-delete-btn {
@@ -1030,31 +1093,31 @@ function onDelete() {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 5px;
font-size: 11.5px;
font-weight: 500;
letter-spacing: 0.4px;
color: rgba(255, 200, 210, 0.95);
background: linear-gradient(135deg, rgba(255, 90, 120, 0.18) 0%, rgba(220, 60, 110, 0.22) 100%);
border: 1px solid rgba(255, 110, 140, 0.45);
transition: background .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 550;
color: #fecaca;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.35);
transition: background .12s ease, border-color .12s ease;
}
.prop-delete-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(255, 90, 120, 0.55) 0%, rgba(220, 60, 110, 0.65) 100%);
border-color: rgba(255, 140, 170, 0.85);
background: rgba(239, 68, 68, 0.22);
border-color: rgba(239, 68, 68, 0.55);
color: #fff;
box-shadow: 0 3px 10px rgba(220, 70, 110, 0.45);
box-shadow: none;
}
.prop-delete-btn:active:not(:disabled) { transform: scale(0.96); }
.prop-delete-btn:disabled { opacity: 0.55; cursor: progress; }
.prop-delete-icon { font-size: 12px; line-height: 1; }
.prop-name-row, .prop-layer-row, .prop-direction-row { gap: 8px; }
.prop-name-row,
.prop-layer-row,
.prop-direction-row { gap: 10px; }
.prop-name-label {
width: 36px;
font-size: 11.5px;
color: rgba(200, 180, 220, 0.7);
font-size: 12px;
color: var(--me-text-muted);
flex: none;
}
.prop-layer-select { flex: 1; }
@@ -1062,15 +1125,15 @@ function onDelete() {
.prop-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 8px;
row-gap: 8px;
column-gap: 10px;
row-gap: 10px;
}
.prop-grid-item { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.prop-grid-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.prop-grid-item--wide { grid-column: 1 / -1; }
.prop-field-label {
font-size: 11px;
color: rgba(200, 180, 230, 0.78);
letter-spacing: 0.3px;
color: var(--me-text-muted);
letter-spacing: 0.2px;
display: flex;
justify-content: space-between;
align-items: baseline;
@@ -1081,155 +1144,230 @@ function onDelete() {
}
.prop-field-unit {
font-size: 10px;
color: rgba(170, 150, 200, 0.55);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--me-text-muted);
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
flex: none;
}
.prop-field-num :deep(.el-input-number) { width: 100%; }
.prop-field-num :deep(.el-input__wrapper) {
background: rgba(255,255,255,0.06);
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
.prop-field-num :deep(.el-input__wrapper),
.prop-card :deep(.el-input__wrapper),
.prop-card :deep(.el-select__wrapper),
.prop-card :deep(.el-textarea__inner) {
background: var(--me-inset) !important;
box-shadow: inset 0 0 0 1px var(--me-card-border) !important;
}
.prop-field-row { display: flex; align-items: center; gap: 4px; }
.prop-field-num :deep(.el-input__inner),
.prop-card :deep(.el-input__inner),
.prop-card :deep(.el-textarea__inner),
.prop-card :deep(.el-select__selected-item),
.prop-card :deep(.el-select__placeholder) {
color: var(--me-text) !important;
}
.prop-field-num :deep(.el-input__wrapper:hover),
.prop-card :deep(.el-input__wrapper:hover),
.prop-card :deep(.el-select__wrapper:hover) {
box-shadow: inset 0 0 0 1px rgba(var(--mg-primary-rgb), 0.55) !important;
}
.prop-field-num :deep(.el-input__wrapper.is-focus),
.prop-card :deep(.el-input__wrapper.is-focus),
.prop-card :deep(.el-select__wrapper.is-focused) {
box-shadow:
inset 0 0 0 1px var(--mg-primary),
0 0 0 3px rgba(var(--mg-primary-rgb), 0.16) !important;
}
.prop-field-row { display: flex; align-items: center; gap: 6px; }
.prop-field-icon-btn {
appearance: none; border: 0; cursor: pointer;
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(255, 100, 130, 0.12);
color: rgba(255, 180, 200, 0.85);
font-size: 14px; line-height: 1;
display: inline-flex; align-items: center; justify-content: center;
appearance: none;
border: 1px solid rgba(239, 68, 68, 0.28);
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 7px;
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
font-size: 15px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
transition: background .14s, color .14s;
transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.prop-field-icon-btn:hover {
background: rgba(255, 100, 130, 0.32);
color: #fff;
background: rgba(239, 68, 68, 0.16);
border-color: rgba(239, 68, 68, 0.45);
color: #b91c1c;
}
.prop-empty-row {
font-size: 11.5px;
color: rgba(200, 180, 220, 0.4);
font-size: 12px;
color: var(--me-text-muted);
text-align: center;
padding: 8px 0;
padding: 14px 8px;
border-radius: 8px;
background: var(--me-inset);
border: 1px dashed var(--me-card-border);
}
.prop-add-field-btn {
appearance: none; border: 0; cursor: pointer;
appearance: none;
cursor: pointer;
width: 100%;
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
background: rgba(170, 110, 250, 0.14);
border: 1px dashed rgba(170, 110, 250, 0.40);
color: rgba(220, 180, 250, 0.92);
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--me-surface);
border: 1px dashed rgba(var(--mg-primary-rgb), 0.42);
color: var(--mg-primary);
font-size: 12px;
font-weight: 500;
transition: background .14s ease, border-color .14s ease, color .14s ease;
font-weight: 600;
transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.prop-add-field-btn:hover {
background: rgba(170, 110, 250, 0.26);
border-color: rgba(170, 110, 250, 0.65);
color: #fff;
.prop-add-field-btn:hover:not(:disabled) {
background: var(--me-hover);
border-color: rgba(var(--mg-primary-rgb), 0.7);
border-style: solid;
color: var(--mg-primary);
}
.prop-add-field-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.custom-field-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
gap: 8px;
}
.custom-field-item {
display: flex;
flex-direction: column;
gap: 3px;
gap: 5px;
min-width: 0;
padding: 9px 10px;
border-radius: 9px;
background: var(--me-inset);
border: 1px solid var(--me-card-border);
}
.custom-field-key {
margin: 0;
font-family: var(--mg-font-mono, ui-monospace, monospace);
font-size: 11px;
font-weight: 500;
color: rgba(200, 180, 230, 0.88);
font-weight: 650;
letter-spacing: 0.02em;
color: var(--me-text);
white-space: normal;
word-break: break-all;
line-height: 1.35;
cursor: default;
}
.custom-field-input {
flex: 1;
min-width: 0;
width: 100%;
}
.custom-field-input { flex: 1; min-width: 0; width: 100%; }
.custom-field-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
background: var(--me-card-bg) !important;
box-shadow: inset 0 0 0 1px var(--me-card-border) !important;
min-height: 30px;
}
.custom-field-input :deep(.el-input__inner) {
color: var(--me-text) !important;
font-weight: 550;
font-variant-numeric: tabular-nums;
}
.custom-field-input :deep(.el-input__wrapper:hover) {
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.32);
box-shadow: inset 0 0 0 1px rgba(var(--mg-primary-rgb), 0.45) !important;
}
.custom-field-input :deep(.el-input__wrapper.is-focus) {
box-shadow: inset 0 0 0 1px rgba(190, 130, 255, 0.55);
box-shadow:
inset 0 0 0 1px var(--mg-primary),
0 0 0 3px rgba(var(--mg-primary-rgb), 0.14) !important;
}
.add-field-select { width: 100%; }
.add-field-select {
width: 100%;
/* 运行状态:胶囊行 */
.prop-status-list {
display: flex;
flex-direction: column;
gap: 5px;
max-height: 280px;
overflow: auto;
padding-right: 2px;
}
.prop-status-list { display: flex; flex-direction: column; gap: 4px; }
.prop-status-item {
display: flex; justify-content: space-between; align-items: center;
padding: 5px 8px;
border-radius: 4px;
background: rgba(255,255,255,0.03);
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 8px;
background: var(--me-inset);
border: 1px solid var(--me-card-border);
font-size: 11.5px;
}
.prop-status-key { color: rgba(170, 200, 240, 0.85); font-weight: 500; }
.prop-status-value {
color: rgba(232, 245, 255, 0.95);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
max-width: 60%;
.prop-status-item:nth-child(even) {
background: rgba(var(--mg-primary-rgb), 0.08);
}
.prop-status-key {
color: var(--me-text-muted);
font-weight: 600;
flex: none;
max-width: 46%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prop-status-value {
color: var(--me-text);
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
max-width: 54%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.prop-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
gap: 8px;
}
.prop-action-btn {
appearance: none; border: 0; cursor: pointer;
padding: 7px 10px;
border-radius: 7px;
background: linear-gradient(135deg, rgba(170, 110, 250, 0.20) 0%, rgba(120, 70, 220, 0.20) 100%);
border: 1px solid rgba(170, 110, 250, 0.35);
color: rgba(232, 215, 245, 0.95);
appearance: none;
cursor: pointer;
padding: 9px 11px;
border-radius: 8px;
background: var(--mg-primary);
border: 1px solid var(--mg-primary);
color: #fff;
font-size: 12px;
font-weight: 500;
display: flex; align-items: center; justify-content: flex-start; gap: 6px;
transition: background .14s ease, border-color .14s ease, transform .12s ease, box-shadow .14s ease;
font-weight: 600;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
text-align: left;
transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, transform .12s ease;
}
.prop-action-btn:nth-child(2n) {
background: var(--me-card-bg);
border-color: rgba(var(--mg-primary-rgb), 0.38);
color: var(--mg-primary);
}
.prop-action-btn:nth-child(2n) .prop-action-icon {
color: var(--mg-primary);
}
.prop-action-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(170, 110, 250, 0.62) 0%, rgba(255, 110, 220, 0.55) 100%);
border-color: rgba(255, 130, 220, 0.7);
background: var(--mg-primary-hover, var(--mg-primary));
border-color: var(--mg-primary-hover, var(--mg-primary));
color: #fff;
box-shadow: 0 4px 12px rgba(180, 90, 220, 0.40);
box-shadow: 0 4px 12px var(--me-active-glow);
transform: translateY(-1px);
}
.prop-action-btn:active:not(:disabled) { transform: scale(0.985); }
.prop-action-btn:disabled { opacity: 0.6; cursor: progress; }
.prop-action-icon {
font-size: 9px;
color: rgba(255, 200, 250, 0.85);
flex: none;
.prop-action-btn:nth-child(2n):hover:not(:disabled) {
background: rgba(var(--mg-primary-rgb), 0.1);
border-color: var(--mg-primary);
color: var(--mg-primary);
box-shadow: 0 2px 8px var(--me-active-glow);
}
.prop-action-btn:nth-child(2n):hover:not(:disabled) .prop-action-icon {
color: var(--mg-primary);
}
.prop-action-btn:disabled { opacity: 0.55; cursor: progress; transform: none; }
.prop-action-icon { font-size: 9px; color: rgba(255, 255, 255, 0.92); flex: none; }
.prop-action-spinner {
display: inline-block;
animation: prop-spin .8s linear infinite;
@@ -1237,56 +1375,63 @@ function onDelete() {
}
.prop-action-label {
flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes prop-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
to { transform: rotate(360deg); }
}
.defaults-pane { display: flex; flex-direction: column; gap: 10px; }
.defaults-alert { margin-bottom: 4px; }
.defaults-empty { padding: 40px 12px; }
.defaults-alert { margin-bottom: 0; }
.defaults-empty { padding: 32px 12px; }
.defaults-form .el-button { margin-top: 8px; }
.defaults-form :deep(.el-form-item) { margin-bottom: 10px; }
/* 图层 Tab:白底数据块(深侧栏上的可读「岛」),各主题下表头/单元格均用深字 */
.defaults-form :deep(.el-form-item__label) { color: var(--me-text-muted); }
.layer-table {
margin: 6px 0;
border-radius: 8px;
margin: 0;
border-radius: 12px;
overflow: hidden;
background: #fff;
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.14);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
}
.layer-table :deep(.el-table__inner-wrapper) {
background: #fff;
.layer-table :deep(.el-table__inner-wrapper),
.layer-table :deep(.el-table),
.layer-table :deep(.el-table tr) {
background: transparent !important;
}
.layer-table :deep(th.el-table__cell) {
background: #f5f3ff !important;
color: #2d1b69 !important;
font-weight: 600;
background: rgba(var(--mg-primary-rgb), 0.16) !important;
color: var(--me-text) !important;
font-weight: 650;
border-color: rgba(255, 255, 255, 0.08) !important;
}
.layer-table :deep(td.el-table__cell) {
background: #fff !important;
color: #1a0f3d !important;
font-weight: 500;
}
.layer-table :deep(.cell) {
color: #1a0f3d !important;
background: transparent !important;
color: var(--me-text) !important;
border-color: rgba(255, 255, 255, 0.06) !important;
}
.layer-table :deep(.cell) { color: inherit !important; }
.layer-table :deep(tr:hover > td.el-table__cell) {
background: #ede9fe !important;
background: rgba(var(--mg-primary-rgb), 0.12) !important;
}
.new-layer { margin-top: 10px; }
.new-layer :deep(.el-input__wrapper) {
background: rgba(0, 0, 0, 0.22) !important;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10) !important;
}
.new-layer { margin-top: 8px; }
.new-layer :deep(.el-input__inner) {
color: #1a0f3d;
color: var(--me-text);
font-weight: 500;
}
.new-layer :deep(.el-input__inner::placeholder) {
color: #7a6b9a;
color: var(--me-text-muted);
}
.new-layer :deep(.el-input-group__append .el-button) {
color: #5b21b6;
color: var(--mg-accent);
font-weight: 600;
}
</style>
@@ -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; }
@@ -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);
}
</style>
@@ -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;
@@ -137,10 +137,13 @@ defineExpose({ refresh })
<style scoped>
.plugin-list-card {
background: rgba(30, 14, 55, 0.55) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(12px);
margin-bottom: 12px;
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.94) !important;
border: 1px solid var(--mg-veil-border, rgba(40, 33, 58, 0.10)) !important;
border-radius: 8px !important;
box-shadow: none !important;
backdrop-filter: none;
margin-bottom: 0;
flex: none;
}
.plg-header {
display: flex;
@@ -148,9 +151,9 @@ defineExpose({ refresh })
gap: 8px;
}
.plg-title {
font-size: 14px;
font-weight: 600;
color: var(--mg-text-light);
font-size: 12.5px;
font-weight: 650;
color: var(--el-text-color-primary, #28213a);
}
.plg-collapse { margin-left: auto; }
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
@@ -3,11 +3,14 @@
<PluginListPanel ref="pluginPanelRef" :visible="true" @changed="refresh" />
<div class="ref-mgr-header">
<h2 class="ref-mgr-title">{{ title }}</h2>
<div class="ref-mgr-heading">
<h2 class="ref-mgr-title">{{ shortTitle }}</h2>
<span class="ref-mgr-count">{{ filteredObjects.length }} / {{ objects.length }}</span>
</div>
<div class="ref-mgr-actions">
<el-input
v-model="search"
:placeholder="`搜索 ${kindLabel} (id / 名称 / 类型)`"
:placeholder="`搜索 ${kindLabel}`"
size="small"
clearable
class="ref-mgr-search"
@@ -77,6 +80,27 @@
</div>
</div>
<div v-if="typeChips.length > 1" class="ref-mgr-chips" role="tablist">
<button
type="button"
class="ref-chip"
:class="{ 'is-active': !typeFilter }"
@click="typeFilter = ''"
>
全部<b>{{ objects.length }}</b>
</button>
<button
v-for="chip in typeChips"
:key="chip.type"
type="button"
class="ref-chip"
:class="{ 'is-active': typeFilter === chip.type }"
@click="typeFilter = typeFilter === chip.type ? '' : chip.type"
>
{{ chip.short }}<b>{{ chip.count }}</b>
</button>
</div>
<div class="ref-mgr-body">
<!-- 列表 -->
<el-card shadow="never" class="ref-mgr-list-card">
@@ -86,15 +110,22 @@
:data="filteredObjects"
row-key="id"
stripe
border
size="small"
highlight-current-row
class="ref-mgr-table"
@current-change="onSelect"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="typeName" label="类型" width="160" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="72">
<template #default="{ row }">
<span class="col-mono">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="typeName" label="类型" width="140" show-overflow-tooltip>
<template #default="{ row }">
<span class="col-type">{{ shortTypeName(row.typeName) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showSummaryColumn"
prop="summary"
@@ -186,7 +217,7 @@
<div class="detail-tab-body">
<template v-if="detailTab === 'properties'">
<div class="field-section field-section--first">
<div class="field-section-title">强类型字段 ({{ typedFields.length }})</div>
<div class="field-section-title"><span>基础属性</span><b>{{ typedFields.length }}</b></div>
<el-descriptions
v-if="typedFields.length > 0"
:column="2"
@@ -238,7 +269,7 @@
<!-- 动态字段Prop.fields -->
<div class="field-section">
<div class="field-section-title">
<span>动态字段 ({{ dynamicFields.length }})</span>
<span>动态字段</span><b>{{ dynamicFields.length }}</b>
<button class="add-field-btn" @click="onAddField"> 添加字段</button>
</div>
<el-descriptions
@@ -472,6 +503,7 @@ import { ElMessage, ElMessageBox, type TableInstance } from 'element-plus'
import { Search, Plus, Refresh, CaretBottom, Connection, Document } from '@element-plus/icons-vue'
import {
reflectionApi,
formatReflectionExecuteMessage,
type ReflectionKind,
type ReflectionMethod,
type ReflectionObject,
@@ -545,6 +577,7 @@ const savingProject = ref(false)
const lastProjectSavePath = ref<string | null>(null)
const creating = ref(false)
const search = ref('')
const typeFilter = ref('')
const scriptActionLoading = ref<'source' | 'status' | null>(null)
const scriptActionLoadingRowId = ref<number | null>(null)
const scriptDialogOpen = ref(false)
@@ -577,6 +610,11 @@ const autoSaveProjectEnabled = computed(
() => props.autoSaveProject ?? isProjectPersistableKind(props.kind)
)
const saveButtonLabel = computed(() => props.saveButtonLabel ?? '保存')
const shortTitle = computed(() => {
const t = (props.title || '').trim()
const cut = t.indexOf('')
return cut > 0 ? t.slice(0, cut) : t
})
const projectSaveTooltip = computed(() => {
if (lastProjectSavePath.value) {
return `将当前内存项目写回 JSON;上次保存:${lastProjectSavePath.value}`
@@ -592,14 +630,38 @@ const rowActionsWidth = computed(() => {
return Math.max(w, 72)
})
function shortTypeName(typeName?: string | null): string {
const raw = (typeName ?? '').trim()
if (!raw) return '—'
const parts = raw.split(/[.+]/)
return parts[parts.length - 1] || raw
}
const typeChips = computed(() => {
const map = new Map<string, number>()
for (const o of objects.value) {
const t = o.typeName || 'Unknown'
map.set(t, (map.get(t) ?? 0) + 1)
}
return [...map.entries()]
.map(([type, count]) => ({ type, short: shortTypeName(type), count }))
.sort((a, b) => b.count - a.count || a.short.localeCompare(b.short))
.slice(0, 8)
})
const filteredObjects = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return objects.value
return objects.value.filter((o) =>
String(o.id).includes(q) ||
(o.name ?? '').toLowerCase().includes(q) ||
(o.typeName ?? '').toLowerCase().includes(q)
)
return objects.value.filter((o) => {
if (typeFilter.value && o.typeName !== typeFilter.value) return false
if (!q) return true
return (
String(o.id).includes(q) ||
(o.name ?? '').toLowerCase().includes(q) ||
(o.typeName ?? '').toLowerCase().includes(q) ||
(o.summary ?? '').toLowerCase().includes(q) ||
(o.status ?? '').toLowerCase().includes(q)
)
})
})
// process / car-typed
@@ -1124,7 +1186,7 @@ async function runMethod(m: ReflectionMethod) {
executing.value = m.methodName
try {
const r = await reflectionApi.execute(realKind, current.value.id, m.methodName, params)
ElMessage.success(`${m.label ?? m.methodName} 完成${r.returnValue ? `${r.returnValue}` : ''}`)
ElMessage.success(formatReflectionExecuteMessage(m.label ?? m.methodName, r))
await onSelect(current.value)
} catch (err) {
ElMessage.error(`执行失败:${(err as Error).message}`)
@@ -1136,7 +1198,7 @@ async function runMethod(m: ReflectionMethod) {
executing.value = m.methodName
try {
const r = await reflectionApi.execute(realKind, current.value.id, m.methodName)
ElMessage.success(`${m.label ?? m.methodName} 完成${r.returnValue ? `${r.returnValue}` : ''}`)
ElMessage.success(formatReflectionExecuteMessage(m.label ?? m.methodName, r))
await onSelect(current.value)
} catch (err) {
ElMessage.error(`执行失败:${(err as Error).message}`)
@@ -1198,7 +1260,11 @@ useMapEditStream({
onObjectBatchChanged: () => refresh()
})
watch(() => props.kind, refresh)
watch(() => props.kind, () => {
typeFilter.value = ''
search.value = ''
void refresh()
})
onMounted(refresh)
</script>
@@ -1207,31 +1273,81 @@ onMounted(refresh)
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
color: var(--mg-text-light);
min-height: 0;
}
.ref-mgr-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 0 4px;
border-bottom: 1px solid var(--mg-divider);
gap: 10px;
min-height: 40px;
padding: 4px 2px 8px;
border-bottom: 1px solid var(--mg-veil-border, var(--mg-divider));
flex: none;
}
.ref-mgr-heading {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.ref-mgr-title {
margin: 0;
font-size: 16px;
font-weight: 600;
/* 用 CSS 变量自适应深色 / 浅色主题,#fff 兜底深色玻璃语境。 */
font-size: 15px;
font-weight: 650;
color: var(--el-text-color-primary, #fff);
letter-spacing: 0.5px;
letter-spacing: 0.2px;
white-space: nowrap;
}
.ref-mgr-count {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--mg-text-muted, #8a7aa8);
}
.ref-mgr-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.ref-mgr-search { width: 220px; }
.ref-mgr-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: none;
}
.ref-chip {
appearance: none;
border: 1px solid var(--mg-veil-border, rgba(124, 58, 237, 0.18));
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.92);
color: var(--mg-text-muted, #756d85);
border-radius: 6px;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.ref-chip b {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-weight: 650;
color: var(--el-text-color-primary, #2d1b69);
}
.ref-chip:hover { color: var(--el-text-color-primary, #2d1b69); }
.ref-chip.is-active {
color: var(--mg-primary, #7c3aed);
border-color: rgba(124, 58, 237, 0.45);
background: rgba(124, 58, 237, 0.08);
}
.ref-mgr-search { width: 280px; }
.ref-type-asm {
margin-left: 6px;
@@ -1242,69 +1358,103 @@ onMounted(refresh)
.ref-mgr-body {
flex: 1;
display: grid;
grid-template-columns: minmax(380px, 0.9fr) 1.4fr;
gap: 12px;
grid-template-columns: minmax(420px, 1.15fr) minmax(320px, 0.95fr);
gap: 10px;
min-height: 0;
}
.ref-mgr-list-card,
.ref-mgr-detail-card {
background: rgba(30, 14, 55, 0.55) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(12px);
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.94) !important;
border: 1px solid var(--mg-veil-border, rgba(124, 58, 237, 0.14)) !important;
border-radius: 8px !important;
box-shadow: none !important;
backdrop-filter: none;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ref-mgr-list-card :deep(.el-card__body),
.ref-mgr-detail-card :deep(.el-card__body) {
flex: 1;
overflow: auto;
padding: 12px;
padding: 8px 10px;
min-height: 0;
}
.ref-mgr-table { flex: 1; min-height: 200px; width: 100%; }
.col-mono {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--mg-text-muted, #756d85);
}
.col-type {
font-size: 12px;
color: var(--mg-text-muted, #756d85);
}
.ref-mgr-table { flex: 1; min-height: 240px; }
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--mg-veil-border);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mg-veil-border, var(--mg-divider));
}
.detail-title {
display: flex;
align-items: baseline;
gap: 10px;
gap: 8px;
min-width: 0;
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.detail-id {
color: var(--mg-text-dim);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.detail-name {
color: var(--el-text-color-primary, #fff);
font-size: 15px;
font-weight: 600;
font-size: 14px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.detail-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
.detail-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
.detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
.detail-tabs :deep(.el-tabs__item) {
color: var(--mg-text-muted) !important;
height: 34px;
line-height: 34px;
font-size: 13px;
padding: 0 14px !important;
}
.detail-tabs :deep(.el-tabs__item.is-active) {
color: var(--mg-primary, #7c3aed) !important;
font-weight: 650;
}
.detail-tabs :deep(.el-tabs__active-bar) {
background: var(--mg-primary, #7c3aed);
height: 2px;
}
.detail-tab-body {
flex: 1;
min-height: 0;
overflow: auto;
padding-top: 10px;
padding-top: 8px;
}
.field-section {
margin-top: 14px;
margin-top: 12px;
}
.field-section--first {
margin-top: 0;
@@ -1312,13 +1462,21 @@ onMounted(refresh)
.field-section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
/* CSS 变量自适应深 / 浅主题,避免白底白字。 */
gap: 8px;
font-size: 12px;
font-weight: 650;
color: var(--el-text-color-primary, rgba(255, 255, 255, 0.85));
margin-bottom: 8px;
letter-spacing: 0.4px;
letter-spacing: 0.3px;
padding-left: 8px;
border-left: 2px solid var(--mg-primary, #7c3aed);
}
.field-section-title b {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-weight: 650;
color: var(--mg-text-muted, #756d85);
margin-right: auto;
}
.field-empty {
color: var(--mg-text-dim);
@@ -1345,34 +1503,32 @@ onMounted(refresh)
margin-bottom: 8px;
}
/* + hover +
配色参考 EditPropertyPanel .prop-action-btn EditTopBar .topbar-btn */
/* Style A:实色主按钮,去掉霓虹渐变。 */
.method-btn {
appearance: none; border: 0; cursor: pointer;
padding: 8px 12px;
border-radius: 7px;
background: linear-gradient(135deg, rgba(170, 110, 250, 0.30) 0%, rgba(120, 70, 220, 0.30) 100%);
border: 1px solid rgba(170, 110, 250, 0.55);
padding: 7px 10px;
border-radius: 6px;
background: var(--mg-primary, #7c3aed);
border: 1px solid var(--mg-primary, #7c3aed);
color: #fff;
font-size: 12.5px;
font-weight: 500;
letter-spacing: 0.3px;
font-size: 12px;
font-weight: 550;
letter-spacing: 0.2px;
display: flex;
align-items: center;
gap: 6px;
text-align: left;
transition: background .14s ease, border-color .14s ease, transform .12s ease, box-shadow .14s ease;
transition: background .12s ease, border-color .12s ease, transform .1s ease;
}
.method-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(170, 110, 250, 0.72) 0%, rgba(255, 110, 220, 0.62) 100%);
border-color: rgba(255, 130, 220, 0.78);
box-shadow: 0 4px 14px rgba(180, 90, 220, 0.42);
background: var(--mg-primary-hover, #6d28d9);
border-color: var(--mg-primary-hover, #6d28d9);
}
.method-btn:active:not(:disabled) { transform: scale(0.985); }
.method-btn:disabled { opacity: 0.55; cursor: progress; }
.method-btn-icon {
font-size: 10px;
color: rgba(255, 200, 250, 0.85);
color: rgba(255, 255, 255, 0.85);
flex: none;
}
.method-btn-spin {
@@ -1399,39 +1555,38 @@ onMounted(refresh)
/* 「+ 添加字段」按钮:与动作按钮同色系但偏淡,区分等级。 */
.add-field-btn {
appearance: none; border: 0; cursor: pointer;
padding: 4px 10px;
appearance: none;
cursor: pointer;
padding: 3px 8px;
border-radius: 6px;
background: rgba(170, 110, 250, 0.18);
border: 1px dashed rgba(170, 110, 250, 0.45);
color: rgba(255, 230, 255, 0.95);
background: transparent;
border: 1px dashed rgba(124, 58, 237, 0.45);
color: var(--mg-primary, #7c3aed);
font-size: 12px;
font-weight: 500;
transition: background .14s ease, border-color .14s ease, color .14s ease;
font-weight: 550;
transition: background .12s ease, border-color .12s ease;
margin-left: auto;
}
.add-field-btn:hover {
background: rgba(170, 110, 250, 0.34);
border-color: rgba(170, 110, 250, 0.75);
color: #fff;
background: rgba(124, 58, 237, 0.08);
border-color: rgba(124, 58, 237, 0.7);
}
/* 详情头「在 3D 中高亮」按钮:与动作按钮同款样式。 */
.header-action-btn {
appearance: none; border: 0; cursor: pointer;
padding: 6px 14px;
padding: 5px 12px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(170, 110, 250, 0.32) 0%, rgba(120, 70, 220, 0.32) 100%);
border: 1px solid rgba(170, 110, 250, 0.55);
background: var(--mg-primary, #7c3aed);
border: 1px solid var(--mg-primary, #7c3aed);
color: #fff;
font-size: 12.5px;
font-weight: 500;
letter-spacing: 0.4px;
transition: background .14s ease, border-color .14s ease, box-shadow .14s ease;
font-size: 12px;
font-weight: 550;
letter-spacing: 0.2px;
transition: background .12s ease;
}
.header-action-btn:hover {
background: linear-gradient(135deg, rgba(170, 110, 250, 0.72) 0%, rgba(255, 110, 220, 0.62) 100%);
border-color: rgba(255, 130, 220, 0.78);
box-shadow: 0 3px 10px rgba(180, 90, 220, 0.40);
background: var(--mg-primary-hover, #6d28d9);
border-color: var(--mg-primary-hover, #6d28d9);
}
.script-dialog-textarea :deep(.el-textarea__inner) {
@@ -1468,33 +1623,14 @@ onMounted(refresh)
background: var(--mg-veil-1) !important;
}
:deep(.el-table__body tr.current-row > td.el-table__cell) {
background: rgba(170, 110, 250, 0.20) !important;
background: rgba(124, 58, 237, 0.10) !important;
}
:deep(.el-table .el-button.is-text) {
padding: 2px 6px;
}
/*
改为实色紫底白字对比 ~6.9:1 WCAG AAscoped 限本组件不影响地图编辑画布 */
:root[data-theme="fame-lavender"] .method-btn,
:root[data-theme="fame-lavender"] .header-action-btn {
background: #7c3aed;
border-color: #7c3aed;
color: #fff;
}
:root[data-theme="fame-lavender"] .method-btn:hover:not(:disabled),
:root[data-theme="fame-lavender"] .header-action-btn:hover {
background: #6d28d9;
border-color: #6d28d9;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
}
:root[data-theme="fame-lavender"] .method-btn-icon { color: rgba(255, 255, 255, 0.85); }
:root[data-theme="fame-lavender"] .method-btn-tag { background: rgba(255, 255, 255, 0.28); }
:root[data-theme="fame-lavender"] .add-field-btn {
background: rgba(124, 58, 237, 0.12);
border-color: rgba(124, 58, 237, 0.5);
color: #6d28d9;
}
:root[data-theme="fame-lavender"] .add-field-btn:hover {
background: rgba(124, 58, 237, 0.22);
border-color: rgba(124, 58, 237, 0.7);
color: #5b21b6;
@media (max-width: 1100px) {
.ref-mgr-body { grid-template-columns: 1fr; }
.ref-mgr-search { width: min(220px, 100%); }
}
</style>
@@ -28,7 +28,7 @@
@row-click="onRowClick"
@row-contextmenu="onRowContextMenu"
>
<el-table-column prop="id" label="ID" width="40" align="center" />
<el-table-column prop="id" label="ID" width="72" align="center" show-overflow-tooltip />
<el-table-column label="起点" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span>
@@ -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<DeliveryAction, string> = {
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;
}
</style>
@@ -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;
}
@@ -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 {
@@ -1,6 +1,6 @@
<template>
<div class="vehicle-monitor">
<div class="overview-row">
<div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table }">
<div v-if="!compact && !table" class="overview-row">
<div class="overview-item">
<div class="overview-num">{{ cars.length }}</div>
<div class="overview-label">总数</div>
@@ -28,10 +28,11 @@
v-model="search"
size="small"
clearable
placeholder="搜索 ID / 名称 / 任务"
:placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'"
class="search"
/>
<el-select
v-if="!table"
v-model="filterState"
size="small"
placeholder="状态"
@@ -47,7 +48,51 @@
</el-select>
</div>
<div class="vehicle-list">
<div v-if="table" class="chip-row" role="tablist" aria-label="状态筛选">
<button
v-for="chip in filterChips"
:key="chip.key"
type="button"
role="tab"
class="chip"
:class="{ 'is-active': quickFilter === chip.key }"
:aria-selected="quickFilter === chip.key"
@click="quickFilter = chip.key"
>
{{ chip.label }}
<span class="chip-n">{{ chip.count }}</span>
</button>
</div>
<div v-if="table" class="table-wrap">
<div class="table-head" aria-hidden="true">
<span class="col-id">车辆编号</span>
<span class="col-state">状态</span>
<span class="col-bat">电量</span>
<span class="col-pos">当前位置</span>
</div>
<div class="vehicle-list table-body">
<button
v-for="car in filteredCars"
:key="car.id"
type="button"
class="table-row"
:class="{ 'is-selected': car.id === selectedId }"
@click="onSelect(car)"
>
<span class="col-id" :title="car.name">{{ shortId(car) }}</span>
<span class="col-state">
<span class="state-dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }}
</span>
<span class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</span>
<span class="col-pos" :title="positionText(car)">{{ positionText(car) }}</span>
</button>
<div v-if="!filteredCars.length" class="empty muted">无匹配车辆</div>
</div>
</div>
<div v-else class="vehicle-list">
<div
v-for="car in filteredCars"
:key="car.id"
@@ -120,6 +165,10 @@ const props = defineProps<{
cars: Car[]
missions: Mission[]
selectedId?: string | null
/** 紧凑模式:隐藏顶部概览条 */
compact?: boolean
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局) */
table?: boolean
}>()
const emit = defineEmits<{
@@ -128,6 +177,8 @@ const emit = defineEmits<{
const search = ref('')
const filterState = ref<CarState | ''>('')
type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault'
const quickFilter = ref<QuickFilter>('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;
@@ -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 {
@@ -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<ChatMessage[]>([])
const sessions = ref<AssistantSessionMeta[]>([])
const currentSessionId = ref<string | null>(localStorage.getItem(CURRENT_KEY))
const status = ref<'idle' | 'streaming'>('idle')
const toolIsWrite = ref<Record<string, boolean>>({})
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<void> {
try {
sessions.value = await listSessions()
} catch {
/* 列表失败不阻塞对话 */
}
}
async function ensureInit(): Promise<void> {
if (initialized) return
initialized = true
try {
const tools = await listTools()
const map: Record<string, boolean> = {}
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<void> {
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<void> {
try {
await deleteSession(id)
} catch {
/* ignore */
}
await refreshSessions()
if (currentSessionId.value === id) newSession()
}
async function send(text: string): Promise<void> {
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
}
}
@@ -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<EditToolId>('transform.move')
// 空闲默认:不落在已移除的「移动」上,侧栏无高亮(画布仍可点选)。
const activeTool = ref<EditToolId>('select.single')
const snap = ref<SnapState>({ 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': '删除',
@@ -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
}
}
@@ -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<OtaSettings | null>(null)
const target = ref<OtaTarget | null>(null)
const targetSummary = ref<Record<string, string>>({})
const activeJobCount = ref(0)
const loadingMeta = ref(false)
let pollTimer: ReturnType<typeof setInterval> | 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 }
@@ -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 ''
})
@@ -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: '设计与编辑' }
]
},
{
@@ -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<string, string> = {
'platform-config': 'admin-map-editor',
mission: 'admin-task-templates',
@@ -33,7 +33,7 @@ const LEGACY_KEY_ALIASES: Record<string, string> = {
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
@@ -53,7 +53,7 @@
<div class="aside-footer">
<span class="aside-footer-badge">v1.6</span>
<span v-if="!ui.sidebarCollapsed">{{ ui.activeTheme.name }} · {{ ui.activeTheme.preview }}</span>
<span v-if="!ui.sidebarCollapsed">{{ ui.activeTheme.name }}</span>
</div>
</el-aside>
@@ -82,6 +82,15 @@
<el-dropdown-menu>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
<el-dropdown-item command="status">服务状态</el-dropdown-item>
<el-dropdown-item divided disabled class="legacy-theme-label">高级 · 兼容主题</el-dropdown-item>
<el-dropdown-item
v-for="t in ui.legacyThemes"
:key="t.id"
:command="`theme:${t.id}`"
:class="{ 'is-legacy-active': t.id === ui.themeId }">
<span class="legacy-swatch" :style="{ background: t.preview }" />
{{ t.name }}
</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -97,10 +106,13 @@
</el-main>
<el-footer class="footer">
<span>{{ scopeLabel }} · webVRender :{{ vrPort }} · API {{ apiBase }}</span>
<span>{{ scopeLabel }} · Projection :{{ slPort }} · API {{ apiBase }}</span>
</el-footer>
</el-container>
</el-container>
<!-- 全局 AI 助手右侧抽屉 + 右下角悬浮唤起所有界面可呼出后端要求 Platform 权限故按 scope 显示 -->
<AiAssistantDrawer v-if="auth.scope === 'Platform'" />
</div>
</template>
@@ -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;
}
</style>
<style>
/* 用户菜单「兼容主题」项(teleport 到 body */
.legacy-theme-label {
font-size: 11px !important;
opacity: 0.65;
cursor: default !important;
}
.legacy-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.el-dropdown-menu__item.is-legacy-active {
color: var(--mg-primary) !important;
font-weight: 600;
}
</style>
@@ -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<VehicleAlarm[]> {
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
}
]
}
@@ -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<DeliveryTask[]> {
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<DeliveryTask[]> {
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<DeliveryTask[]> {
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<DeliveryTask[]> {
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
}
]
}
@@ -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' },
@@ -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) {
@@ -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<UiState>
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
File diff suppressed because it is too large Load Diff
@@ -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<string, string>
}
export const DEFAULT_THEME_ID = 'fame-lavender'
/** Outpost 浅色壳共用表面(纸白卡、深字、关 orb);各主题可覆盖 card/tint */
const OUTPOST_SHELL_VARS: Record<string, string> = {
'--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<string, string>): Record<string, string> {
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<ThemePreset, 'outpostShell' | 'vars'> & { vars: Record<string, string> }
): 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<string, string> = {
'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)
}
/** 把主题变量写入 :rootOutpost 壳写 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')
}
}
@@ -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[]
}
@@ -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
}
/** 车辆运维卡片合并模型 */
@@ -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
}
@@ -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<string, OtaFileArtifact>
}
export interface OtaPackageInfo {
id: string
sourceIp?: string
createdAt: string
totalBytes: number
isTarget: boolean
components: Record<string, OtaFileArtifact>
}
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<string, string>
}
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'
@@ -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)
}
/** 接受 01 或 0100,输出 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<string, string> | 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
}
@@ -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<Car, 'state' | 'lstatus'>, 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)
}
}
@@ -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()
}
@@ -0,0 +1,136 @@
/**
* MarkdownHTML
* / / / / / / /
* HTML http(s)//
* CommonMark markdown-it XSS
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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(`<code class="mmd-code">${c}</code>`)
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 `<a class="mmd-link" href="${safe}" target="_blank" rel="noopener noreferrer">${text}</a>`
})
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1<em>$2</em>')
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(`<pre class="mmd-pre"><code${clsAttr}>${body}</code></pre>`)
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(`<p>${inline(escapeHtml(para.join(' ')))}</p>`)
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(`<blockquote class="mmd-quote">${inline(escapeHtml(items.join(' ')))}</blockquote>`)
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(`<ul class="mmd-ul">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ul>`)
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(`<ol class="mmd-ol">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ol>`)
continue
}
para.push(line)
i++
}
flushPara()
return out.join('\n')
}
@@ -4,17 +4,10 @@
<div class="bg-overlay" />
<div class="bg-orb bg-orb-a" />
<div class="bg-orb bg-orb-b" />
<div class="bg-orb bg-orb-c" />
<div class="bg-stars" aria-hidden="true">
<span v-for="(s, i) in starStyles" :key="i" :style="s" />
</div>
<div class="login-card">
<!-- 左侧品牌 hero -->
<div class="hero">
<div class="hero-stars" aria-hidden="true">
<span v-for="(s, i) in heroStarStyles" :key="i" :style="s" />
</div>
<div class="hero-brand">
<span class="hero-mark">
<img src="/FRLD-logo-white-no_title.png" alt="迷毂" class="hero-mark-img" />
@@ -212,25 +205,6 @@ const rules: FormRules = {
const year = computed(() => new Date().getFullYear())
// n
function makeStars(n: number): Record<string, string>[] {
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<string, string>
})
}
// +
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() {
</script>
<style scoped>
/*
* 登录页固定配色不随全局主题切换变化
* 统一用一组本地 --lg-* 变量午夜靛蓝 + 青色霓虹保证无论用户切到哪个
* 主题登录界面始终是同一套高级克制的深色质感
* */
/* Outpost 登录:纸白主区 + plum 品牌柱(对齐 fairylandtech.amerc.ai */
.login-page {
/* 梦幻晨曦渐变:粉紫(左上) → 蓝紫(中) → 淡蓝(右下),高明度柔光,参考产品视觉稿。 */
position: relative;
min-height: 100vh;
display: flex;
@@ -283,11 +252,11 @@ async function submit() {
justify-content: center;
overflow: hidden;
background:
radial-gradient(ellipse 75% 55% at 16% 14%, #dcbef7 0%, transparent 55%),
radial-gradient(ellipse 80% 65% at 86% 90%, #abc9f5 0%, transparent 58%),
linear-gradient(135deg, #cbb2f0 0%, #b7adec 46%, #a7c6f2 100%);
color: var(--lg-text);
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
radial-gradient(ellipse 70% 50% at 15% 10%, rgba(117, 67, 232, 0.14), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 85%, rgba(155, 124, 255, 0.1), transparent 50%),
#f6f3fb;
color: #28213a;
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
/* ===== 背景层 ===== */
@@ -329,25 +298,18 @@ async function submit() {
animation: drift 20s ease-in-out infinite;
}
.bg-orb-a {
width: 460px; height: 460px;
top: -150px; left: -130px;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 52%, transparent 80%);
opacity: 0.24;
width: 420px; height: 420px;
top: -140px; left: -100px;
background: radial-gradient(circle, rgba(155, 124, 255, 0.55) 0%, rgba(117, 67, 232, 0.35) 55%, transparent 80%);
opacity: 0.22;
}
.bg-orb-b {
width: 600px; height: 600px;
bottom: -220px; right: -190px;
background: radial-gradient(circle, var(--lg-accent) 0%, var(--lg-primary-deep) 58%, transparent 86%);
opacity: 0.18;
width: 480px; height: 480px;
bottom: -180px; right: -140px;
background: radial-gradient(circle, rgba(117, 67, 232, 0.4) 0%, rgba(155, 124, 255, 0.2) 55%, transparent 85%);
opacity: 0.16;
animation-delay: -7s;
}
.bg-orb-c {
width: 320px; height: 320px;
top: 32%; right: 9%;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 60%, transparent 90%);
opacity: 0.14;
animation-delay: -13s;
}
@keyframes drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, -28px) scale(1.05); }
@@ -368,7 +330,7 @@ async function submit() {
50% { opacity: 0.95; transform: scale(1.2); }
}
/* ===== 卡片:双栏 hero ===== */
/* ===== 卡片:Outpost 纸白 + plum 品牌柱 ===== */
.login-card {
position: relative;
z-index: 2;
@@ -378,34 +340,19 @@ async function submit() {
display: grid;
grid-template-columns: 360px 1fr;
overflow: hidden;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
/* 登录框本体:紫 → 靛蓝的「紫蓝混合」渐变,梦幻且不发灰;高不透明度保证文字清晰 */
background:
linear-gradient(140deg,
rgba(48, 26, 96, 0.95) 0%,
rgba(33, 26, 92, 0.955) 50%,
rgba(22, 26, 84, 0.96) 100%);
backdrop-filter: blur(26px) saturate(150%);
-webkit-backdrop-filter: blur(26px) saturate(150%);
border-radius: 20px;
border: 1px solid rgba(40, 33, 58, 0.08);
background: #fffefd;
box-shadow:
0 44px 110px rgba(0, 0, 0, 0.62),
0 0 70px rgba(var(--lg-primary-rgb), 0.24),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.16) inset,
0 1px 0 rgba(255, 255, 255, 0.18) inset;
0 1px 2px rgba(40, 33, 58, 0.04),
0 24px 56px rgba(54, 35, 78, 0.12);
}
/* 顶部霓虹高光线 */
.login-card::before {
content: '';
position: absolute;
top: 0; left: 12%; right: 12%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(var(--lg-accent-rgb), 0.55) 30%,
rgba(255, 255, 255, 0.92) 50%,
rgba(var(--lg-accent-rgb), 0.55) 70%,
transparent 100%);
background: linear-gradient(90deg, transparent 0%, rgba(117, 67, 232, 0.35) 50%, transparent 100%);
pointer-events: none;
z-index: 3;
}
@@ -415,12 +362,8 @@ async function submit() {
position: relative;
padding: 44px 34px 34px;
color: #fff;
/* 品牌区紫蓝混合:左上紫光球 + 右下蓝光球,叠在深紫底上 */
background:
radial-gradient(ellipse at 16% 18%, rgba(123, 77, 255, 0.34) 0%, transparent 56%),
radial-gradient(ellipse at 88% 84%, rgba(94, 124, 255, 0.32) 0%, transparent 58%),
linear-gradient(170deg, rgba(var(--lg-primary-rgb), 0.26) 0%, rgba(var(--lg-aside-rgb), 0.42) 60%, rgba(var(--lg-deep-rgb), 0.55) 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(165deg, #3f2454 0%, #241530 100%);
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
}
@@ -550,56 +493,53 @@ async function submit() {
padding: 44px 44px 32px;
display: flex;
flex-direction: column;
/* 表单侧:紫 → 蓝的洗光,与品牌区一致的「紫蓝混合」基调 */
background:
linear-gradient(135deg, rgba(123, 77, 255, 0.14) 0%, rgba(94, 124, 255, 0.10) 100%);
background: #fffefd;
color: #28213a;
}
.panel-head { margin-bottom: 26px; }
.panel-title {
font-size: 30px; font-weight: 700;
letter-spacing: 12px;
background: linear-gradient(135deg, #ffffff 0%, var(--lg-accent) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 22px rgba(var(--lg-primary-hover-rgb), 0.5);
font-size: 28px; font-weight: 700;
letter-spacing: 8px;
color: #28213a;
background: none;
-webkit-text-fill-color: unset;
text-shadow: none;
}
.panel-sub {
margin-top: 6px;
font-size: 12.5px;
color: var(--lg-text-dim);
color: #756d85;
letter-spacing: 1px;
}
.glass-form { flex: 1; display: flex; flex-direction: column; }
.glass-form :deep(.el-form-item) { margin-bottom: 18px; }
/* 输入框 透明玻璃 */
.glass-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.06) !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset !important;
border-radius: 10px !important;
transition: all .25s;
background: #f6f3fb !important;
box-shadow: 0 0 0 1px rgba(40, 33, 58, 0.1) inset !important;
border-radius: 12px !important;
transition: all .2s;
}
.glass-form :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.11) !important;
box-shadow: 0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
background: #fbf9fd !important;
box-shadow: 0 0 0 1px rgba(117, 67, 232, 0.35) inset !important;
}
.glass-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.13) !important;
background: #fff !important;
box-shadow:
0 0 0 1px rgba(var(--lg-accent-rgb), 0.85) inset,
0 0 20px rgba(var(--lg-primary-hover-rgb), 0.45) !important;
0 0 0 1px rgba(117, 67, 232, 0.55) inset,
0 0 0 3px rgba(117, 67, 232, 0.12) !important;
}
.glass-form :deep(.el-input__inner) {
color: #fff !important;
-webkit-text-fill-color: #fff !important;
color: #28213a !important;
-webkit-text-fill-color: #28213a !important;
font-size: 14px;
height: 42px;
}
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.42); }
.glass-form :deep(.el-input__inner::placeholder) { color: #a8a0c0; }
.glass-form :deep(.el-input__prefix-inner > :first-child),
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.55); }
.glass-form :deep(.el-input__suffix-inner) { color: #756d85; }
/* scope 双卡 */
.scope-item :deep(.el-form-item__content) { width: 100%; }
@@ -611,45 +551,42 @@ async function submit() {
display: flex; align-items: center; gap: 6px;
margin-bottom: 8px;
font-size: 12px;
color: var(--lg-text-soft);
color: #756d85;
letter-spacing: 1px;
}
.launch-mode-title { font-weight: 500; }
.launch-mode-help {
color: var(--lg-text-dim);
color: #a8a0c0;
cursor: help;
font-size: 14px;
}
.launch-mode-help:hover { color: var(--lg-accent); }
.launch-mode-help:hover { color: #7543e8; }
.launch-wrap { margin-bottom: 4px; }
.launch-tip { max-width: 280px; line-height: 1.6; font-size: 12px; }
.launch-tip > div + div { margin-top: 6px; }
.scope-tab {
appearance: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.16);
background: #f6f3fb;
border: 1px solid rgba(40, 33, 58, 0.1);
border-radius: 12px;
padding: 14px 14px;
display: flex; align-items: center; gap: 12px;
color: rgba(255, 255, 255, 0.78);
color: #28213a;
cursor: pointer;
transition: all .2s;
text-align: left;
}
.scope-tab:hover {
background: rgba(255, 255, 255, 0.11);
color: #fff;
background: #fbf9fd;
border-color: rgba(117, 67, 232, 0.3);
color: #28213a;
transform: translateY(-1px);
}
.scope-tab.active {
background: linear-gradient(135deg,
rgba(var(--lg-primary-rgb), 0.55) 0%,
rgba(var(--lg-accent-rgb), 0.28) 100%);
border-color: rgba(var(--lg-accent-rgb), 0.75);
background: linear-gradient(135deg, #7543e8 0%, #9b7cff 100%);
border-color: transparent;
color: #fff;
box-shadow:
0 0 22px rgba(var(--lg-primary-hover-rgb), 0.45),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.45) inset;
box-shadow: 0 8px 20px rgba(117, 67, 232, 0.28);
}
.scope-tab .el-icon { font-size: 20px; }
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
@@ -660,107 +597,83 @@ async function submit() {
.row { display: flex; align-items: center; }
.row-between { justify-content: space-between; margin: -6px 0 10px; }
.remember :deep(.el-checkbox__label) {
color: rgba(255, 255, 255, 0.9) !important;
color: #28213a !important;
font-size: 12.5px;
text-shadow: 0 1px 2px rgba(6, 9, 18, 0.4);
text-shadow: none;
}
.remember :deep(.el-checkbox__inner) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.32);
background: #fff;
border-color: rgba(40, 33, 58, 0.25);
}
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
background: var(--lg-primary);
border-color: var(--lg-primary);
background: #7543e8;
border-color: #7543e8;
}
.adv-link { color: var(--lg-text-soft) !important; font-size: 12.5px; }
.adv-link:hover { color: #fff !important; }
.adv-link { color: #756d85 !important; font-size: 12.5px; }
.adv-link:hover { color: #7543e8 !important; }
/* 高级折叠 */
.adv-collapse { background: transparent !important; border: none !important; margin-bottom: 16px; }
.adv-collapse :deep(.el-collapse-item__wrap),
.adv-collapse :deep(.el-collapse-item__header) {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--lg-text-soft) !important;
border-color: rgba(40, 33, 58, 0.1) !important;
color: #756d85 !important;
}
.adv-title { font-size: 12px; letter-spacing: 1px; }
.adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; padding-top: 6px; }
.adv-grid :deep(.el-form-item__label) {
color: var(--lg-text-soft);
color: #756d85;
font-size: 12px;
}
.adv-grid :deep(.el-input-number) { width: 100%; }
.adv-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
/* 登录按钮:靛蓝→青 霓虹 · 扫光动效 */
.btn-login {
position: relative;
overflow: hidden;
height: 46px !important;
border-radius: 10px;
border-radius: 12px;
margin-top: 6px;
font-size: 15px;
letter-spacing: 6px;
font-weight: 600;
color: #fff !important;
background: linear-gradient(135deg, var(--lg-primary) 0%, var(--lg-primary-hover) 100%) !important;
background: #7543e8 !important;
border: none !important;
box-shadow:
0 12px 28px rgba(var(--lg-primary-rgb), 0.5),
0 0 26px rgba(var(--lg-primary-hover-rgb), 0.36),
0 0 0 1px rgba(255, 255, 255, 0.20) inset !important;
transition: all .28s cubic-bezier(.25, .8, .25, 1);
}
.btn-login::after {
content: '';
position: absolute;
top: 0; left: -120%;
width: 80%; height: 100%;
background: linear-gradient(110deg,
transparent 0%,
rgba(255, 255, 255, 0.0) 30%,
rgba(255, 255, 255, 0.38) 50%,
rgba(255, 255, 255, 0.0) 70%,
transparent 100%);
transition: left .6s cubic-bezier(.25, .8, .25, 1);
pointer-events: none;
box-shadow: 0 10px 24px rgba(117, 67, 232, 0.28) !important;
transition: all .2s ease;
}
.btn-login::after { display: none; }
.btn-login:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, var(--lg-primary-hover) 0%, var(--lg-accent) 130%) !important;
box-shadow:
0 18px 38px rgba(var(--lg-primary-rgb), 0.6),
0 0 42px rgba(var(--lg-accent-rgb), 0.5),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
transform: translateY(-1px);
background: #8660f0 !important;
box-shadow: 0 14px 28px rgba(117, 67, 232, 0.35) !important;
}
.btn-login:hover::after { left: 120%; }
.bottom-note {
margin-top: 16px;
font-size: 11.5px;
color: var(--lg-text-dim);
color: #756d85;
text-align: center;
line-height: 1.6;
}
/* 底部水印 */
.footer-stamp {
position: absolute;
bottom: 18px; left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 2px;
color: rgba(45, 32, 78, 0.6);
color: rgba(40, 33, 58, 0.45);
z-index: 2;
}
/* 错误提示 */
.glass-form :deep(.el-form-item__error) {
color: #ff9bb8;
color: #c23a5c;
padding-top: 4px;
}
/* 小屏自适应 */
@media (max-width: 760px) {
.login-card { grid-template-columns: 1fr; min-height: auto; }
.hero { border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding: 28px; }
@@ -43,6 +43,28 @@
<el-alert v-if="error" :title="error" type="warning" :closable="false" class="status-block" />
<div v-if="canControlSimpleLite" class="status-block sl-control">
<div class="sl-control-title">SimpleLite 操作</div>
<div class="sl-control-actions">
<el-button
type="warning"
:loading="actionLoading === 'restart'"
:disabled="!!actionLoading"
@click="onRestart">
重启 SimpleLite
</el-button>
<el-button
type="danger"
plain
:loading="actionLoading === 'stop'"
:disabled="!!actionLoading"
@click="onStop">
关闭 SimpleLite
</el-button>
</div>
<p class="sl-control-hint">将终止本机全部 SimpleLite 进程重启后按当前启动模式重新拉起</p>
</div>
<div class="status-actions">
<el-button :loading="loading" @click="refresh">刷新</el-button>
<el-button @click="back">返回</el-button>
@@ -54,34 +76,29 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import http from '@/api/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getHealth,
getSimpleLiteDiagnostics,
restartSimpleLite,
resolveRestartLaunchMode,
stopSimpleLite,
type HealthInfo,
type SimpleLiteDiagnostics
} from '@/api/health'
import { useAuthStore } from '@/stores/auth'
interface HealthInfo {
status: string
startTime: string
uptimeSec: number
}
interface SimpleLiteDiagnostics {
enabled: boolean
isRunning: boolean
lastLaunchMode?: string | null
projectionPort: number
projectionPortReachable: boolean
gotoSiteApiAvailable?: boolean | null
executableExists: boolean
deployHint?: string | null
}
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const actionLoading = ref<'stop' | 'restart' | null>(null)
const health = ref<HealthInfo | null>(null)
const sl = ref<SimpleLiteDiagnostics | null>(null)
const error = ref('')
const canControlSimpleLite = computed(() => auth.token && auth.scope === 'Platform')
let timer: number | undefined
const serverStartTime = computed(() =>
@@ -117,11 +134,10 @@ async function refresh() {
try {
// /status public /health simplelite
// 401
const requests: [Promise<{ data: HealthInfo }>, Promise<{ data: SimpleLiteDiagnostics }> | null] = [
http.get<HealthInfo>('/health'),
auth.token ? http.get<SimpleLiteDiagnostics>('/health/simplelite') : null
]
const [h, d] = await Promise.allSettled([requests[0], requests[1] ?? Promise.reject(new Error('skipped'))])
const [h, d] = await Promise.allSettled([
getHealth(),
auth.token ? getSimpleLiteDiagnostics() : Promise.reject(new Error('skipped'))
])
health.value = h.status === 'fulfilled' ? h.value.data : null
sl.value = d.status === 'fulfilled' ? d.value.data : null
if (h.status === 'rejected') error.value = '无法连接 MiGu.Server/api/health'
@@ -131,6 +147,59 @@ async function refresh() {
}
}
async function onStop() {
try {
await ElMessageBox.confirm(
'将终止本机全部 SimpleLite 进程,地图监控与 /api/sl/* 功能将不可用,直到重新登录或手动重启。',
'关闭 SimpleLite',
{ type: 'warning', confirmButtonText: '关闭', cancelButtonText: '取消' }
)
} catch {
return
}
actionLoading.value = 'stop'
try {
const { data } = await stopSimpleLite()
sl.value = data.diagnostics
ElMessage.success(data.killed > 0 ? `已关闭 ${data.killed} 个 SimpleLite 进程` : '当前没有运行中的 SimpleLite 进程')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '关闭 SimpleLite 失败')
} finally {
actionLoading.value = null
}
}
async function onRestart() {
const launchMode = resolveRestartLaunchMode(sl.value?.lastLaunchMode, auth.runMode)
const modeLabel = launchMode === 'WebOnly' ? '仅 Web' : '本地 + Web'
try {
await ElMessageBox.confirm(
`将关闭本机全部 SimpleLite 并按「${modeLabel}」模式重新拉起,期间 /api/sl/* 可能短暂不可用。`,
'重启 SimpleLite',
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' }
)
} catch {
return
}
actionLoading.value = 'restart'
try {
const { data } = await restartSimpleLite(launchMode)
sl.value = data.diagnostics
const r = data.restart
if (r.warning) ElMessage.warning(r.warning)
else if (r.started) ElMessage.success('SimpleLite 已重新拉起')
else ElMessage.error(`重启未完成:${r.status}`)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '重启 SimpleLite 失败')
} finally {
actionLoading.value = null
void refresh()
}
}
onMounted(() => {
void refresh()
// 10s goto-site 60s SimpleLite
@@ -148,5 +217,8 @@ function back() { router.back() }
.status-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.status-card { width: 720px; }
.status-block { margin-top: 16px; }
.sl-control-title { font-weight: 600; margin-bottom: 8px; }
.sl-control-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.sl-control-hint { margin: 8px 0 0; font-size: 12px; color: var(--el-text-color-secondary); }
.status-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
</style>
@@ -0,0 +1,373 @@
<template>
<div class="alarm-mgmt-page">
<!-- 统计条 -->
<header class="am-stats">
<div class="am-stat">
<span class="am-stat-label">当前活跃</span>
<span class="am-stat-value" :class="{ 'am-danger': counts.active > 0 }"><b>{{ counts.active }}</b></span>
</div>
<div class="am-stat-sep" aria-hidden="true" />
<div class="am-stat">
<span class="am-stat-label">今日新增</span>
<span class="am-stat-value"><b>{{ counts.today }}</b></span>
</div>
<div class="am-stat">
<span class="am-stat-label">已恢复</span>
<span class="am-stat-value"><b>{{ counts.cleared }}</b></span>
</div>
<div class="am-stat">
<span class="am-stat-label">涉及车辆</span>
<span class="am-stat-value"><b>{{ counts.cars }}</b></span>
</div>
<div class="am-stats-actions">
<span class="am-live" :class="{ 'is-live': autoRefresh }" @click="autoRefresh = !autoRefresh">
{{ autoRefresh ? '自动刷新' : '已暂停' }}
</span>
<el-button :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
</div>
</header>
<el-alert
v-if="!online"
type="warning"
show-icon
:closable="false"
class="am-offline"
title="SimpleLite 未连接:以下为平台最近一次采集的报警快照。"
:description="lastSyncAt ? `最近同步:${formatTime(lastSyncAt)}` : '暂无同步记录'"
/>
<!-- 筛选条 -->
<div class="am-toolbar">
<el-input v-model="search" clearable placeholder="搜索车辆 / 报警信息" class="am-search" :prefix-icon="Search" />
<el-select v-model="carFilter" clearable filterable placeholder="车辆" class="am-car">
<el-option v-for="c in carOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-select v-model="levelFilter" clearable placeholder="级别" class="am-level">
<el-option label="严重" :value="'danger'" />
<el-option label="警告" :value="'warning'" />
<el-option label="提示" :value="'info'" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="首次起"
end-placeholder="首次止"
class="am-date"
:shortcuts="dateShortcuts"
unlink-panels
/>
<span class="am-count">{{ filteredRows.length }} / {{ rows.length }}</span>
</div>
<!-- 快捷芯片 -->
<div class="am-chips" role="tablist">
<button
v-for="chip in statusChips"
:key="chip.key"
type="button"
class="am-chip"
:class="{ 'is-active': quickStatus === chip.key }"
@click="quickStatus = chip.key"
>
{{ chip.label }}<b>{{ chip.count }}</b>
</button>
</div>
<!-- 报警表 -->
<div v-loading="loading" class="am-table-wrap">
<el-table
:data="filteredRows"
stripe
height="100%"
highlight-current-row
:row-class-name="rowClassName"
empty-text="暂无匹配报警"
>
<el-table-column label="车辆" width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.carName || `#${row.carId}` }}</span>
</template>
</el-table-column>
<el-table-column label="级别" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="levelTagType(row.level)" effect="plain">{{ levelLabel(row.level) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="报警信息" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="am-info">{{ row.info || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'danger' : 'info'" effect="plain">
{{ row.status === 'active' ? '活跃' : '已恢复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="首次触发" width="160" sortable :sort-method="(a, b) => sortByTime(a.firstAt, b.firstAt)">
<template #default="{ row }">{{ formatTime(row.firstAt) }}</template>
</el-table-column>
<el-table-column label="最近更新" width="160">
<template #default="{ row }">{{ formatTime(row.lastAt) }}</template>
</el-table-column>
<el-table-column label="恢复时间" width="160">
<template #default="{ row }">{{ formatTime(row.resolvedAt) }}</template>
</el-table-column>
<el-table-column label="持续时长" width="110" align="right">
<template #default="{ row }">{{ durationText(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="locateOnMap(row)">定位</el-button>
</template>
</el-table-column>
</el-table>
</div>
<p class="am-footnote">
报警来自车体状态车体_AlarmInfo / 车体_AlarmLevel由平台按车对帐记录出现即开消失即恢复历史长期保留
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { fetchAlarmFeed } from '@/api/alarm'
import type { VehicleAlarm } from '@/types/alarm'
import { dateShortcuts, formatTime, isToday, parseTime, sortByTime } from '@/utils/dateTime'
type QuickKey = 'all' | 'active' | 'cleared'
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
type LevelKey = 'danger' | 'warning' | 'info'
const router = useRouter()
const rows = ref<VehicleAlarm[]>([])
const loading = ref(false)
const autoRefresh = ref(true)
const online = ref(true)
const lastSyncAt = ref<string | null>(null)
const search = ref('')
const carFilter = ref<number | null>(null)
const levelFilter = ref<LevelKey | null>(null)
const quickStatus = ref<QuickKey>('all')
const dateRange = ref<[string, string] | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
function levelKeyOf(level: number): LevelKey {
if (level >= 2) return 'danger'
if (level === 1) return 'warning'
return 'info'
}
function levelLabel(level: number): string {
return { danger: '严重', warning: '警告', info: '提示' }[levelKeyOf(level)]
}
function levelTagType(level: number): TagType {
return levelKeyOf(level)
}
function durationText(row: VehicleAlarm): string {
let secs = row.durationSecs ?? null
if (secs == null && row.status === 'active') {
const t = parseTime(row.firstAt)
if (t != null) secs = Math.max(0, Math.round((Date.now() - t) / 1000))
}
if (secs == null) return '—'
if (secs < 60) return `${secs}`
const m = Math.floor(secs / 60)
const s = secs % 60
if (m < 60) return s ? `${m}${s}` : `${m}`
const h = Math.floor(m / 60)
return `${h}${m % 60}`
}
function inDateRange(row: VehicleAlarm): boolean {
if (!dateRange.value) return true
const t = parseTime(row.firstAt)
if (t == null) return false
const [from, to] = dateRange.value
return t >= Date.parse(`${from}T00:00:00`) && t <= Date.parse(`${to}T23:59:59.999`)
}
const carOptions = computed(() => {
const map = new Map<number, string>()
for (const r of rows.value) if (!map.has(r.carId)) map.set(r.carId, r.carName || `#${r.carId}`)
return [...map.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => a.value - b.value)
})
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return rows.value.filter((row) => {
if (quickStatus.value === 'active' && row.status !== 'active') return false
if (quickStatus.value === 'cleared' && row.status !== 'cleared') return false
if (carFilter.value != null && row.carId !== carFilter.value) return false
if (levelFilter.value && levelKeyOf(row.level) !== levelFilter.value) return false
if (!inDateRange(row)) return false
if (!tokens.length) return true
const hay = [row.carName, String(row.carId), row.info, String(row.level)].join(' ').toLowerCase()
return tokens.every((tok) => hay.includes(tok))
})
})
const counts = computed(() => ({
active: rows.value.filter((r) => r.status === 'active').length,
cleared: rows.value.filter((r) => r.status === 'cleared').length,
today: rows.value.filter((r) => isToday(r.firstAt)).length,
cars: new Set(rows.value.filter((r) => r.status === 'active').map((r) => r.carId)).size
}))
const statusChips = computed(() => [
{ key: 'all' as const, label: '全部', count: rows.value.length },
{ key: 'active' as const, label: '活跃', count: counts.value.active },
{ key: 'cleared' as const, label: '已恢复', count: counts.value.cleared }
])
function rowClassName({ row }: { row: VehicleAlarm }) {
return row.status === 'active' ? 'is-active-alarm-row' : ''
}
function locateOnMap(row: VehicleAlarm) {
if (!row.carId) { ElMessage.info('无车辆信息'); return }
void router.push({ path: '/admin/map-monitor', query: { focusKind: 'car', focusId: String(row.carId) } })
}
async function reload() {
loading.value = true
try {
const feed = await fetchAlarmFeed(2000)
rows.value = feed.alarms
online.value = feed.online
lastSyncAt.value = feed.lastSyncAt
} catch (err) {
ElMessage.error(`加载报警失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
onMounted(() => {
void reload()
pollTimer = setInterval(() => { if (autoRefresh.value) void reload() }, 6000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.alarm-mgmt-page {
display: flex;
flex-direction: column;
gap: 10px;
height: calc(100vh - 56px - 36px - 32px);
min-height: 0;
color: var(--mg-text-light);
}
.am-stats {
flex: none;
display: flex;
align-items: center;
gap: 26px;
min-height: 54px;
padding: 8px 18px;
border-radius: 10px;
background: rgba(var(--mg-bg-card-rgb), 0.94);
border: 1px solid var(--mg-veil-border);
overflow-x: auto;
}
.am-stat { display: flex; flex-direction: column; gap: 3px; min-width: 56px; }
.am-stat-label { font-size: 11px; color: var(--mg-text-muted); white-space: nowrap; }
.am-stat-value {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 17px;
font-weight: 650;
line-height: 1.1;
}
.am-stat-value.am-danger b { color: var(--mg-status-danger); }
.am-stat-sep { width: 1px; height: 30px; background: var(--mg-veil-border); flex: none; }
.am-stats-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
.am-live {
font-size: 12px;
color: var(--mg-text-muted);
cursor: pointer;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--mg-veil-border);
user-select: none;
}
.am-live.is-live { color: var(--mg-status-success); }
.am-offline { flex: none; }
.am-toolbar {
flex: none;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 12px;
border-radius: 10px;
background: rgba(var(--mg-bg-card-rgb), 0.94);
border: 1px solid var(--mg-veil-border);
}
.am-search { width: min(260px, 100%); }
.am-car { width: 150px; }
.am-level { width: 120px; }
.am-date { width: 250px; }
.am-count { margin-left: auto; font-size: 12px; color: var(--mg-text-muted); font-variant-numeric: tabular-nums; }
.am-chips { flex: none; display: flex; gap: 8px; flex-wrap: wrap; }
.am-chip {
appearance: none;
border: 1px solid var(--mg-veil-border);
background: rgba(var(--mg-bg-card-rgb), 0.9);
color: var(--mg-text-muted);
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.am-chip b { font-family: var(--mg-font-mono); font-variant-numeric: tabular-nums; color: var(--mg-text-light); }
.am-chip:hover { color: var(--mg-text-light); background: var(--mg-veil-2); }
.am-chip.is-active {
color: var(--mg-primary);
border-color: rgba(var(--mg-primary-rgb), 0.45);
background: rgba(var(--mg-primary-rgb), 0.12);
}
.am-table-wrap {
flex: 1 1 0;
min-height: 0;
border-radius: 10px;
background: rgba(var(--mg-bg-card-rgb), 0.94);
border: 1px solid var(--mg-veil-border);
overflow: hidden;
padding: 4px;
}
.am-table-wrap :deep(.is-active-alarm-row) { --el-table-tr-bg-color: rgba(239, 68, 68, 0.08); }
.am-info { color: var(--mg-status-danger); }
.am-footnote { flex: none; margin: 0; font-size: 12px; color: var(--mg-text-muted); }
@media (max-width: 960px) {
.am-search, .am-date { width: 100%; }
}
</style>
@@ -1,17 +1,17 @@
<template>
<div class="car-page">
<el-tabs v-model="tab" type="border-card" class="car-tabs admin-tabs" @tab-change="onTabChange">
<el-tabs v-model="tab" class="car-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
<el-tab-pane label="车辆列表" name="list">
<ReflectionManagerPanel
v-if="tab === 'list'"
kind="car"
kind-label="车辆"
title="车辆管理Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
title="车辆管理"
empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。"
enable-project-save
/>
</el-tab-pane>
<el-tab-pane label="车型样式 / 报警颜色" name="style">
<el-tab-pane label="车型样式" name="style">
<CarStyleEditor v-if="tab === 'style'" />
</el-tab-pane>
</el-tabs>
@@ -39,10 +39,11 @@ onMounted(() => {
<style scoped>
.car-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
}
.car-tabs {
flex: 1;
File diff suppressed because it is too large Load Diff
@@ -1740,8 +1740,14 @@ onUnmounted(() => {
.map-editor-page {
height: calc(100vh - 56px);
display: flex; flex-direction: column;
background: radial-gradient(ellipse at 50% 40%, #2d1456 0%, #1a0930 50%, #0f0420 100%);
color: rgba(232,215,245,0.9);
/* 画布区保持深色;色相跟当前主题侧栏 (--me-chrome-*) */
background: radial-gradient(
ellipse at 50% 40%,
var(--me-chrome-1) 0%,
var(--me-chrome-2) 52%,
#080610 100%
);
color: var(--me-text);
}
.me-body {
flex: 1;
@@ -1767,13 +1773,13 @@ onUnmounted(() => {
border-radius: 12px 0 0 12px;
cursor: pointer;
color: #fff;
background: linear-gradient(135deg, rgba(150, 90, 240, 0.96) 0%, rgba(255, 90, 200, 0.92) 100%);
box-shadow: -4px 0 16px rgba(120, 40, 200, 0.45);
background: var(--mg-primary);
box-shadow: -4px 0 16px var(--me-active-glow);
transition: right 0.26s cubic-bezier(0.25, 0.8, 0.25, 1), filter 0.15s ease, box-shadow 0.15s ease;
}
.ai-assistant-fab:hover {
filter: brightness(1.08);
box-shadow: -6px 0 22px rgba(150, 60, 230, 0.6);
box-shadow: -6px 0 22px var(--me-active-glow);
}
/* 展开时 right 偏移由内联样式按面板实际宽度设置(默认 360);此处仅作兜底。 */
.ai-assistant-fab.is-open {
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
<ReflectionManagerPanel
kind="process"
kind-label="进程"
title="进程管理Mission / Process"
title="进程管理"
empty-text="当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"
show-status-column
status-label="状态"
@@ -20,7 +20,8 @@ import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPan
<style scoped>
.process-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
min-height: 0;
}
</style>
@@ -1,12 +1,12 @@
<template>
<div class="scene-mgr-page">
<el-tabs v-model="activeTab" type="border-card" class="scene-tabs admin-tabs" @tab-change="onTabChange">
<div class="scene-mgr-page ops-console-page">
<el-tabs v-model="activeTab" class="scene-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
<el-tab-pane label="站点" name="site">
<ReflectionManagerPanel
ref="sitePanelRef"
kind="site"
kind-label="站点"
title="站点管理Site / UISite,含禁用/启用、必空点等动作)"
title="站点管理"
empty-text="当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"
enable-project-save
/>
@@ -16,7 +16,7 @@
ref="trackPanelRef"
kind="track"
kind-label="路径"
title="路径管理Track / UITrack,含方向、冲突、投影、二分等动作)"
title="路径管理"
empty-text="当前没有路径;点击右上角「新建路径」选起止站点添加。"
enable-project-save
/>
@@ -26,7 +26,7 @@
ref="specialPanelRef"
kind="special"
kind-label="装饰物"
title="装饰物管理UI_Image / UI_Text / UI_Model"
title="装饰物管理"
empty-text="当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"
enable-project-save
/>
@@ -37,14 +37,8 @@
<script setup lang="ts">
/**
* 场景管理 3 ReflectionManagerPanel 实例承载 site/track/special 三个 subKind
*
* 这里之所以用三个独立 panel 而不是单一 panel + 顶部切换
* - 每个 subKind 列字段可创建子类型完全不同site x/ytrack siteA/siteB
* 装饰物要 imagePath/modelPath 共享一组 ref 会让创建表单状态错乱
* - ReflectionManagerPanel 已经把 list/详情/方法/字段/SSE 全部包好三份实例几乎零边际成本
*
* SceneManagerView 自己只负责 tab 切换 + 默认走 site tab
* 场景管理3 ReflectionManagerPanel 分别承载 site/track/special
* Style A外层 underline tabs + 紧凑运维控制台壳
*/
import { ref } from 'vue'
@@ -57,7 +51,6 @@ const trackPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(nu
const specialPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
function onTabChange(name: string | number) {
// tab refresh SSE
const target =
name === 'site' ? sitePanelRef.value :
name === 'track' ? trackPanelRef.value :
@@ -67,19 +60,19 @@ function onTabChange(name: string | number) {
</script>
<style scoped>
/* N+2tabs `.admin-tabs` styles/theme.css
这里只保留容器布局避免 scoped 选择器把全局规则压住 */
.scene-mgr-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
}
.scene-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
</style>
@@ -11,7 +11,7 @@
<ReflectionManagerPanel
kind="script"
kind-label="脚本"
title="脚本管理CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
title="脚本管理"
empty-text="当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,请在 SimpleLite 工作台新建 Mission(任务)后由调度运行时自动生成。"
disable-create
disable-delete
@@ -31,7 +31,8 @@ import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPan
<style scoped>
.script-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
min-height: 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show More