merge
This commit is contained in:
@@ -13,6 +13,8 @@ MiGu.Server/data/*.db
|
||||
MiGu.Server/data/*.db-shm
|
||||
MiGu.Server/data/*.db-wal
|
||||
MiGu.Server/data/.internal-token
|
||||
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录,*.json 上面那行不递归)
|
||||
MiGu.Server/data/ota/
|
||||
|
||||
# 前端构建产物
|
||||
MiGu.Server/wwwroot/
|
||||
@@ -34,3 +36,8 @@ Thumbs.db
|
||||
Desktop.ini
|
||||
/.cursor/rules
|
||||
/MiGu.Server/wwwroot
|
||||
/MiGu.Server/data/platform.db
|
||||
|
||||
# 临时脚本 / 工具产物(不应入库)
|
||||
.codex-temp/
|
||||
frontends/apps/simple-platform-vue/imgui.ini
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 管理端可调。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 Medulla;1 Clumsy;2 Detour;3 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-5
@@ -7,6 +7,7 @@ using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.OpenApi;
|
||||
using MiGu.Server.Ota;
|
||||
using MiGu.Server.Persistence;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
@@ -31,12 +32,27 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory
|
||||
});
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["urls"])
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))
|
||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS")))
|
||||
// 管理面默认 :8080;WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。
|
||||
{
|
||||
// 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:8080");
|
||||
var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000);
|
||||
var urls = builder.Configuration["urls"]
|
||||
?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_URLS");
|
||||
if (string.IsNullOrWhiteSpace(urls))
|
||||
urls = "http://0.0.0.0:8080";
|
||||
|
||||
var parts = urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var hasReceive = parts.Any(u =>
|
||||
u.Contains($":{receivePort}", StringComparison.OrdinalIgnoreCase)
|
||||
|| u.EndsWith($":{receivePort}/", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasReceive)
|
||||
{
|
||||
urls = string.Join(';', parts.Append($"http://0.0.0.0:{receivePort}"));
|
||||
// 安全提示:该端口挂匿名 upload-mdcs/upload-history(WatchDog 写死回传)。
|
||||
// upload-mdcs 仅在有进行中的拉取会话时可写入,其余管理端点仍需 JWT。请确保本机处于可信内网。
|
||||
Console.WriteLine($"[MiGu.Server] OTA 回传端口 {receivePort} 已监听(0.0.0.0):匿名接收车辆包,仅限可信内网。");
|
||||
}
|
||||
builder.WebHost.UseUrls(urls);
|
||||
}
|
||||
|
||||
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||||
@@ -95,6 +111,11 @@ builder.Services.AddControllers()
|
||||
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||
opt.JsonSerializerOptions.WriteIndented = false;
|
||||
});
|
||||
// WatchDog 回传 M/D/C 可较大;放宽 multipart 默认 128MB 限制
|
||||
builder.Services.Configure<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>();
|
||||
|
||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||
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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -868,6 +868,20 @@ public sealed class WmsService
|
||||
entity.GetType().Name, entity.Id, version);
|
||||
}
|
||||
|
||||
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||
{
|
||||
var msg = e.Message;
|
||||
if (msg.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("unique index", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("duplicate key", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void EnsureUnlocked(EntityBase entity)
|
||||
{
|
||||
if (entity.IsLock)
|
||||
|
||||
@@ -70,8 +70,11 @@ public sealed class WmsTransportPlanner
|
||||
.Where(x => x.LocationType == ContainerLocationTypes.Storage).ToListAsync();
|
||||
var materials = await _db.Materials.AsNoTracking().Where(x => x.Enabled).ToListAsync();
|
||||
var containerMaterials = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeReservations = await _db.WmsTransportReservations.AsNoTracking()
|
||||
.Where(x => x.Status == WmsReservationStatuses.Active).ToListAsync();
|
||||
.Where(x => x.Status == WmsReservationStatuses.Active
|
||||
&& (x.ExpiresAt == null || x.ExpiresAt > now))
|
||||
.ToListAsync();
|
||||
var activeTasks = await _db.WmsTransportTasks.AsNoTracking()
|
||||
.Where(x => WmsTransportTaskStatuses.Active.Contains(x.Status)).ToListAsync();
|
||||
|
||||
|
||||
@@ -166,10 +166,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);
|
||||
@@ -195,14 +212,36 @@ public sealed class WmsTransportTaskService
|
||||
return task;
|
||||
}
|
||||
|
||||
private async Task ExpireStaleReservationsAsync()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var rows = await _db.WmsTransportReservations
|
||||
.Where(x => x.Status == WmsReservationStatuses.Active
|
||||
&& x.ExpiresAt != null
|
||||
&& x.ExpiresAt < now)
|
||||
.ToListAsync();
|
||||
if (rows.Count == 0) return;
|
||||
|
||||
foreach (var row in rows)
|
||||
row.Status = WmsReservationStatuses.Expired;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureNoActiveReservationAsync(Guid containerId, Guid targetStorageId)
|
||||
{
|
||||
await ExpireStaleReservationsAsync();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (await _db.WmsTransportReservations.AnyAsync(x =>
|
||||
x.Status == WmsReservationStatuses.Active && x.ContainerId == containerId))
|
||||
x.Status == WmsReservationStatuses.Active
|
||||
&& (x.ExpiresAt == null || x.ExpiresAt > now)
|
||||
&& x.ContainerId == containerId))
|
||||
throw new InvalidOperationException("容器已被其他任务预占");
|
||||
|
||||
if (await _db.WmsTransportReservations.AnyAsync(x =>
|
||||
x.Status == WmsReservationStatuses.Active && x.TargetStorageId == targetStorageId))
|
||||
x.Status == WmsReservationStatuses.Active
|
||||
&& (x.ExpiresAt == null || x.ExpiresAt > now)
|
||||
&& x.TargetStorageId == targetStorageId))
|
||||
throw new InvalidOperationException("目标库位已被其他任务预占");
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@
|
||||
"Dispatch": {
|
||||
}
|
||||
},
|
||||
"_comment_Ota": "车辆 OTA:包与任务落盘 DataRoot;WatchDog:9776。拉包时车会 POST 到 WatchDog 配置的 serverIP:ReceivePort/upload-mdcs/*(默认 8000,与旧 Electron OTA 一致)。请把各车 watch_dog.json 的 serverIP 设为本机局域网 IP。",
|
||||
"Ota": {
|
||||
"DataRoot": "data/ota",
|
||||
"WatchDogPort": 9776,
|
||||
"ReceivePort": 8000,
|
||||
"RequestTimeoutMs": 60000,
|
||||
"UploadTimeoutMs": 600000,
|
||||
"PublicBaseUrl": ""
|
||||
},
|
||||
"_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。",
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
@@ -65,6 +74,15 @@
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-assistant-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "PlatformScope",
|
||||
"Order": -2,
|
||||
"Match": { "Path": "/api/sl/projection/assistant/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-reflection-selection-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "AnyAuthed",
|
||||
|
||||
@@ -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 @@
|
||||
# 迷榖 OTA(WatchDog)实现计划
|
||||
|
||||
> **状态:** 核心任务已落地(后端 API + 前端工作台)。联调 WatchDog 实车需现场验证。
|
||||
> **面向 AI 代理的工作者:** 按任务顺序实现;每完成一大任务做一次验证。规格:`docs/superpowers/specs/2026-07-19-migu-ota-watchdog-design.md`。
|
||||
|
||||
**目标:** 车辆运维改为「运维总览 + OTA」,平台编排 WatchDog 完成包库/下发/任务/配置/自定义文件/设置与延迟检测。
|
||||
**架构:** MiGu.Server `api/ota/*` 出站调 WatchDog `:9776`;包与任务落盘 `data/ota/`;前端 `OtaWorkbenchView` 只调平台 API。
|
||||
**技术栈:** ASP.NET Core 8、Vue 3、Element Plus、HttpClient、现有 JWT/RBAC。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 后端(新建)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `MiGu.Server/Ota/OtaOptions.cs` | DataRoot、WatchDogPort、超时、本机接收端口 |
|
||||
| `MiGu.Server/Ota/OtaModels.cs` | Package/Target/Job/Settings/VehicleRow DTO |
|
||||
| `MiGu.Server/Ota/OtaPathMap.cs` | MDC 路径与组件键 |
|
||||
| `MiGu.Server/Ota/OtaHash.cs` | MD5 Base64 去 `-` |
|
||||
| `MiGu.Server/Ota/OtaStore.cs` | packages/target/jobs/settings/history 读写 |
|
||||
| `MiGu.Server/Ota/WatchDogClient.cs` | getMDCInfo、update*、get*json、getmdcsexe、latency |
|
||||
| `MiGu.Server/Ota/OtaPackageReceiver.cs` | 供 WatchDog 回传 upload-mdcs* 的内部端点宿主或同进程路由 |
|
||||
| `MiGu.Server/Ota/OtaJobRunner.cs` | 分批、限速、状态机、重试/取消 |
|
||||
| `MiGu.Server/Ota/OtaVehicleSource.cs` | 从 SimpleLite projection 取车列表 |
|
||||
| `MiGu.Server/Controllers/OtaController.cs` | `/api/ota/*` |
|
||||
| `MiGu.Server/Controllers/OtaReceiveController.cs` | WatchDog 回传 `/api/ota/receive/upload-mdcs*` |
|
||||
|
||||
### 后端(修改)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `MiGu.Server/Program.cs` | 注册 OTA 服务 |
|
||||
| `MiGu.Server/appsettings.json` | `Ota` 节 |
|
||||
|
||||
### 前端(新建)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/api/ota.ts` | API 客户端 |
|
||||
| `src/types/ota.ts` | 类型 |
|
||||
| `src/views/shared/ota/OtaWorkbenchView.vue` | 左导航壳 + 顶条 |
|
||||
| `src/views/shared/ota/OtaVehiclesPane.vue` | 车辆升级 |
|
||||
| `src/views/shared/ota/OtaPackagesPane.vue` | 版本库 |
|
||||
| `src/views/shared/ota/OtaJobsPane.vue` | 任务中心 |
|
||||
| `src/views/shared/ota/OtaConfigPane.vue` | 配置同步 |
|
||||
| `src/views/shared/ota/OtaCustomFilePane.vue` | 自定义文件 |
|
||||
| `src/views/shared/ota/OtaSettingsPane.vue` | 设置 |
|
||||
| `src/composables/useOtaWorkbench.ts` | 目标版本、设置、刷新 |
|
||||
|
||||
### 前端(修改)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/views/shared/VehicleHubView.vue` | Tab:overview \| ota;重定向旧 tab |
|
||||
|
||||
---
|
||||
|
||||
## 任务
|
||||
|
||||
### 任务 1:后端基础(Options / Models / Store / Hash)
|
||||
|
||||
1. 创建 `OtaOptions`、`OtaModels`、`OtaPathMap`、`OtaHash`、`OtaStore`。
|
||||
2. Settings 默认:bandwidth=0(不限)、maxCar=2、latencyEnabled=false、rttThresholdMs=200、overThreshold=`skip`、backupPeriodMinutes=60、backupExe=false。
|
||||
3. `Program.cs` + `appsettings.json` 注册。
|
||||
4. 验证:`dotnet build MiGu.Server/MiGu.Server.csproj` 通过。
|
||||
|
||||
### 任务 2:WatchDogClient + 车辆源 + Receive
|
||||
|
||||
1. `WatchDogClient`:MDCInfo、组件上传(multipart)、JSON get/put、触发 getmdcsexe、TCP/HTTP RTT。
|
||||
2. `OtaVehicleSource`:HttpClient 调 SimpleLite `http://127.0.0.1:8222/api/agv/list` 或 projection cars(与现有一致优先 projection)。
|
||||
3. `OtaReceiveController`:接收 upload-mdcs* 写入当前 pull 会话目录。
|
||||
4. 验证:build 通过。
|
||||
|
||||
### 任务 3:JobRunner + OtaController API
|
||||
|
||||
实现规格 §4.2 全部端点;JobRunner 支持 sync/custom/config 三类 job;审计写入 `OpsAuditStore` 或 `data/ota/audit.json`。
|
||||
验证:build 通过;手动 curl GET settings/packages。
|
||||
|
||||
### 任务 4:前端 API + 类型 + Hub Tab 切换
|
||||
|
||||
1. `types/ota.ts`、`api/ota.ts`。
|
||||
2. `VehicleHubView` 仅 overview/ota;挂载 `OtaWorkbenchView`。
|
||||
3. 验证:前端 typecheck/dev 可加载。
|
||||
|
||||
### 任务 5:OTA 工作台 UI(六子页)
|
||||
|
||||
按规格 §5 实现各 Pane;延迟检测开关在车辆升级工具条。
|
||||
验证:页面可切换、设置可保存、无目标时下发被拦截。
|
||||
|
||||
### 任务 6:联调与验收
|
||||
|
||||
对照规格 §9 验收清单;修明显 bug。
|
||||
|
||||
---
|
||||
|
||||
## 关键实现约定
|
||||
|
||||
- MD5:`Convert.ToBase64String(MD5.HashData(bytes)).Replace("-", "")`(与参考工具一致则对照其实现)。
|
||||
- 组件键:`M.exe` `M.dll` `M.pdb` `D.exe` `C.exe` `C.dll` `C.pdb`。
|
||||
- Job kind:`sync` | `customFile` | `configPush`。
|
||||
- 进度字段:`doneSteps` / `totalSteps`;每车每组件 `status`:pending|running|succeeded|failed|skipped。
|
||||
- 拉取包:平台记录 `pendingPullId` + 本机可达 URL,调车 `getmdcsexe?time=`;车推到 `/api/ota/receive/...`。
|
||||
|
||||
---
|
||||
|
||||
## 规格覆盖自检
|
||||
|
||||
| 规格项 | 任务 |
|
||||
|--------|------|
|
||||
| IA / 删维护策略与生命周期 | 4 |
|
||||
| WatchDog 编排 | 2–3 |
|
||||
| 包库/目标/任务/设置/延迟 | 3、5 |
|
||||
| 配置同步/自定义文件 | 3、5 |
|
||||
| 审计 | 3 |
|
||||
| 验收 §9 | 6 |
|
||||
@@ -0,0 +1,23 @@
|
||||
# 迷毂 Outpost UI 实现计划(P0–P1)
|
||||
|
||||
> **面向 AI 代理的工作者:** 按任务顺序实现;规格见 `docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md`。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `src/styles/themes.ts` | PRIMARY 三色 + LEGACY;默认 `outpost-light`;`data-shell` |
|
||||
| `src/styles/theme.css` | 浅色壳选择器改 `data-shell="outpost"`;默认 token 贴近浅色 |
|
||||
| `src/stores/ui.ts` | `availableThemes` / `legacyThemes`;未知 id 迁移 |
|
||||
| `src/components/ThemeSwitcher.vue` | 仅精选 3 |
|
||||
| `src/layouts/AppShell.vue` | 高级 → 兼容主题 |
|
||||
| `src/views/LoginView.vue` | 弱化霓虹(P1) |
|
||||
| 若干 Vue 内 `fame-lavender` 选择器 | 改为 `data-shell="outpost"` |
|
||||
|
||||
## 任务
|
||||
|
||||
1. 重写 `themes.ts`(精选 + legacy + applyThemeVars 写 `data-shell`)
|
||||
2. `theme.css` / 组件选择器:`fame-lavender` → `data-shell="outpost"`
|
||||
3. `ui` store + ThemeSwitcher + AppShell 高级入口
|
||||
4. Login 弱化星空主导(能快则做)
|
||||
5. `pnpm`/`npm` typecheck 或 build 验证
|
||||
@@ -0,0 +1,258 @@
|
||||
# 迷榖车辆运维 OTA(WatchDog)设计规格
|
||||
|
||||
**日期:** 2026-07-19
|
||||
**状态:** 已批准并实现中
|
||||
**范围:** 在迷榖「车辆运维」中交付完整 OTA 能力;车上协议沿用 WatchDog;管理面由 MiGu.Server 编排。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 参考实现
|
||||
|
||||
`E:\Work\FRLD\OTA\ota` 为 Electron OTA 管理工具:版本拉取/管理、多车 M/D/C 同步、自定义文件、配置 JSON 下发、带宽限速与分批。车上依赖 WatchDog(`:9776`)。参考工具缺少任务状态机、可靠进度与正式回滚。
|
||||
|
||||
### 1.2 迷榖现状
|
||||
|
||||
- 「车辆运维」=`VehicleHubView`:运维总览 / 维护策略 / 车队生命周期。
|
||||
- OTA 仅为 `OtaPolicy`(enabled / batchSize / rollbackOnFail)只读占位,无包库、下发、进度或审计。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
1. 车辆运维顶部仅保留 **运维总览** 与 **OTA**。
|
||||
2. 删除「维护策略」「车队生命周期」Tab 及维护策略配置页(本期不迁移)。
|
||||
3. OTA 全面对齐参考工具能力,并补齐平台侧任务进度、失败重试、审计。
|
||||
4. 车上协议 **沿用 WatchDog**;浏览器不直连车辆。
|
||||
5. UI 按迷榖运维工作台语言重做,不照搬 Electron 通用后台壳。
|
||||
|
||||
### 1.4 非目标(本期)
|
||||
|
||||
- 差分/增量包、签名验签、A/B 双分区
|
||||
- 空闲时段自动升级
|
||||
- 浏览器直连 WatchDog
|
||||
- 维护策略配置的任何入口保留或迁移
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构决策
|
||||
|
||||
**选定:方案 A — 平台编排。**
|
||||
|
||||
|
||||
| 组件 | 职责 |
|
||||
| ------------------ | ---------------------------- |
|
||||
| 前端 | 仅调用 `/api/ota/`*,展示对照/进度/设置 |
|
||||
| MiGu.Server OTA 模块 | 包存储、目标版本、任务状态机、分批、限速、延迟探测、审计 |
|
||||
| 车队名单 | 复用现有投影/车辆列表(IP、名称、状态) |
|
||||
| WatchDog `:9776` | 装包、读版本、回传包、读写 JSON、自定义文件 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. 信息架构
|
||||
|
||||
### 3.1 车辆运维 Tab
|
||||
|
||||
|
||||
| Tab | 内容 |
|
||||
| ---- | ------------------ |
|
||||
| 运维总览 | 保持现有:健康卡片、维护态、车队分配 |
|
||||
| OTA | 新工作台 |
|
||||
|
||||
|
||||
- 路由:`/admin/config/vehicle-hub?tab=ota`(监控侧同理)。
|
||||
- 旧 `?tab=maintenance` / `?tab=fleet` 深链重定向到 `ota`。
|
||||
|
||||
### 3.2 OTA 子导航(左侧)
|
||||
|
||||
1. **车辆升级** — 版本对照、勾选、分组件/全量同步、延迟检测开关
|
||||
2. **版本库** — 从车拉取 / 本地上传、设为目标、清理
|
||||
3. **任务中心** — 进行中/历史、进度、重试、取消
|
||||
4. **配置同步** — Medulla/Detour/Clumsy JSON 浏览、编辑、多车下发
|
||||
5. **自定义文件** — 文件 + 车上路径 + 重启策略
|
||||
6. **设置** — 带宽、并发、备份、延迟检测阈值与门禁策略
|
||||
|
||||
### 3.3 与运维总览的边界
|
||||
|
||||
|
||||
| 能力 | Tab |
|
||||
| ------------------------------------ | ---- |
|
||||
| 健康、电量、报警、维护态、车队分配、开车上页 | 运维总览 |
|
||||
| 版本对照、包库、下发、JSON/自定义文件、任务、OTA 设置、延迟检测 | OTA |
|
||||
|
||||
|
||||
运维总览不强制展示完整 MDC 哈希;若后续加「版本落后」标记,仅作跳转 OTA 的入口。
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端:存储、API、任务
|
||||
|
||||
### 4.1 存储布局
|
||||
|
||||
根目录:`MiGu.Server/data/ota/`(可配置)
|
||||
|
||||
|
||||
| 路径 | 用途 |
|
||||
| ---------------- | ----------------------- |
|
||||
| `packages/{id}/` | 一次拉取或上传的 M/D/C 文件树 |
|
||||
| `target.json` | 当前目标版本(等价参考 `ota.json`) |
|
||||
| `jobs/` | 任务元数据与事件日志 |
|
||||
| `history/` | 定时备份(JSON ± exe) |
|
||||
|
||||
|
||||
- 版本标识:文件 **MD5(Base64,去除 `-`)**,与 WatchDog `getMDCInfo` 对齐。
|
||||
- 组件映射:内置/可配置 `MDCPath`(Medulla / Detour / Clumsy 的 exe·dll·pdb)。
|
||||
|
||||
### 4.2 API
|
||||
|
||||
|
||||
| 方法 | 路径 | 作用 |
|
||||
| ------- | ------------------------------------- | ------------------------ |
|
||||
| GET | `/api/ota/vehicles` | 车列表 + 车上 MDC 版本 +(可选)RTT |
|
||||
| POST | `/api/ota/packages/pull` | 从指定车拉取包 |
|
||||
| POST | `/api/ota/packages/upload` | 管理端上传包 |
|
||||
| GET | `/api/ota/packages` | 版本库列表 |
|
||||
| POST | `/api/ota/packages/{id}/activate` | 设为目标版本 |
|
||||
| DELETE | `/api/ota/packages/{id}` | 清理(当前目标不可删) |
|
||||
| GET | `/api/ota/target` | 当前目标 |
|
||||
| POST | `/api/ota/jobs` | 创建下发任务(全量/组件/自定义文件/JSON) |
|
||||
| GET | `/api/ota/jobs` · `/jobs/{id}` | 列表与详情进度 |
|
||||
| POST | `/api/ota/jobs/{id}/retry` · `cancel` | 重试失败项 / 取消未开始 |
|
||||
| GET/PUT | `/api/ota/settings` | 带宽、maxCar、备份、延迟检测 |
|
||||
| GET/PUT | `/api/ota/config/{carId}/{app}` | 单车 JSON(medulla |
|
||||
| POST | `/api/ota/config/push` | JSON 多车下发(走 job) |
|
||||
| GET | `/api/ota/latency` | 按需批量 RTT(受设置开关约束) |
|
||||
|
||||
|
||||
权限:JWT/RBAC;Platform 全量写;Monitor 可读 + 受控执行(`ops.ota.`*)。写操作进入审计。
|
||||
|
||||
### 4.3 任务状态机
|
||||
|
||||
```
|
||||
pending → probing(可选) → running → succeeded
|
||||
↘ failed | partial
|
||||
↘ cancelled
|
||||
```
|
||||
|
||||
- **分批:** `maxCar`(兼容原 `ota.batchSize` 语义)
|
||||
- **限速:** 服务端上传流按 `bandwidth` kb/s 节流
|
||||
- **进度:** 按「车 × 组件」;SSE 或短轮询推到任务中心
|
||||
- **重试:** 仅重跑失败车辆/组件
|
||||
- **取消:** 仅 `pending` / 未开始批次;已在传的组件尽量完成并标记
|
||||
- **延迟门禁:** 任务可带 `requireLatencyCheck`;超阈值按设置跳过或二次确认后仍下发
|
||||
|
||||
### 4.4 WatchDog 映射
|
||||
|
||||
|
||||
| 能力 | WatchDog |
|
||||
| ---- | --------------------------------------------------------- |
|
||||
| 读版本 | `GET /getMDCInfo` |
|
||||
| 拉包 | `GET /getmdcsexe` → 车推到平台接收端(或平台主动拉,实现时选更稳方案) |
|
||||
| 下发 | `/updateMedullaExecutable` 等 + `/updateFile/{name}/{op}/` |
|
||||
| JSON | `get*json` / `update*json` |
|
||||
| 备份 | `gethistoryexe` + 平台 `history/` |
|
||||
|
||||
|
||||
参考工具 Express `:8000` 接收能力收进 MiGu(内部端点,仅供 WatchDog 回传)。服务端对 WatchDog 统一超时与有限重试。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI 设计
|
||||
|
||||
**Design Read:** 工业 B2B 运维工作台;延续运维总览玻璃卡片 + 状态色 + JetBrains Mono;密度偏驾驶舱。
|
||||
**Dial:** Variance 4 / Motion 3 / Density 7。使用 `--mg-`* 与 `--mg-status-`*。
|
||||
|
||||
### 5.1 骨架
|
||||
|
||||
左子导航 + 右工作区;顶条固定:**当前目标版本摘要**(M/D/C 短哈希 + 名称)+ 进行中任务角标。
|
||||
|
||||
### 5.2 车辆升级
|
||||
|
||||
- 工具条:搜索、车队筛选、「仅显示不一致」、**网络延迟检测开关**、刷新、同步全部/分组件
|
||||
- 主表:勾选 | 车名/ID | IP | Medulla | Detour | Clumsy | RTT | 维护态
|
||||
- 与目标一致 → 绿;不一致 → 琥珀;不可达 → 灰
|
||||
- 延迟检测关:RTT 为「—」;开:数值 + 超阈值着色
|
||||
- 有勾选时底栏粘性操作条:已选数、预计批次、开始下发(二次确认)
|
||||
|
||||
### 5.3 版本库
|
||||
|
||||
包列表(时间、来源 IP、体积、是否目标)+ 包内文件树与哈希;操作:拉取、上传、激活、删除。
|
||||
|
||||
### 5.4 任务中心
|
||||
|
||||
进行中(车×组件进度)+ 历史;详情含错误摘要、重试失败项、取消未开始。
|
||||
|
||||
### 5.5 配置同步 / 自定义文件 / 设置
|
||||
|
||||
- 配置:选车 → JSON 树 → 编辑 → 多车下发(进任务)
|
||||
- 自定义文件:文件、车上路径、重启策略(无/M/D/C/WatchDog)、多车 → 任务
|
||||
- 设置:传输(带宽、maxCar)、延迟检测(默认开关、RTT 阈值、超限策略)、备份、展示名
|
||||
|
||||
### 5.6 交互原则
|
||||
|
||||
- 下发二次确认,写清目标版本与车辆数
|
||||
- 延迟开且超阈值:默认排除并提示;设置可改为「仍允许但确认」
|
||||
- 任务进度可离页后续看(持久化)
|
||||
- 动效克制:进度与状态点过渡即可
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误处理与审计
|
||||
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --------------- | ---------------------------- |
|
||||
| WatchDog 不可达/超时 | 该车失败,不阻塞同批其他车;任务可为 `partial` |
|
||||
| 上传中断/哈希不匹配 | 组件级失败;可按失败项重试 |
|
||||
| 延迟超阈值 | 依设置跳过或确认后下发 |
|
||||
| 无目标版本却同步 | 前端拦截 + API 400 |
|
||||
| 磁盘满/包损坏 | 拉取/上传失败,不激活残包 |
|
||||
| 任务取消 | 仅未开始批次 |
|
||||
|
||||
|
||||
审计覆盖:激活目标、创建/取消/重试任务、改设置、推 JSON/自定义文件(操作者、时间、摘要)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端改动要点(实现指引)
|
||||
|
||||
- `VehicleHubView`:Tab 改为 `overview` | `ota`;移除 `VehicleMaintenanceView` / `FleetLifecycleView` 挂载。
|
||||
- 新增 `views/.../OtaWorkbenchView.vue`(及子页/composables/api)。
|
||||
- 新增 `src/api/ota.ts` 对接 `/api/ota/`*。
|
||||
- 路由/深链:`maintenance`/`fleet` → `ota`。
|
||||
- 删除或停用对维护策略页、FleetLifecycle 只读 OTA 页的导航依赖;`FleetLifecycleConfig.ota` 可迁移到 `/api/ota/settings` 后废弃只读 UI。
|
||||
|
||||
---
|
||||
|
||||
## 8. 后端改动要点(实现指引)
|
||||
|
||||
- 新增 OTA Controller / Service / 存储 / Job runner / WatchDog HttpClient。
|
||||
- 配置项:OTA 数据根路径、WatchDog 端口(默认 9776)、超时。
|
||||
- RBAC:`ops.ota.`*;与现有 Ops 审计集成或并行 OTA audit store。
|
||||
- 包接收端点替代原 Express `:8000` 的 `upload-mdcs`* / `upload-history`*。
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. 车辆运维仅见「运维总览」「OTA」;旧 Tab 深链落到 OTA。
|
||||
2. 可从车拉取或上传包,激活为目标,在车辆升级页看到绿/琥珀对照。
|
||||
3. 可分批全量或分组件下发;任务中心可见进度;失败可重试;可取消未开始。
|
||||
4. 延迟检测开关生效:关不探测;开显示 RTT 并按阈值门禁。
|
||||
5. 配置 JSON 多车下发、自定义文件同步可用。
|
||||
6. 设置可持久化(带宽、并发、备份、延迟策略)。
|
||||
7. 写操作有审计;浏览器不直连 `:9776`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 已确认决策摘要
|
||||
|
||||
|
||||
| 决策 | 选择 |
|
||||
| ------ | ---------------------- |
|
||||
| 车上协议 | WatchDog |
|
||||
| IA | 运维总览 + OTA;去掉维护策略与生命周期 |
|
||||
| 维护策略配置 | 本期删除 |
|
||||
| 功能范围 | 全面对齐参考工具 + 平台任务/进度/重试 |
|
||||
| 架构 | MiGu.Server 平台编排 |
|
||||
| 延迟检测 | OTA 内按钮开关;开才检测 |
|
||||
@@ -0,0 +1,273 @@
|
||||
# 迷毂平台 UI · 锁定 Outpost 浅色设计规格
|
||||
|
||||
> 状态:待用户审查(主题策略已按反馈修订)
|
||||
> 日期:2026-07-25
|
||||
> 参考站:[Outpost · Fairyland Technology](https://fairylandtech.amerc.ai)
|
||||
> 决策:方案 1 壳层气质(Outpost)+ **主路径精选 3 套配色(浅色 / 紫色 / 蓝色)**;旧深色霓虹主题**保留并藏入高级**
|
||||
> 范围前端:`Migu2.0/frontends/apps/simple-platform-vue`
|
||||
> 不改:SimpleLite ImGui / CycleGUI 桌面壳、后端 API 契约
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
- 迷毂 Web(登录 → AppShell → 业务页 → 地图编辑器 chrome)在视觉与壳层气质上对齐 Outpost。
|
||||
- **壳层统一为 Outpost 结构**:浅主区 + 深色纹理侧栏 + 纸白卡片 + 柔阴影;无默认霓虹 orb。
|
||||
- **主路径提供 3 套精选配色**(可一键切换):浅色(默认)、紫色、蓝色——同结构、不同主色/侧栏色相。
|
||||
- 旧深色霓虹主题(星云紫等)**代码保留**,放入「高级 / 兼容主题」,不进主切换列表。
|
||||
- 保留调度业务信息架构;**不**把 Outpost 会话列表硬套进调度导航。
|
||||
- 状态色(绿/琥珀/红/蓝/灰)与品牌色解耦,语义不变。
|
||||
|
||||
### 1.2 非目标
|
||||
|
||||
- 不重写业务逻辑、路由权限、Projection/SSE 协议。
|
||||
- 不第一阶段替换 Element Plus(以 CSS 变量 + 皮肤覆盖为主)。
|
||||
- 不强制 3D 画布(webVRender)改浅色底。
|
||||
- 不把 Outpost 的 Agent 对话 IA 迁移为迷毂主导航。
|
||||
- 主路径不堆砌过多主题(精选 3 + 高级兼容即可)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品决策(已确认)
|
||||
|
||||
| 项 | 决策 |
|
||||
|---|---|
|
||||
| 壳层气质 | Outpost:浅主区 + 深侧栏 + 纸白卡 + 柔阴影 |
|
||||
| 主路径配色 | **3 套精选**:浅色(默认)/ 紫色 / 蓝色 |
|
||||
| 旧深色主题 | **保留**,藏入用户菜单「高级 → 兼容主题」 |
|
||||
| ThemeSwitcher | **保留在顶栏**,只列出精选 3 套;兼容主题在高级入口 |
|
||||
| 登录页 | 跟当前精选主题(默认浅色 Outpost);去掉星空霓虹主导 |
|
||||
| 地图编辑器 | Chrome 跟精选浅色壳;**3D 画布保持深色** |
|
||||
| AI 助手抽屉 | 视觉跟 Outpost 对话区,不改后端权限 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 参考站提炼(Outpost)
|
||||
|
||||
### 3.1 信息架构(借壳不借业务)
|
||||
|
||||
```
|
||||
┌────────────┬──────────────────────────────────────┐
|
||||
│ Sidebar │ Top context (optional, light) │
|
||||
│ ~256px ├──────────────────────────────────────┤
|
||||
│ dark plum │ Main workspace │
|
||||
│ textured │ paper cards / lists / forms │
|
||||
│ brand+nav │ │
|
||||
└────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
迷毂映射:
|
||||
|
||||
| Outpost | 迷毂 |
|
||||
|---|---|
|
||||
| Sidebar brand + list | `AppShell` 侧栏 logo + `el-menu` |
|
||||
| Workspace | `el-main` + 各业务 View |
|
||||
| Artifact / card | KPI 卡、列表卡、属性面板、抽屉 |
|
||||
| Conversation chrome | AI 助手抽屉(仅视觉) |
|
||||
|
||||
### 3.2 设计 Token(锁定值)
|
||||
|
||||
来源:Outpost 线上 CSS(`color-scheme: light`)。
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `--op-page` | `#f6f3fb` | 应用主区底 |
|
||||
| `--op-paper` | `#fffefd` | 卡片/面板底 |
|
||||
| `--op-paper-soft` | `#fbf9fd` | 次级表面 |
|
||||
| `--op-ink` | `#28213a` | 主文字 |
|
||||
| `--op-muted` | `#756d85` | 次级文字 |
|
||||
| `--op-faint` | `#9a93a7` | 弱化/占位 |
|
||||
| `--op-line` | `#e8e2ef` | 分割线 |
|
||||
| `--op-line-strong` | `#ddd4e8` | 强调边框 |
|
||||
| `--op-violet` | `#7543e8` | 品牌主色 |
|
||||
| `--op-violet-strong` | `#5d2ecb` | 主色按下/深 |
|
||||
| `--op-violet-soft` | `#eee8ff` | 主色浅底 |
|
||||
| `--op-mint` / `--op-amber` / `--op-danger` / `--op-blue` | 见参考站 | 仅作辅助;**业务状态仍用既有 `--mg-status-*`** |
|
||||
| `--op-shadow` | `0 12px 35px rgba(54,35,78,.08)` | 默认浮起 |
|
||||
| `--op-shadow-strong` | `0 22px 65px rgba(46,24,72,.18)` | 弹层/强调卡 |
|
||||
| `--op-radius` | `16px` | 默认圆角 |
|
||||
| Sidebar | `linear-gradient(160deg,#3f2454,#2c193c 45%,#241530)` + 细纹理 | 侧栏底 |
|
||||
| Font | Inter + 系统无衬线;数据位保留 JetBrains Mono | 与现 `--mg-font-*` 对齐 |
|
||||
|
||||
### 3.3 明确禁止(相对当前「星云紫」)
|
||||
|
||||
- 主路径禁用:大面积霓虹 glow、多层 orb 背景、深色玻璃卡片作默认表面。
|
||||
- 主路径禁用:把品牌紫当 success/warning。
|
||||
- 避免再引入第三套互不兼容的色板命名;Outpost token **映射进**既有 `--mg-*`,业务组件继续只读 `--mg-*`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 精选三套主题 + Token 映射
|
||||
|
||||
三套均遵守同一 Outpost 壳层规则(浅主区 / 纸白卡 / 柔阴影 / orb=0 / radius≈16),仅 **主色 + 侧栏色相 + 主区底轻微染色** 不同。
|
||||
|
||||
### 4.1 主路径精选(`PRIMARY_THEMES`)
|
||||
|
||||
| id | 显示名 | 定位 | 主色 | 侧栏 | 主区底 |
|
||||
|---|---|---|---|---|---|
|
||||
| `outpost-light` | **浅色**(默认) | 最贴近参考站 | `#7543e8` | `#3f2454 → #241530`(plum) | `#f6f3fb` |
|
||||
| `outpost-purple` | **紫色** | 同结构、更饱和紫品牌 | `#7c3aed` | `#2d1b69 → #1a1040` | `#f3eefc`(略偏紫) |
|
||||
| `outpost-blue` | **蓝色** | 同结构、蓝科技感 | `#3c78d2` | `#0f2744 → #0a1a30` | `#f3f6fb`(略偏蓝) |
|
||||
|
||||
共用表面:
|
||||
|
||||
| `--mg-*` | 值 |
|
||||
|---|---|
|
||||
| `--mg-bg-card-rgb` | paper `255,254,253` |
|
||||
| `--mg-text` 系 | ink `#28213a` / muted `#756d85`(浅底深字) |
|
||||
| `--mg-glass-*` | 白卡片 + 细边 + 柔阴影(无强 blur glow) |
|
||||
| `--mg-radius*` | 基准 16px |
|
||||
| `--mg-orb-*-opacity` | **0** |
|
||||
| 状态色 | 继续 `--mg-status-*`,不随主题主色改语义 |
|
||||
|
||||
### 4.2 高级兼容主题(`LEGACY_THEMES`,保留)
|
||||
|
||||
现有深色霓虹预设移入此列表(至少含):
|
||||
|
||||
- `industrial-purple` 星云紫
|
||||
- `deep-azure` 深邃蓝
|
||||
- `emerald-forge` / `crimson-iron` / `graphite-steel` / `amber-forge`(若仍在代码中)
|
||||
- 旧 `fame-lavender`:合并进 `outpost-purple` 或标 deprecated 后从主列表移除
|
||||
|
||||
入口:用户头像菜单 → **高级 → 兼容主题**(折叠)。主顶栏 `ThemeSwitcher` **只显示 3 个精选**。
|
||||
|
||||
### 4.3 默认与迁移
|
||||
|
||||
- `DEFAULT_THEME_ID = 'outpost-light'`
|
||||
- `:root` 默认 CSS 与 `outpost-light` 一致,避免闪屏
|
||||
- localStorage 若为未知 / 已 deprecated id → 迁移到 `outpost-light`
|
||||
- 若为 `LEGACY_THEMES` 中的 id → **尊重用户选择**(不强制踢回浅色)
|
||||
- 登录页品牌区跟当前主题侧栏色;`--lg-*` 随精选主题切换或登录页固定跟 `outpost-light`(实现时二选一,推荐:**登录固定 outpost-light,进壳后再跟用户主题**)
|
||||
|
||||
---
|
||||
|
||||
## 5. 壳层与页面改造要点
|
||||
|
||||
### 5.1 AppShell
|
||||
|
||||
- 侧栏:纹理渐变(随主题色相)、品牌区分隔线、菜单选中用 soft 底或左侧高光条。
|
||||
- 主区装饰 orb:`opacity=0`。
|
||||
- 顶栏:浅底、细边、弱阴影;breadcrumb + **精选 ThemeSwitcher(3 项)** + 用户区。
|
||||
- 用户菜单增加「高级 → 兼容主题」子菜单(列出 `LEGACY_THEMES`)。
|
||||
- 底栏:弱化字色或并入顶栏次级信息(可选后续)。
|
||||
- `aside-footer`:版本号;可选极简当前主题名。
|
||||
|
||||
### 5.2 登录 / 配置向导
|
||||
|
||||
- 布局可保留左右分栏,但:
|
||||
- 背景:浅底 + 可选极轻纹理,不用星空粒子主导。
|
||||
- 左:深色品牌板(默认跟 `outpost-light` 侧栏)。
|
||||
- 右:paper 表单卡 + 主色按钮。
|
||||
- 配置向导与登录同肤。
|
||||
|
||||
### 5.3 Element Plus 皮肤
|
||||
|
||||
统一覆盖(全局):
|
||||
|
||||
- Button primary / default
|
||||
- Card / Dialog / Drawer / MessageBox
|
||||
- Table / Pagination
|
||||
- Menu(侧栏已透明底,补选中态)
|
||||
- Input / Select / Tabs / Tag
|
||||
|
||||
原则:纸白表面、ink 文字、violet 主按钮、radius≈14–16、阴影用 `--op-shadow`。
|
||||
|
||||
### 5.4 业务页(渐进,同一皮肤)
|
||||
|
||||
优先级:
|
||||
|
||||
1. Dashboard / 地图监控 / 调度工作台
|
||||
2. 任务 / 告警 / 车辆中心
|
||||
3. OTA / 配置中心 / 服务状态
|
||||
4. AI 助手抽屉
|
||||
|
||||
统一模式:`page-header` + paper 卡片栅格 + 紫 CTA;清理页面内 `industrial-purple` 专用覆盖或改为仅在「兼容主题」下生效。
|
||||
|
||||
### 5.5 地图编辑器
|
||||
|
||||
| 层 | 皮肤 |
|
||||
|---|---|
|
||||
| EditTopBar / ToolRail / PropertyPanel / StatusBar / AI 侧栏 | Outpost 浅色 |
|
||||
| Workspace3D / webVRender 画布 | **保持深色视口** |
|
||||
| 与壳层交界 | 1px `--op-line`,避免深浅硬切刺眼 |
|
||||
|
||||
### 5.6 主题列表处理
|
||||
|
||||
| 分组 | 主题 | 处理 |
|
||||
|---|---|---|
|
||||
| 精选 | `outpost-light` / `outpost-purple` / `outpost-blue` | 新建或由现有浅色主题改版;顶栏可切换 |
|
||||
| 合并 | `fame-lavender` | 并入 `outpost-purple` 或启动时 remap |
|
||||
| 高级兼容 | `industrial-purple`、`deep-azure`、绿/赤/钢/琥珀等 | 保留完整 vars,仅高级菜单可选 |
|
||||
| 持久化 | 精选或 legacy id | 精选正常读写;未知 id → `outpost-light`;legacy id **不强制迁移** |
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件影响面(实现时)
|
||||
|
||||
| 文件/目录 | 职责 |
|
||||
|---|---|
|
||||
| `src/styles/theme.css` | 默认 token、Element 覆盖、禁 orb |
|
||||
| `src/styles/themes.ts` | `PRIMARY_THEMES`(3)+ `LEGACY_THEMES`;默认 `outpost-light` |
|
||||
| `src/stores/ui.ts`(或等价) | 默认主题、未知 id 迁移、legacy 可选 |
|
||||
| `src/components/ThemeSwitcher.vue` | 只渲染精选 3;样式 Outpost 化 |
|
||||
| `src/layouts/AppShell.vue` | 侧栏/顶栏;用户菜单挂「兼容主题」 |
|
||||
| `src/views/LoginView.vue` | 登录换肤 |
|
||||
| `design-system/MASTER.md` | 产品风格改为 Outpost 浅色工作台 |
|
||||
| `design-system/outpost.md` | Token 与组件规范(新建) |
|
||||
| 各 View 内 `:root[data-theme="industrial-purple"]` 块 | 降级为 legacy 或删除 |
|
||||
| 地图编辑器 chrome 组件 | 浅色适配 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 分阶段交付
|
||||
|
||||
| Phase | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| **P0** | 三套精选 token + 默认 `outpost-light` + legacy 分组 + 文档 | 冷启动浅色 Outpost;可切紫/蓝 |
|
||||
| **P1** | AppShell + 登录 + Element 皮肤 + ThemeSwitcher(3) + 高级兼容入口 | 壳层/登录 Outpost 化;高级仍能开星云紫等 |
|
||||
| **P2** | Dashboard / 监控 / 调度 / 列表页 | 主业务无霓虹玻璃默认态 |
|
||||
| **P3** | 地图编辑器 chrome + AI 抽屉 | 外围浅、画布深;助手区纸白气泡 |
|
||||
| **P4** | 清理 legacy 覆盖、MASTER 更新、回归清单 | 无残留 `industrial-purple` 主路径样式 |
|
||||
|
||||
每阶段可独立合并;**不在本规格内自动改 SimpleLite 原生菜单皮肤**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 深色主题下写死的白字/半透明在浅底不可读 | P0 强制迁移 + P2 逐页扫对比度 |
|
||||
| 地图页信息密度高,浅色易显脏 | 卡片分区 + 表格紧凑密度,状态色保留 |
|
||||
| Element 覆盖不全 | 先覆盖高频控件,缺口记入回归清单 |
|
||||
| 用户 localStorage 旧主题 | 启动迁移 + 一次性提示(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单(整站)
|
||||
|
||||
- [ ] 未登录:登录页为 Outpost 浅色气质
|
||||
- [ ] 登录后:侧栏深紫纹理 + 主区 `#f6f3fb` + 纸白卡片
|
||||
- [ ] 顶栏可切换 **浅色 / 紫色 / 蓝色** 三套精选
|
||||
- [ ] 用户菜单「高级 → 兼容主题」仍能选星云紫等旧深色
|
||||
- [ ] 监控/调度/OTA/配置:纸白卡、ink 字、无默认霓虹 orb
|
||||
- [ ] 地图编辑器:工具/属性浅色,3D 画布仍深色可读
|
||||
- [ ] 告警/成功/故障色不被品牌色替换
|
||||
- [ ] 刷新后主题保持;未知 id 回落 `outpost-light`
|
||||
|
||||
---
|
||||
|
||||
## 10. 开放项(审查时确认)
|
||||
|
||||
1. ~~旧深色主题~~ → **已确认:保留并藏入高级**
|
||||
2. **底栏 footer**:保留弱化版,还是删除并入顶栏?(建议:P1 先弱化保留)
|
||||
3. 实现计划:`Migu2.0/docs/superpowers/plans/2026-07-25-migu-outpost-ui-lock.md`(本规格通过后编写)
|
||||
|
||||
---
|
||||
|
||||
## 11. 审查结论(请用户勾选)
|
||||
|
||||
- [ ] 同意本规格(含精选三色 + 高级保留旧主题),可编写实现计划并开工
|
||||
- [ ] 需要修改:________
|
||||
@@ -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
@@ -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 header。fetch 这里手动对齐
|
||||
* axios 的双轨鉴权(Cookie + Bearer + X-Scope)。后端见 SimpleLite `Web/Assistant/AssistantApi.cs`。
|
||||
*/
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
const REST = '/sl/projection/assistant'
|
||||
|
||||
export interface AssistantSessionMeta {
|
||||
id: string
|
||||
title: string
|
||||
created: string
|
||||
updated: string
|
||||
turns: number
|
||||
}
|
||||
|
||||
export interface AssistantToolMeta {
|
||||
name: string
|
||||
description: string
|
||||
isWrite: boolean
|
||||
}
|
||||
|
||||
export interface AssistantHistoryTurn {
|
||||
role: 'user' | 'assistant' | 'tool'
|
||||
content?: string | null
|
||||
toolCalls?: { name: string; arguments: string }[]
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface AssistantHistory {
|
||||
id: string
|
||||
title: string
|
||||
created: string
|
||||
updated: string
|
||||
turns: AssistantHistoryTurn[]
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<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.2:session / token / tool_call / tool_result / error / done。
|
||||
*/
|
||||
export async function streamChat(
|
||||
payload: { message: string; sessionId?: string | null; profile?: string },
|
||||
handlers: AssistantStreamHandlers,
|
||||
signal: AbortSignal
|
||||
): Promise<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=仅 UITrack;curve=贝塞尔/弧/NURBS */
|
||||
trackMode?: SelectTrackMode
|
||||
}
|
||||
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
|
||||
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
|
||||
}
|
||||
@@ -195,11 +204,68 @@ export const mapEditApi = {
|
||||
batch: (ops: BatchOp[]) =>
|
||||
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
|
||||
|
||||
/**
|
||||
* CAD 相对包围盒对齐(SimpleLite CadAlignService)。
|
||||
* mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV
|
||||
*/
|
||||
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
|
||||
unwrap<{
|
||||
mode: string
|
||||
count: number
|
||||
previous: Array<{ kind: string; id: number; x: number; y: number }>
|
||||
updated: Array<{ kind: string; id: number; x: number; y: number }>
|
||||
}>(http.post(`${BASE}/cad/align`, { mode, targets })),
|
||||
|
||||
/** 横向/纵向等距建站(CadBatchGenerateService.PlanLinear)。 */
|
||||
cadBatchLinear: (p: {
|
||||
horizontal: boolean
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
count: number
|
||||
layer?: string
|
||||
namePrefix?: string
|
||||
}) =>
|
||||
unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>(
|
||||
http.post(`${BASE}/cad/batch-linear`, p)
|
||||
),
|
||||
|
||||
/** 矩阵建站(CadBatchGenerateService.PlanMatrix)。 */
|
||||
cadBatchMatrix: (p: {
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
rows: number
|
||||
cols: number
|
||||
layer?: string
|
||||
namePrefix?: string
|
||||
}) =>
|
||||
unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>(
|
||||
http.post(`${BASE}/cad/batch-matrix`, p)
|
||||
),
|
||||
|
||||
copyFieldsTo: (kind: MapEditKind | string, id: number, fieldNames: string[], targets: Array<{ kind: string; id: number }>) =>
|
||||
unwrap<{ copied: number }>(http.post(`${BASE}/objects/${kind}/${id}/fields/copy-to`, { fieldNames, targets })),
|
||||
|
||||
// 拾取 / 仪表盘
|
||||
pick: () => unwrap<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/cars;502 或空列表时回退 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;
|
||||
|
||||
+168
@@ -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>
|
||||
+363
-218
@@ -16,11 +16,8 @@
|
||||
<div v-else class="object-pane">
|
||||
<!-- Header card:ID / 类型 / 名称 / 图层 / 删除按钮 -->
|
||||
<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); }
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
+245
-109
@@ -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) =>
|
||||
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.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 AA)。scoped 限本组件,不影响地图编辑画布。 ── */
|
||||
: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>
|
||||
|
||||
+14
-14
@@ -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;
|
||||
}
|
||||
|
||||
+2
-3
@@ -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 {
|
||||
|
||||
+256
-20
@@ -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;
|
||||
|
||||
+17
@@ -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,20 +14,77 @@ 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 系统:深紫侧栏 + 浅紫白主区,柔和耐看,长时间使用不刺眼',
|
||||
preview: '#7c3aed',
|
||||
previewAccent: '#a855f7',
|
||||
/**
|
||||
* 地图编辑 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: {
|
||||
// 主色保留紫色(与 FAME 一致),但 hover/accent 使用更明亮的 violet/purple
|
||||
'--mg-primary': '#7c3aed',
|
||||
'--mg-primary-rgb': '124, 58, 237',
|
||||
'--mg-primary-hover': '#8b5cf6',
|
||||
@@ -36,84 +92,139 @@ export const THEMES: ThemePreset[] = [
|
||||
'--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-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': '249, 246, 255',
|
||||
'--mg-bg-card-darker-rgb':'245, 240, 251',
|
||||
'--mg-shadow-rgb': '124, 58, 237',
|
||||
|
||||
// 文字:深紫标题 #2d1b69, 中灰正文 #606266
|
||||
'--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': '96, 98, 102',
|
||||
|
||||
// 关闭装饰性 orb
|
||||
'--mg-orb-a-opacity': '0',
|
||||
'--mg-orb-b-opacity': '0',
|
||||
'--mg-orb-c-opacity': '0'
|
||||
'--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'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'industrial-purple',
|
||||
name: '星云紫',
|
||||
description: '科幻紫主色,辅以蓝紫渐变,磨砂玻璃 · 霓虹辉光',
|
||||
}),
|
||||
outpostPreset({
|
||||
id: 'outpost-purple',
|
||||
name: '紫色',
|
||||
description: '同壳层、更饱和紫品牌;侧栏深蓝紫',
|
||||
preview: '#7c3aed',
|
||||
previewAccent: '#c4b5fd',
|
||||
previewAccent: '#a855f7',
|
||||
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': '#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: '科幻紫主色 · 磨砂玻璃 · 霓虹辉光(兼容)',
|
||||
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-hover-rgb': '139, 92, 246',
|
||||
'--mg-primary-active': '#5b21b6',
|
||||
// accent:浅紫高光,边缘霓虹与点缀
|
||||
'--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-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-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'
|
||||
@@ -122,9 +233,10 @@ export const THEMES: ThemePreset[] = [
|
||||
{
|
||||
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)
|
||||
}
|
||||
|
||||
/** 把主题变量写入 :root;Outpost 壳写 data-shell="outpost" 供浅色 CSS 命中 */
|
||||
export function applyThemeVars(preset: ThemePreset): void {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(preset.vars)) {
|
||||
// preset.vars 在 outpostPreset 里已跑过 withMapEditorChrome;再跑一次以补全缺省 --me-*
|
||||
const vars = withMapEditorChrome({ ...preset.vars })
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (key === '--me-chrome-mode') continue
|
||||
root.style.setProperty(key, value)
|
||||
}
|
||||
root.setAttribute('data-theme', preset.id)
|
||||
if (preset.outpostShell) {
|
||||
root.setAttribute('data-shell', 'outpost')
|
||||
} else {
|
||||
root.removeAttribute('data-shell')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** 接受 0–1 或 0–100,输出 0–1。 */
|
||||
export function normalizeSocRatio(n: number): number {
|
||||
const ratio = n > 1 ? n / 100 : n
|
||||
return Math.max(0, Math.min(1, ratio))
|
||||
}
|
||||
|
||||
export function socToPercent(ratio: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(ratio <= 1 ? ratio * 100 : ratio)))
|
||||
}
|
||||
|
||||
export function extractSocFromKv(
|
||||
rows: Array<{ key?: string | null; value?: string | null }> | null | undefined
|
||||
): number | null {
|
||||
if (!rows?.length) return null
|
||||
const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量']
|
||||
for (const key of preferred) {
|
||||
const hit = rows.find((r) => r.key === key)
|
||||
const parsed = parseSocNumber(hit?.value)
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
for (const r of rows) {
|
||||
if (!r.key || !SOC_KEY_RE.test(r.key)) continue
|
||||
const parsed = parseSocNumber(r.value)
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractSocFromFieldMap(map: Record<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 @@
|
||||
/**
|
||||
* 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法:
|
||||
* 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。
|
||||
* 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。
|
||||
* 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function safeUrl(url: string): string | null {
|
||||
const u = url.trim()
|
||||
if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#')) return u
|
||||
return null
|
||||
}
|
||||
|
||||
/** 行内格式:行内代码先占位保护,再处理链接/粗体/斜体,最后还原代码。入参须已 HTML 转义。 */
|
||||
function inline(escaped: string): string {
|
||||
const codes: string[] = []
|
||||
let s = escaped.replace(/`([^`]+)`/g, (_m, c: string) => {
|
||||
codes.push(`<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/y、track 要 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+2:tabs 通用样式提到全局 `.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>
|
||||
|
||||
@@ -566,26 +566,37 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.simple-field-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
padding: 10px 12px;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.page-card {
|
||||
min-height: calc(100vh - 88px);
|
||||
border: 1px solid rgba(40, 33, 58, 0.10) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: none !important;
|
||||
background: #fffefd !important;
|
||||
}
|
||||
.page-card { min-height: calc(100vh - 88px); }
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.page-header h2 { margin: 0 0 2px; font-size: 15px; font-weight: 650; }
|
||||
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.header-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(40, 33, 58, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(246, 243, 251, 0.65);
|
||||
}
|
||||
.car-type-filter,
|
||||
.keyword-filter {
|
||||
@@ -636,7 +647,21 @@ onMounted(async () => {
|
||||
.bordered-select :deep(.el-select__placeholder) {
|
||||
line-height: 30px;
|
||||
}
|
||||
.field-tabs { margin-top: 4px; }
|
||||
.field-tabs { margin-top: 2px; }
|
||||
.field-tabs :deep(.el-tabs__item) {
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.field-tabs :deep(.el-tabs__item.is-active) { font-weight: 700; }
|
||||
.page-card :deep(.el-card__header) {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(40, 33, 58, 0.08);
|
||||
}
|
||||
.page-card :deep(.el-card__body) { padding: 12px 14px; }
|
||||
.page-card :deep(.el-table .cell) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.field-tabs :deep(.search-hit-row > td.el-table__cell) {
|
||||
background-color: #f5f3ff !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
<template>
|
||||
<div class="task-mgmt-page">
|
||||
<!-- 统计条 -->
|
||||
<header class="tm-stats">
|
||||
<div class="tm-stat">
|
||||
<span class="tm-stat-label">任务总数</span>
|
||||
<span class="tm-stat-value"><b>{{ rows.length }}</b></span>
|
||||
</div>
|
||||
<div class="tm-stat-sep" aria-hidden="true" />
|
||||
<div class="tm-stat">
|
||||
<span class="tm-stat-label">等待</span>
|
||||
<span class="tm-stat-value"><b>{{ counts.waiting }}</b></span>
|
||||
</div>
|
||||
<div class="tm-stat">
|
||||
<span class="tm-stat-label">执行中</span>
|
||||
<span class="tm-stat-value tm-ok"><b>{{ counts.running }}</b></span>
|
||||
</div>
|
||||
<div class="tm-stat">
|
||||
<span class="tm-stat-label">已完成</span>
|
||||
<span class="tm-stat-value"><b>{{ counts.done }}</b></span>
|
||||
</div>
|
||||
<div class="tm-stat">
|
||||
<span class="tm-stat-label">异常</span>
|
||||
<span class="tm-stat-value" :class="{ 'tm-danger': counts.error > 0 }"><b>{{ counts.error }}</b></span>
|
||||
</div>
|
||||
<div class="tm-stat">
|
||||
<span class="tm-stat-label">超时</span>
|
||||
<span class="tm-stat-value" :class="{ 'tm-warn': counts.overdue > 0 }"><b>{{ counts.overdue }}</b></span>
|
||||
</div>
|
||||
|
||||
<div class="tm-stats-actions">
|
||||
<span class="tm-live" :class="{ 'is-live': autoRefresh }" @click="autoRefresh = !autoRefresh">
|
||||
{{ autoRefresh ? '自动刷新' : '已暂停' }}
|
||||
</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建任务</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert
|
||||
v-if="!online"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="tm-offline"
|
||||
title="SimpleLite 未连接:以下为平台最近一次快照;取消/重发/新建等操作需 SimpleLite 在线,离线执行会提示失败。"
|
||||
:description="lastSyncAt ? `最近同步:${formatTime(lastSyncAt)}` : '暂无同步记录'"
|
||||
/>
|
||||
|
||||
<!-- 筛选条 -->
|
||||
<div class="tm-toolbar">
|
||||
<el-input
|
||||
v-model="search"
|
||||
clearable
|
||||
placeholder="搜索任务号 / 单号 / 站点 / 车辆 / 进程"
|
||||
class="tm-search"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
<el-select v-model="statusFilter" clearable placeholder="状态" class="tm-status">
|
||||
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="下发起"
|
||||
end-placeholder="下发止"
|
||||
class="tm-date"
|
||||
:shortcuts="dateShortcuts"
|
||||
unlink-panels
|
||||
/>
|
||||
<el-checkbox v-model="includeFinished">含已完成</el-checkbox>
|
||||
<el-checkbox v-model="includeAborted">含取消/异常</el-checkbox>
|
||||
<span class="tm-count">{{ filteredRows.length }} / {{ rows.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 快捷芯片 -->
|
||||
<div class="tm-chips" role="tablist">
|
||||
<button
|
||||
v-for="chip in statusChips"
|
||||
:key="chip.key"
|
||||
type="button"
|
||||
class="tm-chip"
|
||||
:class="{ 'is-active': quickStatus === chip.key }"
|
||||
@click="quickStatus = chip.key"
|
||||
>
|
||||
{{ chip.label }}<b>{{ chip.count }}</b>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 任务表 -->
|
||||
<div v-loading="loading" class="tm-table-wrap">
|
||||
<el-table
|
||||
:data="filteredRows"
|
||||
stripe
|
||||
height="100%"
|
||||
highlight-current-row
|
||||
:row-class-name="rowClassName"
|
||||
empty-text="暂无匹配任务"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<el-table-column label="任务号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="tm-idcell">
|
||||
<span class="tm-id">{{ row.id }}</span>
|
||||
<span v-if="row.taskId" class="tm-taskid">单号 {{ row.taskId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="取货点" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.srcLabel || `站点 ${row.srcSiteId}` }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="放货点" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.dstLabel || `站点 ${row.dstSiteId}` }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="车辆" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.carName || row.carId">{{ row.carName || `#${row.carId}` }}</span>
|
||||
<span v-else class="muted">未分配</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.statusCode)" effect="plain">
|
||||
{{ row.status || row.statusCode }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="76" align="center" sortable />
|
||||
<el-table-column label="下发时间" width="160" sortable :sort-method="(a, b) => sortByTime(a.createTime, b.createTime)">
|
||||
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.finishTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="卡住原因" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.stuckReason" class="tm-stuck">{{ row.stuckReason }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="tm-ops">
|
||||
<el-button link type="primary" size="small" @click.stop="openDetail(row)">详情</el-button>
|
||||
<el-button link type="warning" size="small" :disabled="!canCancel(row) || acting" @click.stop="runAction(row, 'cancel')">取消</el-button>
|
||||
<el-button link type="primary" size="small" :disabled="acting" @click.stop="runAction(row, 'resend')">重发</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => onMore(row, cmd)">
|
||||
<el-button link type="info" size="small" @click.stop>
|
||||
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="force-complete" :disabled="!canCancel(row)">强制完成</el-dropdown-item>
|
||||
<el-dropdown-item command="pause" :disabled="!canPause(row)">暂停</el-dropdown-item>
|
||||
<el-dropdown-item command="resume" :disabled="!canResume(row)">恢复</el-dropdown-item>
|
||||
<el-dropdown-item command="change-car" :disabled="!canChangeCar(row)">换车</el-dropdown-item>
|
||||
<el-dropdown-item command="priority" divided>改优先级</el-dropdown-item>
|
||||
<el-dropdown-item command="locate" :disabled="!row.carId">地图定位</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<p class="tm-footnote">
|
||||
数据来自 CDM 搬运任务投影(StandardScene TransportDelivery)。超时 = 下发超过 30 分钟仍未结束。
|
||||
</p>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
modal-class="tm-detail-drawer-modal"
|
||||
title="任务详情"
|
||||
size="420px"
|
||||
:with-header="true"
|
||||
>
|
||||
<div v-if="detailRow" class="tm-detail">
|
||||
<div v-for="item in detailItems" :key="item.k" class="tm-detail-row">
|
||||
<span class="tm-detail-k">{{ item.k }}</span>
|
||||
<span class="tm-detail-v" :class="item.cls">{{ item.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 新建任务弹窗 -->
|
||||
<el-dialog v-model="createVisible" title="新建搬运任务" width="460px" destroy-on-close append-to-body>
|
||||
<el-form label-width="80px" class="tm-create-form">
|
||||
<el-form-item label="取货点">
|
||||
<el-select v-model="createForm.src" filterable clearable placeholder="选择取货站点" class="tm-create-select">
|
||||
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="放货点">
|
||||
<el-select v-model="createForm.dst" filterable clearable placeholder="选择放货站点" class="tm-create-select">
|
||||
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number v-model="createForm.priority" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="车型">
|
||||
<el-input v-model="createForm.carType" placeholder="默认 Car" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部单号">
|
||||
<el-input v-model="createForm.taskId" placeholder="可选,外部系统单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" :disabled="!createForm.src && !createForm.dst" @click="submitCreate">
|
||||
下发
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowDown, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
cancelDelivery,
|
||||
changeCarDelivery,
|
||||
createDelivery,
|
||||
fetchCdmTaskFeed,
|
||||
forceCompleteDelivery,
|
||||
pauseDelivery,
|
||||
resendDelivery,
|
||||
resumeDelivery,
|
||||
setDeliveryPriority
|
||||
} from '@/api/delivery'
|
||||
import { listSites } from '@/api/projection'
|
||||
import type { DeliveryTask } from '@/types/delivery'
|
||||
import { dateShortcuts, formatTime, parseTime, sortByTime } from '@/utils/dateTime'
|
||||
|
||||
type QuickKey = 'all' | 'waiting' | 'running' | 'done' | 'aborted' | 'error'
|
||||
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
|
||||
type MoreCmd = 'force-complete' | 'pause' | 'resume' | 'change-car' | 'priority' | 'locate'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const rows = ref<DeliveryTask[]>([])
|
||||
const loading = ref(false)
|
||||
const acting = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const online = ref(true)
|
||||
const lastSyncAt = ref<string | null>(null)
|
||||
|
||||
const search = ref('')
|
||||
const statusFilter = ref<string | null>(null)
|
||||
const quickStatus = ref<QuickKey>('all')
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const includeFinished = ref(true)
|
||||
const includeAborted = ref(true)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<DeliveryTask | null>(null)
|
||||
|
||||
const createVisible = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive<{ src?: number; dst?: number; priority: number; carType: string; taskId: string }>({
|
||||
src: undefined,
|
||||
dst: undefined,
|
||||
priority: 0,
|
||||
carType: '',
|
||||
taskId: ''
|
||||
})
|
||||
const siteOptions = ref<{ value: number; label: string }[]>([])
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'Waiting', label: '等待 / 已下发' },
|
||||
{ value: 'Fetching', label: '取货中' },
|
||||
{ value: 'Putting', label: '放货中' },
|
||||
{ value: 'Finished', label: '已完成' },
|
||||
{ value: 'Canceled', label: '已取消' },
|
||||
{ value: 'Terminated', label: '已终止' },
|
||||
{ value: 'Error', label: '异常' },
|
||||
{ value: 'Suspended', label: '挂起' }
|
||||
]
|
||||
|
||||
function isTerminal(code: string) {
|
||||
return ['Finished', 'Canceled', 'Terminated', 'Error'].includes(code)
|
||||
}
|
||||
function isRunning(code: string) {
|
||||
return code === 'Fetching' || code === 'Putting'
|
||||
}
|
||||
function isAborted(code: string) {
|
||||
return code === 'Canceled' || code === 'Terminated'
|
||||
}
|
||||
|
||||
function matchesQuick(row: DeliveryTask, key: QuickKey): boolean {
|
||||
const c = row.statusCode
|
||||
switch (key) {
|
||||
case 'waiting': return c === 'Waiting' || c === 'Suspended'
|
||||
case 'running': return isRunning(c)
|
||||
case 'done': return c === 'Finished'
|
||||
case 'aborted': return isAborted(c)
|
||||
case 'error': return c === 'Error'
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
function inDateRange(row: DeliveryTask): boolean {
|
||||
if (!dateRange.value) return true
|
||||
const t = parseTime(row.createTime)
|
||||
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 filteredRows = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
|
||||
return rows.value.filter((row) => {
|
||||
if (!includeFinished.value && row.statusCode === 'Finished') return false
|
||||
if (!includeAborted.value && (isAborted(row.statusCode) || row.statusCode === 'Error')) return false
|
||||
if (statusFilter.value && row.statusCode !== statusFilter.value) return false
|
||||
if (!matchesQuick(row, quickStatus.value)) return false
|
||||
if (!inDateRange(row)) return false
|
||||
if (!tokens.length) return true
|
||||
const hay = [
|
||||
row.id, row.taskId ?? '', row.srcLabel, row.dstLabel, row.status, row.statusCode,
|
||||
row.carName ?? '', String(row.carId ?? ''), row.missionName, row.missionTypeName
|
||||
].join(' ').toLowerCase()
|
||||
return tokens.every((tok) => hay.includes(tok))
|
||||
})
|
||||
})
|
||||
|
||||
const counts = computed(() => ({
|
||||
waiting: rows.value.filter((r) => matchesQuick(r, 'waiting')).length,
|
||||
running: rows.value.filter((r) => matchesQuick(r, 'running')).length,
|
||||
done: rows.value.filter((r) => r.statusCode === 'Finished').length,
|
||||
error: rows.value.filter((r) => r.statusCode === 'Error').length,
|
||||
overdue: rows.value.filter((r) => r.overdue).length
|
||||
}))
|
||||
|
||||
const statusChips = computed(() => {
|
||||
const base = rows.value
|
||||
const count = (key: QuickKey) => base.filter((r) => matchesQuick(r, key)).length
|
||||
return [
|
||||
{ key: 'all' as const, label: '全部', count: base.length },
|
||||
{ key: 'waiting' as const, label: '等待', count: count('waiting') },
|
||||
{ key: 'running' as const, label: '执行中', count: count('running') },
|
||||
{ key: 'done' as const, label: '已完成', count: count('done') },
|
||||
{ key: 'aborted' as const, label: '取消/终止', count: count('aborted') },
|
||||
{ key: 'error' as const, label: '异常', count: count('error') }
|
||||
]
|
||||
})
|
||||
|
||||
function statusTagType(code: string): TagType {
|
||||
switch (code) {
|
||||
case 'Fetching':
|
||||
case 'Putting': return 'success'
|
||||
case 'Waiting':
|
||||
case 'Suspended': return 'primary'
|
||||
case 'Finished': return 'info'
|
||||
case 'Canceled':
|
||||
case 'Terminated': return 'warning'
|
||||
case 'Error': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
function rowClassName({ row }: { row: DeliveryTask }) {
|
||||
return row.overdue ? 'is-overdue-row' : ''
|
||||
}
|
||||
|
||||
function canCancel(row: DeliveryTask) { return !isTerminal(row.statusCode) }
|
||||
function canPause(row: DeliveryTask) { return isRunning(row.statusCode) }
|
||||
function canResume(row: DeliveryTask) { return row.statusCode === 'Terminated' || row.statusCode === 'Suspended' }
|
||||
function canChangeCar(row: DeliveryTask) {
|
||||
return row.statusCode === 'Suspended' || row.statusCode === 'Waiting'
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const feed = await fetchCdmTaskFeed(2000)
|
||||
rows.value = feed.tasks
|
||||
online.value = feed.online
|
||||
lastSyncAt.value = feed.lastSyncAt
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载任务失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
cancel: '取消任务',
|
||||
resend: '重发任务',
|
||||
'force-complete': '强制完成',
|
||||
pause: '暂停任务',
|
||||
resume: '恢复任务',
|
||||
'change-car': '换车'
|
||||
}
|
||||
|
||||
async function runAction(row: DeliveryTask, action: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定对任务 ${row.id}(${row.srcLabel} → ${row.dstLabel})执行「${actionLabels[action]}」?`,
|
||||
'确认操作',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
acting.value = true
|
||||
try {
|
||||
if (action === 'cancel') await cancelDelivery(row.id)
|
||||
else if (action === 'resend') await resendDelivery(row.id)
|
||||
else if (action === 'force-complete') await forceCompleteDelivery(row.id)
|
||||
else if (action === 'pause') await pauseDelivery(row.id)
|
||||
else if (action === 'resume') await resumeDelivery(row.id)
|
||||
else if (action === 'change-car') await changeCarDelivery(row.id)
|
||||
ElMessage.success(`${actionLabels[action]} 已提交`)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
ElMessage.error(`${actionLabels[action]} 失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onMore(row: DeliveryTask, cmd: string) {
|
||||
const c = cmd as MoreCmd
|
||||
if (c === 'locate') { locateOnMap(row); return }
|
||||
if (c === 'priority') { await editPriority(row); return }
|
||||
await runAction(row, c)
|
||||
}
|
||||
|
||||
async function editPriority(row: DeliveryTask) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('设置任务优先级(数值越大越优先)', '改优先级', {
|
||||
inputValue: String(row.priority ?? 0),
|
||||
inputPattern: /^-?\d+$/,
|
||||
inputErrorMessage: '请输入整数',
|
||||
confirmButtonText: '保存',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
acting.value = true
|
||||
await setDeliveryPriority(row.id, Number(value))
|
||||
ElMessage.success('优先级已更新')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') ElMessage.error(`更新失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function locateOnMap(row: DeliveryTask) {
|
||||
if (!row.carId) { ElMessage.info('该任务尚未分配车辆'); return }
|
||||
void router.push({ path: '/admin/map-monitor', query: { focusKind: 'car', focusId: String(row.carId) } })
|
||||
}
|
||||
|
||||
function openDetail(row: DeliveryTask) {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const detailItems = computed(() => {
|
||||
const r = detailRow.value
|
||||
if (!r) return [] as { k: string; v: string; cls?: string }[]
|
||||
return [
|
||||
{ k: '任务号', v: r.id },
|
||||
{ k: '外部单号', v: r.taskId || '—' },
|
||||
{ k: '状态', v: r.status || r.statusCode },
|
||||
{ k: '取货点', v: r.srcLabel || String(r.srcSiteId) },
|
||||
{ k: '放货点', v: r.dstLabel || String(r.dstSiteId) },
|
||||
{ k: '车辆', v: r.carName || (r.carId ? `#${r.carId}` : '未分配') },
|
||||
{ k: '优先级', v: String(r.priority ?? 0) },
|
||||
{ k: '进程', v: r.missionName || r.missionTypeName || '—' },
|
||||
{ k: '下发时间', v: formatTime(r.createTime) },
|
||||
{ k: '开始时间', v: formatTime(r.startTime) },
|
||||
{ k: '结束时间', v: formatTime(r.finishTime) },
|
||||
{ k: '卡住原因', v: r.stuckReason || '—', cls: r.stuckReason ? 'tm-danger-text' : undefined },
|
||||
{ k: '是否超时', v: r.overdue ? '是' : '否', cls: r.overdue ? 'tm-warn-text' : undefined }
|
||||
]
|
||||
})
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const sites = await listSites()
|
||||
siteOptions.value = sites
|
||||
.map((s) => {
|
||||
const num = parseInt(String(s.id).replace(/\D/g, ''), 10)
|
||||
return { value: num, label: `${num} · ${s.name || '未命名'}` }
|
||||
})
|
||||
.filter((o) => Number.isFinite(o.value))
|
||||
.sort((a, b) => a.value - b.value)
|
||||
} catch {
|
||||
siteOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.src = undefined
|
||||
createForm.dst = undefined
|
||||
createForm.priority = 0
|
||||
createForm.carType = ''
|
||||
createForm.taskId = ''
|
||||
if (!siteOptions.value.length) void loadSites()
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.src && !createForm.dst) {
|
||||
ElMessage.warning('取货点或放货点至少填一个')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await createDelivery({
|
||||
src: createForm.src ?? 0,
|
||||
dst: createForm.dst ?? 0,
|
||||
priority: createForm.priority || 0,
|
||||
carType: createForm.carType.trim() || undefined,
|
||||
taskId: createForm.taskId.trim() || undefined
|
||||
})
|
||||
ElMessage.success(`任务已下发${res.id ? `(${res.id})` : ''}`)
|
||||
createVisible.value = false
|
||||
await reload()
|
||||
} catch (err) {
|
||||
ElMessage.error(`下发失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void reload()
|
||||
void loadSites()
|
||||
pollTimer = setInterval(() => {
|
||||
if (autoRefresh.value && !createVisible.value && !acting.value) void reload()
|
||||
}, 6000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-mgmt-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: calc(100vh - 56px - 36px - 32px);
|
||||
min-height: 0;
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
|
||||
.tm-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;
|
||||
}
|
||||
.tm-stat { display: flex; flex-direction: column; gap: 3px; min-width: 56px; }
|
||||
.tm-stat-label { font-size: 11px; color: var(--mg-text-muted); white-space: nowrap; }
|
||||
.tm-stat-value {
|
||||
font-family: var(--mg-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.tm-stat-value.tm-ok b { color: var(--mg-status-success); }
|
||||
.tm-stat-value.tm-warn b { color: var(--mg-status-warning); }
|
||||
.tm-stat-value.tm-danger b { color: var(--mg-status-danger); }
|
||||
.tm-stat-sep { width: 1px; height: 30px; background: var(--mg-veil-border); flex: none; }
|
||||
|
||||
.tm-stats-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
|
||||
.tm-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;
|
||||
}
|
||||
.tm-live.is-live { color: var(--mg-status-success); border-color: rgba(var(--mg-status-success-rgb, 34,197,94), 0.4); }
|
||||
|
||||
.tm-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);
|
||||
}
|
||||
.tm-search { width: min(280px, 100%); }
|
||||
.tm-status { width: 130px; }
|
||||
.tm-date { width: 250px; }
|
||||
.tm-count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--mg-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tm-chips { flex: none; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tm-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;
|
||||
}
|
||||
.tm-chip b { font-family: var(--mg-font-mono); font-variant-numeric: tabular-nums; color: var(--mg-text-light); }
|
||||
.tm-chip:hover { color: var(--mg-text-light); background: var(--mg-veil-2); }
|
||||
.tm-chip.is-active {
|
||||
color: var(--mg-primary);
|
||||
border-color: rgba(var(--mg-primary-rgb), 0.45);
|
||||
background: rgba(var(--mg-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.tm-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;
|
||||
}
|
||||
.tm-table-wrap :deep(.is-overdue-row) { --el-table-tr-bg-color: rgba(245, 158, 11, 0.08); }
|
||||
.tm-table-wrap :deep(.el-table__row) { cursor: pointer; }
|
||||
|
||||
.tm-idcell { display: flex; flex-direction: column; line-height: 1.25; }
|
||||
.tm-id { font-family: var(--mg-font-mono); font-size: 12px; }
|
||||
.tm-taskid { font-size: 11px; color: var(--mg-text-muted); }
|
||||
.tm-stuck { color: var(--mg-status-danger); }
|
||||
.muted { color: var(--mg-text-muted); }
|
||||
|
||||
/* 操作列:flex 均匀间距(el-dropdown 会打断 el-button 相邻 margin,导致「重发」和「更多」贴住);
|
||||
本主题把 primary 的 link 按钮渲染成实心紫底+白字,需强制成透明底+彩色文字,四个按钮风格统一 */
|
||||
.tm-ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.tm-ops :deep(.el-button) { margin: 0; }
|
||||
.tm-ops :deep(.el-button.is-link) {
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
vertical-align: middle;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tm-ops :deep(.el-button--primary.is-link) { color: var(--mg-primary) !important; }
|
||||
.tm-ops :deep(.el-button--warning.is-link) { color: var(--mg-status-warning) !important; }
|
||||
.tm-ops :deep(.el-button--info.is-link) { color: var(--mg-text-muted) !important; }
|
||||
.tm-ops :deep(.el-button.is-link.is-disabled) { color: var(--mg-text-muted) !important; opacity: 0.45; }
|
||||
.tm-ops :deep(.el-button.is-link:not(.is-disabled):hover) { text-decoration: underline; }
|
||||
.tm-ops :deep(.el-dropdown) { line-height: 1; }
|
||||
|
||||
.tm-offline { flex: none; }
|
||||
.tm-footnote { flex: none; margin: 0; font-size: 12px; color: var(--mg-text-muted); }
|
||||
|
||||
.tm-detail { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tm-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--mg-veil-border);
|
||||
}
|
||||
.tm-detail-k { color: var(--mg-text-muted); font-size: 13px; }
|
||||
.tm-detail-v { font-size: 13px; word-break: break-all; }
|
||||
.tm-detail-v.tm-danger-text { color: var(--mg-status-danger); }
|
||||
.tm-detail-v.tm-warn-text { color: var(--mg-status-warning); }
|
||||
|
||||
.tm-create-form { padding-right: 8px; }
|
||||
.tm-create-select { width: 100%; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.tm-search, .tm-date { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user