Compare commits
9
Commits
bdcd88608c
...
beb6563060
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb6563060 | ||
|
|
8299c3dc5c | ||
|
|
b1dd4f9005 | ||
|
|
ef63dda228 | ||
|
|
95c1a74122 | ||
|
|
6986370e9d | ||
|
|
7056cf0872 | ||
|
|
4223a572c5 | ||
|
|
f4f4cd1d1b |
@@ -13,6 +13,8 @@ MiGu.Server/data/*.db
|
|||||||
MiGu.Server/data/*.db-shm
|
MiGu.Server/data/*.db-shm
|
||||||
MiGu.Server/data/*.db-wal
|
MiGu.Server/data/*.db-wal
|
||||||
MiGu.Server/data/.internal-token
|
MiGu.Server/data/.internal-token
|
||||||
|
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录,*.json 上面那行不递归)
|
||||||
|
MiGu.Server/data/ota/
|
||||||
|
|
||||||
# 前端构建产物
|
# 前端构建产物
|
||||||
MiGu.Server/wwwroot/
|
MiGu.Server/wwwroot/
|
||||||
@@ -32,3 +34,8 @@ Thumbs.db
|
|||||||
Desktop.ini
|
Desktop.ini
|
||||||
/.cursor/rules
|
/.cursor/rules
|
||||||
/MiGu.Server/wwwroot
|
/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:概览 ──
|
// ── 管理端 / Platform:概览 ──
|
||||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||||
|
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
||||||
|
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:设计与编排 ──
|
// ── 管理端 / Platform:设计与编排 ──
|
||||||
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
|
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]
|
[Authorize]
|
||||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
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>
|
/// <summary>
|
||||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||||
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ public class OpsController : ControllerBase
|
|||||||
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
|
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
|
||||||
if (!string.IsNullOrEmpty(_internalToken.Token))
|
if (!string.IsNullOrEmpty(_internalToken.Token))
|
||||||
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _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);
|
using var resp = await client.SendAsync(msg);
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
|
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 不可重复
|
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
|
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按 id 更新单条字段
|
/// 按 id 更新单条字段
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
|
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按 id 删除单条字段
|
/// 按 id 删除单条字段
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public async Task<IActionResult> Delete(Guid id)
|
public async Task<IActionResult> Delete(Guid id)
|
||||||
{
|
{
|
||||||
await _service.DeleteAsync(id);
|
await _service.DeleteAsync(id);
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase
|
|||||||
/// 返回实际写入条数 <c>{ count }</c>。
|
/// 返回实际写入条数 <c>{ count }</c>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("batch")]
|
[HttpPost("batch")]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
|
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
|
||||||
{
|
{
|
||||||
var count = await _service.SaveBatchAsync(req);
|
var count = await _service.SaveBatchAsync(req);
|
||||||
|
|
||||||
return Ok(new { count });
|
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>
|
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
||||||
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
|
||||||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
public int StopAll()
|
||||||
/// </summary>
|
|
||||||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
|
||||||
{
|
{
|
||||||
|
var killed = 0;
|
||||||
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
|
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -361,12 +360,13 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
if (!proc.HasExited)
|
if (!proc.HasExited)
|
||||||
{
|
{
|
||||||
proc.Kill(entireProcessTree: true);
|
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)
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -374,7 +374,7 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Thread.Sleep(1500);
|
if (killed > 0) Thread.Sleep(1500);
|
||||||
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
@@ -382,6 +382,23 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
_lastLaunchMode = null;
|
_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 synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||||||
var result = MaybeStart(launchMode);
|
var result = MaybeStart(launchMode);
|
||||||
if (!synced && result.Warning == null)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
|
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
|
||||||
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||||
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||||
|
public DbSet<MiGu.Server.Fleet.CdmTaskRecord> CdmTasks => Set<MiGu.Server.Fleet.CdmTaskRecord>();
|
||||||
|
public DbSet<MiGu.Server.Fleet.VehicleAlarmRecord> VehicleAlarms => Set<MiGu.Server.Fleet.VehicleAlarmRecord>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -85,6 +87,12 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
|
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
|
||||||
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
|
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
|
||||||
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
|
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
|
||||||
|
// 库位占用 1:1:同一 Storage LocationId 同时只能有一条未删除记录(Car 等其它类型不限)
|
||||||
|
modelBuilder.Entity<ContainerLocation>()
|
||||||
|
.HasIndex(x => x.LocationId)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("LocationType = 'Storage' AND IsDeleted = 0")
|
||||||
|
.HasDatabaseName("IX_wms_container_locations_StorageLocationId");
|
||||||
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
|
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
|
||||||
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
|
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
|
||||||
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.ContainerId);
|
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.ContainerId);
|
||||||
@@ -103,6 +111,61 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
|
|
||||||
ConfigureSimpleField(modelBuilder);
|
ConfigureSimpleField(modelBuilder);
|
||||||
ConfigureUserDashboardShortcut(modelBuilder);
|
ConfigureUserDashboardShortcut(modelBuilder);
|
||||||
|
ConfigureCdmTask(modelBuilder);
|
||||||
|
ConfigureVehicleAlarm(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureVehicleAlarm(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
var e = modelBuilder.Entity<MiGu.Server.Fleet.VehicleAlarmRecord>();
|
||||||
|
e.ToTable("vehicle_alarms");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36);
|
||||||
|
e.Property(x => x.CarId).HasColumnName("car_id");
|
||||||
|
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128);
|
||||||
|
e.Property(x => x.Info).HasColumnName("info").HasColumnType("text");
|
||||||
|
e.Property(x => x.Level).HasColumnName("level");
|
||||||
|
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16);
|
||||||
|
e.Property(x => x.FirstAt).HasColumnName("first_at");
|
||||||
|
e.Property(x => x.LastAt).HasColumnName("last_at");
|
||||||
|
e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false);
|
||||||
|
e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false);
|
||||||
|
e.Property(x => x.Acknowledged).HasColumnName("acknowledged");
|
||||||
|
e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false);
|
||||||
|
e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false);
|
||||||
|
e.HasIndex(x => new { x.CarId, x.Status });
|
||||||
|
e.HasIndex(x => x.Status);
|
||||||
|
e.HasIndex(x => x.FirstAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureCdmTask(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
var e = modelBuilder.Entity<MiGu.Server.Fleet.CdmTaskRecord>();
|
||||||
|
e.ToTable("cdm_tasks");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64);
|
||||||
|
e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false);
|
||||||
|
e.Property(x => x.MissionId).HasColumnName("mission_id");
|
||||||
|
e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128);
|
||||||
|
e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128);
|
||||||
|
e.Property(x => x.SrcSiteId).HasColumnName("src_site_id");
|
||||||
|
e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256);
|
||||||
|
e.Property(x => x.DstSiteId).HasColumnName("dst_site_id");
|
||||||
|
e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256);
|
||||||
|
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32);
|
||||||
|
e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32);
|
||||||
|
e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false);
|
||||||
|
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false);
|
||||||
|
e.Property(x => x.Priority).HasColumnName("priority");
|
||||||
|
e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false);
|
||||||
|
e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false);
|
||||||
|
e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false);
|
||||||
|
e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false);
|
||||||
|
e.Property(x => x.Overdue).HasColumnName("overdue");
|
||||||
|
e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at");
|
||||||
|
e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at");
|
||||||
|
e.HasIndex(x => x.StatusCode);
|
||||||
|
e.HasIndex(x => x.CreateTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
|
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ public static class PlatformPersistence
|
|||||||
await EnsureUserDashboardShortcutsTableAsync(db);
|
await EnsureUserDashboardShortcutsTableAsync(db);
|
||||||
await EnsureWmsTransportSchemaAsync(db);
|
await EnsureWmsTransportSchemaAsync(db);
|
||||||
await EnsureWmsStructureSchemaAsync(db);
|
await EnsureWmsStructureSchemaAsync(db);
|
||||||
|
await EnsureCdmTasksTableAsync(db);
|
||||||
|
await EnsureVehicleAlarmsTableAsync(db);
|
||||||
await MigrateWmsLegacyAsync(scope.ServiceProvider);
|
await MigrateWmsLegacyAsync(scope.ServiceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +187,83 @@ public static class PlatformPersistence
|
|||||||
await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''");
|
await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。</summary>
|
||||||
|
private static async Task EnsureVehicleAlarmsTableAsync(PlatformDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Database.IsSqlite())
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicle_alarms (
|
||||||
|
id TEXT NOT NULL CONSTRAINT PK_vehicle_alarms PRIMARY KEY,
|
||||||
|
car_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
car_name TEXT NOT NULL DEFAULT '',
|
||||||
|
info TEXT NOT NULL DEFAULT '',
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
first_at TEXT NOT NULL,
|
||||||
|
last_at TEXT NOT NULL,
|
||||||
|
resolved_at TEXT,
|
||||||
|
duration_secs INTEGER,
|
||||||
|
acknowledged INTEGER NOT NULL DEFAULT 0,
|
||||||
|
acknowledged_at TEXT,
|
||||||
|
acknowledged_by TEXT
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_car_status ON vehicle_alarms (car_id, status);");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_status ON vehicle_alarms (status);");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_first_at ON vehicle_alarms (first_at);");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await TableExistsAsync(db, "vehicle_alarms"))
|
||||||
|
{
|
||||||
|
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||||
|
await creator.CreateTablesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。</summary>
|
||||||
|
private static async Task EnsureCdmTasksTableAsync(PlatformDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Database.IsSqlite())
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cdm_tasks (
|
||||||
|
id TEXT NOT NULL CONSTRAINT PK_cdm_tasks PRIMARY KEY,
|
||||||
|
task_id TEXT,
|
||||||
|
mission_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mission_name TEXT NOT NULL DEFAULT '',
|
||||||
|
mission_type TEXT NOT NULL DEFAULT '',
|
||||||
|
src_site_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
src_label TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_site_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_label TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
status_code TEXT NOT NULL DEFAULT '',
|
||||||
|
car_id INTEGER,
|
||||||
|
car_name TEXT,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
create_time TEXT,
|
||||||
|
start_time TEXT,
|
||||||
|
finish_time TEXT,
|
||||||
|
stuck_reason TEXT,
|
||||||
|
overdue INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_status_code ON cdm_tasks (status_code);");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_create_time ON cdm_tasks (create_time);");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await TableExistsAsync(db, "cdm_tasks"))
|
||||||
|
{
|
||||||
|
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||||
|
await creator.CreateTablesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
||||||
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
|
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
|
||||||
{
|
{
|
||||||
@@ -339,10 +418,33 @@ public static class PlatformPersistence
|
|||||||
SnapshotJson TEXT NOT NULL
|
SnapshotJson TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
// 库位占用唯一约束(幂等);若库内已有重复占用会创建失败,不阻断启动
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_container_locations_StorageLocationId
|
||||||
|
ON wms_container_locations (LocationId)
|
||||||
|
WHERE LocationType = 'Storage' AND IsDeleted = 0;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[WMS] 无法创建库位占用唯一索引 IX_wms_container_locations_StorageLocationId(可能已有重复占用): {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
|
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
|
||||||
{
|
{
|
||||||
|
// 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白。
|
||||||
|
static bool IsSafeIdent(string s) =>
|
||||||
|
!string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_');
|
||||||
|
if (!IsSafeIdent(table) || !IsSafeIdent(column)
|
||||||
|
|| definition.Contains(';') || definition.Contains("--")
|
||||||
|
|| !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$"))
|
||||||
|
throw new ArgumentException("unsafe sqlite migration identifier");
|
||||||
|
|
||||||
var conn = db.Database.GetDbConnection();
|
var conn = db.Database.GetDbConnection();
|
||||||
if (conn.State != System.Data.ConnectionState.Open)
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
await conn.OpenAsync();
|
await conn.OpenAsync();
|
||||||
|
|||||||
+59
-5
@@ -7,6 +7,7 @@ using MiGu.Server.Auth;
|
|||||||
using MiGu.Server.Configs;
|
using MiGu.Server.Configs;
|
||||||
using MiGu.Server.Launcher;
|
using MiGu.Server.Launcher;
|
||||||
using MiGu.Server.OpenApi;
|
using MiGu.Server.OpenApi;
|
||||||
|
using MiGu.Server.Ota;
|
||||||
using MiGu.Server.Persistence;
|
using MiGu.Server.Persistence;
|
||||||
using Yarp.ReverseProxy.Transforms;
|
using Yarp.ReverseProxy.Transforms;
|
||||||
|
|
||||||
@@ -31,12 +32,27 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
|||||||
ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory
|
ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory
|
||||||
});
|
});
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(builder.Configuration["urls"])
|
// 管理面默认 :8080;WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。
|
||||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))
|
|
||||||
&& string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS")))
|
|
||||||
{
|
{
|
||||||
// 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。
|
var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000);
|
||||||
builder.WebHost.UseUrls("http://0.0.0.0:8080");
|
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/
|
// 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.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||||
opt.JsonSerializerOptions.WriteIndented = false;
|
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.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
@@ -207,6 +228,25 @@ builder.Services.AddReverseProxy()
|
|||||||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||||
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
||||||
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.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;
|
return ValueTask.CompletedTask;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -224,6 +264,20 @@ builder.Services.AddPlatformPersistence(builder.Configuration);
|
|||||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
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();
|
var app = builder.Build();
|
||||||
await app.Services.EnsurePlatformDatabaseAsync();
|
await app.Services.EnsurePlatformDatabaseAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://0.0.0.0:8080",
|
"applicationUrl": "http://0.0.0.0:8080;http://0.0.0.0:8000",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -499,7 +499,14 @@ public sealed class WmsService
|
|||||||
});
|
});
|
||||||
|
|
||||||
await AddContainerMoveEventAsync(req.ContainerId, fromStorageId, toStorageId, actor, req.Reason.TrimOr(""), now);
|
await AddContainerMoveEventAsync(req.ContainerId, fromStorageId, toStorageId, actor, req.Reason.TrimOr(""), now);
|
||||||
|
try
|
||||||
|
{
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("目标库位已被其他容器占用,库位与容器为 1 对 1");
|
||||||
|
}
|
||||||
await SyncOccupancyStatus(containerId: req.ContainerId, storageId: fromStorageId);
|
await SyncOccupancyStatus(containerId: req.ContainerId, storageId: fromStorageId);
|
||||||
await SyncOccupancyStatus(storageId: toStorageId);
|
await SyncOccupancyStatus(storageId: toStorageId);
|
||||||
return current;
|
return current;
|
||||||
@@ -857,6 +864,20 @@ public sealed class WmsService
|
|||||||
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
|
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
||||||
|
{
|
||||||
|
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||||
|
{
|
||||||
|
var msg = e.Message;
|
||||||
|
if (msg.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("unique index", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| msg.Contains("duplicate key", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureUnlocked(EntityBase entity)
|
private static void EnsureUnlocked(EntityBase entity)
|
||||||
{
|
{
|
||||||
if (entity.IsLock) throw new InvalidOperationException("数据已锁定,不能修改");
|
if (entity.IsLock) throw new InvalidOperationException("数据已锁定,不能修改");
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ public sealed class WmsTransportPlanner
|
|||||||
.Where(x => x.LocationType == ContainerLocationTypes.Storage).ToListAsync();
|
.Where(x => x.LocationType == ContainerLocationTypes.Storage).ToListAsync();
|
||||||
var materials = await _db.Materials.AsNoTracking().Where(x => x.Enabled).ToListAsync();
|
var materials = await _db.Materials.AsNoTracking().Where(x => x.Enabled).ToListAsync();
|
||||||
var containerMaterials = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
|
var containerMaterials = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
var activeReservations = await _db.WmsTransportReservations.AsNoTracking()
|
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()
|
var activeTasks = await _db.WmsTransportTasks.AsNoTracking()
|
||||||
.Where(x => WmsTransportTaskStatuses.Active.Contains(x.Status)).ToListAsync();
|
.Where(x => WmsTransportTaskStatuses.Active.Contains(x.Status)).ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -165,10 +165,27 @@ public sealed class WmsTransportTaskService
|
|||||||
|
|
||||||
public async Task<WmsTransportTask> DispatchAsync(Guid taskId, string actor)
|
public async Task<WmsTransportTask> DispatchAsync(Guid taskId, string actor)
|
||||||
{
|
{
|
||||||
var task = await FindTaskAsync(taskId);
|
// 原子抢占:仅一条 Reserved 且未在下发中的任务能进入 Dispatching,避免双发
|
||||||
if (task.Status != WmsTransportTaskStatuses.Reserved)
|
var now = DateTimeOffset.UtcNow;
|
||||||
throw new InvalidOperationException("只有 Reserved 状态的任务可以下发");
|
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 source = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.SourceStorageId);
|
||||||
var target = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.TargetStorageId);
|
var target = await _db.Storages.AsNoTracking().FirstAsync(x => x.Id == task.TargetStorageId);
|
||||||
var container = await _db.Containers.AsNoTracking().FirstAsync(x => x.Id == task.ContainerId);
|
var container = await _db.Containers.AsNoTracking().FirstAsync(x => x.Id == task.ContainerId);
|
||||||
@@ -194,14 +211,36 @@ public sealed class WmsTransportTaskService
|
|||||||
return task;
|
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)
|
private async Task EnsureNoActiveReservationAsync(Guid containerId, Guid targetStorageId)
|
||||||
{
|
{
|
||||||
|
await ExpireStaleReservationsAsync();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
if (await _db.WmsTransportReservations.AnyAsync(x =>
|
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("容器已被其他任务预占");
|
throw new InvalidOperationException("容器已被其他任务预占");
|
||||||
|
|
||||||
if (await _db.WmsTransportReservations.AnyAsync(x =>
|
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("目标库位已被其他任务预占");
|
throw new InvalidOperationException("目标库位已被其他任务预占");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@
|
|||||||
"Dispatch": {
|
"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,防止运营账号经反代直达地图编辑与任意反射调用。",
|
"_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。",
|
||||||
"ReverseProxy": {
|
"ReverseProxy": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
@@ -65,6 +74,15 @@
|
|||||||
{ "PathRemovePrefix": "/api/sl" }
|
{ "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": {
|
"sl-reflection-selection-route": {
|
||||||
"ClusterId": "sl-cluster",
|
"ClusterId": "sl-cluster",
|
||||||
"AuthorizationPolicy": "AnyAuthed",
|
"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_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。
|
||||||
# - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。
|
# - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。
|
||||||
VITE_API_BASE=/api
|
VITE_API_BASE=/api
|
||||||
VITE_VRENDER_HOST=localhost:8223
|
# 留空:走 defaultVrHost() → window.location.hostname:8223。
|
||||||
|
# 切勿写死 localhost,远程浏览器会去连访问者本机而非服务器。
|
||||||
|
# VITE_VRENDER_HOST=
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
|
|||||||
+7
-19
@@ -7,8 +7,10 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AiAssistantDrawer: typeof import('./src/components/assistant/AiAssistantDrawer.vue')['default']
|
||||||
AiAssistantPanel: typeof import('./src/components/map-editor/AiAssistantPanel.vue')['default']
|
AiAssistantPanel: typeof import('./src/components/map-editor/AiAssistantPanel.vue')['default']
|
||||||
AiGenerateDialog: typeof import('./src/components/map-editor/AiGenerateDialog.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']
|
ConfigPageBase: typeof import('./src/components/ConfigPageBase.vue')['default']
|
||||||
DataTablePro: typeof import('./src/components/DataTablePro.vue')['default']
|
DataTablePro: typeof import('./src/components/DataTablePro.vue')['default']
|
||||||
EdgeInspector: typeof import('./src/components/workflow/EdgeInspector.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']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
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']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
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']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
|
||||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
@@ -53,15 +46,10 @@ declare module 'vue' {
|
|||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
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']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
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']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
@@ -69,14 +57,14 @@ declare module 'vue' {
|
|||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
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']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
FieldMemberEditor: typeof import('./src/components/orchestration/FieldMemberEditor.vue')['default']
|
FieldMemberEditor: typeof import('./src/components/orchestration/FieldMemberEditor.vue')['default']
|
||||||
FleetAllocationPanel: typeof import('./src/components/fleet/FleetAllocationPanel.vue')['default']
|
FleetAllocationPanel: typeof import('./src/components/fleet/FleetAllocationPanel.vue')['default']
|
||||||
FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default']
|
FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default']
|
||||||
FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.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']
|
MapConnectionPanel: typeof import('./src/components/map-manage/MapConnectionPanel.vue')['default']
|
||||||
MapMergePanel: typeof import('./src/components/map-manage/MapMergePanel.vue')['default']
|
MapMergePanel: typeof import('./src/components/map-manage/MapMergePanel.vue')['default']
|
||||||
MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.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']
|
PermissionGuard: typeof import('./src/components/PermissionGuard.vue')['default']
|
||||||
PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default']
|
PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default']
|
||||||
ProjectBrowseDialog: typeof import('./src/components/map-editor/ProjectBrowseDialog.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']
|
ReflectionKindTable: typeof import('./src/components/orchestration/ReflectionKindTable.vue')['default']
|
||||||
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
|
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
@@ -97,6 +86,8 @@ declare module 'vue' {
|
|||||||
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
|
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
|
||||||
ThemeSwitcher: typeof import('./src/components/ThemeSwitcher.vue')['default']
|
ThemeSwitcher: typeof import('./src/components/ThemeSwitcher.vue')['default']
|
||||||
VehicleHealthCard: typeof import('./src/components/fleet/VehicleHealthCard.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']
|
VehicleMonitorPanel: typeof import('./src/components/workbench/VehicleMonitorPanel.vue')['default']
|
||||||
WorkbenchSidePanel: typeof import('./src/components/workbench/WorkbenchSidePanel.vue')['default']
|
WorkbenchSidePanel: typeof import('./src/components/workbench/WorkbenchSidePanel.vue')['default']
|
||||||
WorkflowEditor: typeof import('./src/components/workflow/WorkflowEditor.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']
|
Workspace3D: typeof import('./src/components/Workspace3D.vue')['default']
|
||||||
WorkspaceCanvasToolbar: typeof import('./src/components/workspace/WorkspaceCanvasToolbar.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>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-shell="outpost" data-theme="outpost-light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>迷毂 · 智能调度平台</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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 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'
|
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||||
|
|
||||||
@@ -20,17 +20,72 @@ export async function listDeliveries(opts?: {
|
|||||||
return Array.isArray(data) ? data : []
|
return Array.isArray(data) ? data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelDelivery(id: number): Promise<void> {
|
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */
|
||||||
if (MOCK) return
|
export interface CdmTaskFeed {
|
||||||
await http.post(`${BASE}/${id}/cancel`)
|
online: boolean
|
||||||
|
lastSyncAt: string | null
|
||||||
|
tasks: DeliveryTask[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resendDelivery(id: number): Promise<void> {
|
/**
|
||||||
if (MOCK) return
|
* 任务页数据源:优先读平台快照库 /fleet/tasks(离线可读 + 历史保留);
|
||||||
await http.post(`${BASE}/${id}/resend`)
|
* 若平台端点不可用则回退到实时投影 /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
|
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))
|
await new Promise((r) => setTimeout(r, 120))
|
||||||
return mockFleetHealth()
|
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')
|
const { data } = await http.get<FleetHealthRow[]>('/sl/projection/fleet/health')
|
||||||
return Array.isArray(data) ? data : []
|
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
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SelectTrackMode = 'all' | 'straight' | 'curve'
|
||||||
|
|
||||||
export interface ViewFilterSnapshot {
|
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 }
|
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
|
||||||
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
|
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
|
||||||
}
|
}
|
||||||
@@ -195,11 +204,68 @@ export const mapEditApi = {
|
|||||||
batch: (ops: BatchOp[]) =>
|
batch: (ops: BatchOp[]) =>
|
||||||
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
|
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 }>) =>
|
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 })),
|
unwrap<{ copied: number }>(http.post(`${BASE}/objects/${kind}/${id}/fields/copy-to`, { fieldNames, targets })),
|
||||||
|
|
||||||
// 拾取 / 仪表盘
|
/** 场景已有自定义字段键(Ctrl+I /「复制字段」弹窗候选)。 */
|
||||||
pick: () => unwrap<PickResult>(http.post(`${BASE}/pick`)),
|
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: () =>
|
dashboardSummary: () =>
|
||||||
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
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 { Car, CarState } from '@/types/car'
|
||||||
import type { Mission, MissionStatus } from '@/types/mission'
|
import type { Mission, MissionStatus } from '@/types/mission'
|
||||||
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
|
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
|
||||||
|
import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime'
|
||||||
|
|
||||||
/** 设为 true 时使用本地 Mock;默认走 YARP → SimpleLite :8222。
|
/** 设为 true 时使用本地 Mock;默认走 YARP → SimpleLite :8222。
|
||||||
* 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */
|
* 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */
|
||||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
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[]> {
|
export async function listSites(): Promise<Site[]> {
|
||||||
if (MOCK) return mockSites()
|
if (MOCK) return mockSites()
|
||||||
const { data } = await http.get<Site[]>('/sl/projection/sites')
|
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 {
|
function mapStatusToCarState(status?: string | null): CarState {
|
||||||
if (!status) return 'idle'
|
return deriveCarState({ state: 'idle', lstatus: status ?? undefined }, null)
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatusToMissionStatus(status?: string | null): MissionStatus {
|
function mapStatusToMissionStatus(status?: string | null): MissionStatus {
|
||||||
@@ -53,10 +51,11 @@ function carFromReflection(row: ReflectionObject): Car {
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
theta: 0,
|
theta: 0,
|
||||||
batterySoc: 0.8,
|
batterySoc: 0,
|
||||||
state: mapStatusToCarState(row.status),
|
state: mapStatusToCarState(row.status),
|
||||||
lastUpdate: new Date().toISOString(),
|
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)
|
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 场景同源)。 */
|
/** 优先走 SimpleLite /projection/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
|
||||||
export async function listCars(): Promise<Car[]> {
|
export async function listCars(): Promise<Car[]> {
|
||||||
if (MOCK) return mockCars()
|
if (MOCK) return mockCars()
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get<Car[]>('/sl/projection/cars')
|
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()
|
const fallback = await listCarsFromReflection()
|
||||||
if (fallback.length > 0) return fallback
|
if (fallback.length > 0) return enrichCarsRuntime(fallback)
|
||||||
return Array.isArray(data) ? data : []
|
return Array.isArray(data) ? enrichCarsRuntime(data) : []
|
||||||
} catch {
|
} catch {
|
||||||
return listCarsFromReflection()
|
return enrichCarsRuntime(await listCarsFromReflection())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import http from './http'
|
import http from './http'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
mockReflectionAssemblies,
|
mockReflectionAssemblies,
|
||||||
mockReflectionBundle,
|
mockReflectionBundle,
|
||||||
@@ -64,6 +65,8 @@ export interface ReflectionMethod {
|
|||||||
returnType: string
|
returnType: string
|
||||||
hasParams: boolean
|
hasParams: boolean
|
||||||
params: ReflectionParam[]
|
params: ReflectionParam[]
|
||||||
|
requiresPlatformConfirm?: boolean
|
||||||
|
confirmMessage?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflectionObject {
|
export interface ReflectionObject {
|
||||||
@@ -225,30 +228,94 @@ export function emptyMonitorVisibilityMap(): MonitorVisibilityMap {
|
|||||||
// 单独保留命名是为了表达"业务含义不同"——可勾选全集 vs 已勾选白名单。
|
// 单独保留命名是为了表达"业务含义不同"——可勾选全集 vs 已勾选白名单。
|
||||||
export const emptyMonitorAvailableMap = emptyMonitorVisibilityMap
|
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> {
|
async function get<T>(path: string): Promise<T> {
|
||||||
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
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
|
return data.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post<T>(path: string, params?: Record<string, string | number | boolean>): Promise<T> {
|
async function post<T>(
|
||||||
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params })
|
path: string,
|
||||||
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
|
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
|
return data.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del<T>(path: string): Promise<T> {
|
async function del<T>(path: string): Promise<T> {
|
||||||
const { data } = await http.delete<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
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
|
return data.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchJson<T>(path: string, body: unknown): Promise<T> {
|
async function patchJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
const { data } = await http.patch<ReflectionEnvelope<T>>(`${BASE}${path}`, body)
|
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
|
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 = {
|
export const reflectionApi = {
|
||||||
listKinds: () => MOCK
|
listKinds: () => MOCK
|
||||||
? Promise.resolve(mockReflectionKinds())
|
? Promise.resolve(mockReflectionKinds())
|
||||||
@@ -371,16 +438,23 @@ export const reflectionApi = {
|
|||||||
fieldList?: ReflectionKv[]
|
fieldList?: ReflectionKv[]
|
||||||
}>(`/bundle/${kind}/${id}`),
|
}>(`/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(
|
? Promise.resolve(mockReflectionExecute(
|
||||||
kind,
|
kind,
|
||||||
id,
|
id,
|
||||||
method,
|
method,
|
||||||
Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)]))
|
Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)]))
|
||||||
))
|
))
|
||||||
: post<{ returnValue: string }>(
|
: executeWithPlatformConfirm<ReflectionExecuteResult>(
|
||||||
`/execute/${kind}/${id}/${encodeURIComponent(method)}`,
|
`/execute/${kind}/${id}/${encodeURIComponent(method)}`,
|
||||||
params
|
params,
|
||||||
|
opts
|
||||||
),
|
),
|
||||||
|
|
||||||
/** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */
|
/** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */
|
||||||
|
|||||||
@@ -34,10 +34,15 @@ export interface ToolbarRecordingState {
|
|||||||
isRecording: boolean
|
isRecording: boolean
|
||||||
mode: string
|
mode: string
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
|
isAutoPlaying?: boolean
|
||||||
|
speed?: number
|
||||||
elapsedMs: number
|
elapsedMs: number
|
||||||
|
durationMs?: number
|
||||||
frameCount: number
|
frameCount: number
|
||||||
|
currentFrameIndex?: number
|
||||||
droppedFrames: number
|
droppedFrames: number
|
||||||
currentRecordingFile: string | null
|
currentRecordingFile: string | null
|
||||||
|
currentPlaybackFile?: string | null
|
||||||
recordingsDirectory: string
|
recordingsDirectory: string
|
||||||
entries: ToolbarRecordingEntry[]
|
entries: ToolbarRecordingEntry[]
|
||||||
}
|
}
|
||||||
@@ -122,6 +127,20 @@ export const workspaceToolbarApi = {
|
|||||||
|
|
||||||
stopPlayback: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/stop`)),
|
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`)),
|
toggleViewMode: () => unwrap<ToolbarState>(http.post(`${BASE}/view/toggle`)),
|
||||||
|
|
||||||
setCameraFollow: (carId: number | null, enabled: boolean) =>
|
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 {
|
.workspace-3d-mask {
|
||||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
align-items: center; justify-content: center; gap: 10px;
|
align-items: center; justify-content: center; gap: 10px;
|
||||||
|
/* 画布加载光晕固定深紫(不跟浅色壳白底 token,避免被冲成灰白) */
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at center, rgba(var(--mg-primary-rgb), 0.35) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.92) 80%),
|
radial-gradient(
|
||||||
rgba(var(--mg-bg-app-deep-rgb), 0.85);
|
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);
|
color: rgba(232, 220, 255, 0.92);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.workspace-3d-mask .muted { color: rgba(var(--mg-accent-rgb), 0.7); font-size: 12px; }
|
.workspace-3d-mask .muted { color: rgba(196, 181, 253, 0.78); 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 .el-icon {
|
||||||
|
color: #c4b5fd;
|
||||||
|
filter: drop-shadow(0 0 12px rgba(155, 124, 255, 0.65));
|
||||||
|
}
|
||||||
</style>
|
</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}`"
|
:class="`aap-msg--${m.role}`"
|
||||||
>
|
>
|
||||||
<div class="aap-bubble">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,10 +101,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
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 { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
|
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
|
||||||
|
import { renderMarkdown } from '@/utils/miniMarkdown'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 面板是否展开(停靠在右侧)。 */
|
/** 面板是否展开(停靠在右侧)。 */
|
||||||
@@ -198,6 +201,10 @@ function useExample(ex: string) {
|
|||||||
draft.value = ex
|
draft.value = ex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMd(s: string): string {
|
||||||
|
return renderMarkdown(s)
|
||||||
|
}
|
||||||
|
|
||||||
function onKeydown(e: Event | KeyboardEvent) {
|
function onKeydown(e: Event | KeyboardEvent) {
|
||||||
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
|
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
|
||||||
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
|
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
|
||||||
@@ -387,6 +394,48 @@ async function send() {
|
|||||||
color: rgba(210, 188, 240, 0.8);
|
color: rgba(210, 188, 240, 0.8);
|
||||||
}
|
}
|
||||||
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
|
.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 {
|
.aap-bubble--loading {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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">
|
<div v-else class="object-pane">
|
||||||
<!-- Header card:ID / 类型 / 名称 / 图层 / 删除按钮 -->
|
<!-- Header card:ID / 类型 / 名称 / 图层 / 删除按钮 -->
|
||||||
<section class="prop-card prop-card--header">
|
<section class="prop-card prop-card--header">
|
||||||
<div class="prop-card-row">
|
<header class="prop-card-header">
|
||||||
<div class="prop-id-block">
|
<span class="prop-card-title">基础属性</span>
|
||||||
<span class="prop-id-tag">{{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }}</span>
|
|
||||||
<span class="prop-type-tag">{{ primary?.typeName ?? '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="prop-header-actions">
|
<div class="prop-header-actions">
|
||||||
<el-tag
|
<el-tag
|
||||||
v-if="primary?.status"
|
v-if="primary?.status"
|
||||||
@@ -42,11 +39,18 @@
|
|||||||
:disabled="deleting"
|
:disabled="deleting"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
>
|
||||||
<span class="prop-delete-icon">{{ deleting ? '⟳' : '🗑' }}</span>
|
<span class="prop-delete-icon">{{ deleting ? '⟳' : '×' }}</span>
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</button>
|
</button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="prop-card-row prop-name-row">
|
<div class="prop-card-row prop-name-row">
|
||||||
@@ -863,17 +867,17 @@ function onDelete() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Style 2 分组卡片 + Style 5 主题色相(--me-* / --mg-primary) */
|
||||||
.edit-property-panel {
|
.edit-property-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 360px;
|
width: 360px;
|
||||||
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
|
background: linear-gradient(180deg, var(--me-chrome-1) 0%, var(--me-chrome-2) 100%);
|
||||||
border-left: 1px solid rgba(255,255,255,0.10);
|
border-left: 1px solid var(--me-border);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: rgba(232,215,245,0.92);
|
color: var(--me-text);
|
||||||
}
|
}
|
||||||
.prop-tabs {
|
.prop-tabs {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -882,146 +886,205 @@ function onDelete() {
|
|||||||
}
|
}
|
||||||
.prop-tabs :deep(.el-tabs__header) {
|
.prop-tabs :deep(.el-tabs__header) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 8px;
|
padding: 6px 10px 0;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
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__nav-wrap)::after { background: transparent; }
|
||||||
.prop-tabs :deep(.el-tabs__item) {
|
.prop-tabs :deep(.el-tabs__item) {
|
||||||
color: rgba(220, 200, 240, 0.7);
|
color: var(--me-text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
height: 36px;
|
height: 34px;
|
||||||
line-height: 36px;
|
line-height: 34px;
|
||||||
|
padding: 0 14px !important;
|
||||||
}
|
}
|
||||||
.prop-tabs :deep(.el-tabs__item.is-active) {
|
.prop-tabs :deep(.el-tabs__item.is-active) {
|
||||||
color: #fff;
|
color: var(--mg-primary);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.prop-tabs :deep(.el-tabs__active-bar) {
|
.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;
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.prop-tabs :deep(.el-tabs__content) {
|
.prop-tabs :deep(.el-tabs__content) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 10px 12px 14px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-hint {
|
.empty-hint {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex;
|
||||||
padding: 56px 16px; text-align: center;
|
align-items: center;
|
||||||
color: rgba(220, 200, 240, 0.55);
|
gap: 8px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--me-text-muted);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.empty-icon { font-size: 28px; color: rgba(180, 130, 220, 0.45); }
|
.empty-icon { font-size: 28px; color: rgba(var(--mg-primary-rgb), 0.45); }
|
||||||
.empty-title { font-size: 13px; color: rgba(232, 215, 245, 0.75); font-weight: 500; }
|
.empty-title { font-size: 13px; color: var(--me-text); font-weight: 600; }
|
||||||
.empty-desc { font-size: 11.5px; color: rgba(220, 200, 240, 0.5); }
|
.empty-desc { font-size: 11.5px; color: var(--me-text-muted); max-width: 220px; line-height: 1.45; }
|
||||||
.loading-hint {
|
.loading-hint {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex;
|
||||||
padding: 32px 16px; text-align: center;
|
align-items: center;
|
||||||
color: rgba(232,215,245,0.65); font-size: 12.5px;
|
gap: 8px;
|
||||||
|
padding: 32px 16px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--me-text-muted);
|
||||||
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-pane {
|
.object-pane,
|
||||||
|
.defaults-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分组卡片:跟 --me-card-*(Harbor 浅壳 / 紫蓝深壳共用) */
|
||||||
.prop-card {
|
.prop-card {
|
||||||
background: rgba(255,255,255,0.04);
|
position: relative;
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
background: var(--me-card-bg);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--me-card-border);
|
||||||
padding: 10px 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.20);
|
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 {
|
.prop-card--header {
|
||||||
background: linear-gradient(140deg, rgba(170, 110, 250, 0.18) 0%, rgba(70, 30, 110, 0.10) 100%);
|
background: linear-gradient(
|
||||||
border-color: rgba(170, 110, 250, 0.28);
|
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 {
|
.prop-card--header::before {
|
||||||
background: linear-gradient(140deg, rgba(80, 140, 220, 0.10) 0%, rgba(40, 70, 130, 0.06) 100%);
|
background: var(--mg-primary);
|
||||||
border-color: rgba(120, 170, 230, 0.22);
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
}
|
}
|
||||||
.prop-card--style {
|
.prop-card--geom,
|
||||||
background: linear-gradient(140deg, rgba(220, 110, 200, 0.10) 0%, rgba(150, 70, 130, 0.06) 100%);
|
.prop-card--style,
|
||||||
border-color: rgba(230, 130, 200, 0.22);
|
.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 {
|
.prop-card-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
margin-bottom: 8px;
|
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 {
|
.prop-card-title {
|
||||||
font-size: 11.5px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.08em;
|
||||||
color: rgba(220, 180, 250, 0.85);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
color: var(--me-text);
|
||||||
}
|
}
|
||||||
.prop-card-meta {
|
.prop-card-meta {
|
||||||
font-size: 10.5px;
|
margin-left: auto;
|
||||||
color: rgba(200, 180, 220, 0.5);
|
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 {
|
.prop-card-flag {
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: rgba(180, 200, 240, 0.6);
|
color: var(--me-text-muted);
|
||||||
padding: 2px 7px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 999px;
|
||||||
background: rgba(120, 160, 220, 0.10);
|
background: var(--me-surface);
|
||||||
transition: background .25s ease, color .25s ease, box-shadow .25s ease;
|
border: 1px solid var(--me-card-border);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.prop-card-flag.is-synced {
|
.prop-card-flag.is-synced {
|
||||||
background: linear-gradient(120deg, rgba(80, 200, 130, 0.32), rgba(120, 220, 200, 0.32));
|
background: rgba(34, 197, 94, 0.12);
|
||||||
color: rgba(220, 255, 230, 0.95);
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
box-shadow: 0 0 8px rgba(80, 200, 130, 0.45);
|
color: #15803d;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.prop-card-flag-dot {
|
.prop-card-flag-dot {
|
||||||
width: 6px; height: 6px; border-radius: 50%;
|
width: 6px;
|
||||||
background: rgba(180, 200, 240, 0.6);
|
height: 6px;
|
||||||
transition: background .25s ease, box-shadow .25s ease;
|
border-radius: 50%;
|
||||||
|
background: var(--me-text-muted);
|
||||||
}
|
}
|
||||||
.prop-card-flag.is-synced .prop-card-flag-dot {
|
.prop-card-flag.is-synced .prop-card-flag-dot {
|
||||||
background: #6ce7a8;
|
background: #4ade80;
|
||||||
box-shadow: 0 0 6px rgba(108, 231, 168, 0.85);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prop-card-row {
|
.prop-card-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
margin-bottom: 6px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.prop-card-row:last-child { margin-bottom: 0; }
|
.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 {
|
.prop-id-tag {
|
||||||
background: rgba(170, 110, 250, 0.30);
|
background: var(--mg-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 2px 8px;
|
padding: 3px 9px;
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
letter-spacing: 0.5px;
|
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.prop-type-tag {
|
.prop-type-tag {
|
||||||
color: rgba(232, 215, 245, 0.75);
|
color: var(--me-text-muted);
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
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 {
|
.prop-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prop-delete-btn {
|
.prop-delete-btn {
|
||||||
@@ -1030,31 +1093,31 @@ function onDelete() {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 3px 9px;
|
padding: 4px 10px;
|
||||||
border-radius: 5px;
|
border-radius: 6px;
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 550;
|
||||||
letter-spacing: 0.4px;
|
color: #fecaca;
|
||||||
color: rgba(255, 200, 210, 0.95);
|
background: rgba(239, 68, 68, 0.12);
|
||||||
background: linear-gradient(135deg, rgba(255, 90, 120, 0.18) 0%, rgba(220, 60, 110, 0.22) 100%);
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
border: 1px solid rgba(255, 110, 140, 0.45);
|
transition: background .12s ease, border-color .12s ease;
|
||||||
transition: background .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
|
|
||||||
}
|
}
|
||||||
.prop-delete-btn:hover:not(:disabled) {
|
.prop-delete-btn:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, rgba(255, 90, 120, 0.55) 0%, rgba(220, 60, 110, 0.65) 100%);
|
background: rgba(239, 68, 68, 0.22);
|
||||||
border-color: rgba(255, 140, 170, 0.85);
|
border-color: rgba(239, 68, 68, 0.55);
|
||||||
color: #fff;
|
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-btn:disabled { opacity: 0.55; cursor: progress; }
|
||||||
.prop-delete-icon { font-size: 12px; line-height: 1; }
|
.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 {
|
.prop-name-label {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
color: rgba(200, 180, 220, 0.7);
|
color: var(--me-text-muted);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.prop-layer-select { flex: 1; }
|
.prop-layer-select { flex: 1; }
|
||||||
@@ -1062,15 +1125,15 @@ function onDelete() {
|
|||||||
.prop-grid {
|
.prop-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
column-gap: 8px;
|
column-gap: 10px;
|
||||||
row-gap: 8px;
|
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-grid-item--wide { grid-column: 1 / -1; }
|
||||||
.prop-field-label {
|
.prop-field-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(200, 180, 230, 0.78);
|
color: var(--me-text-muted);
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -1081,155 +1144,230 @@ function onDelete() {
|
|||||||
}
|
}
|
||||||
.prop-field-unit {
|
.prop-field-unit {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(170, 150, 200, 0.55);
|
color: var(--me-text-muted);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.prop-field-num :deep(.el-input-number) { width: 100%; }
|
.prop-field-num :deep(.el-input-number) { width: 100%; }
|
||||||
.prop-field-num :deep(.el-input__wrapper) {
|
.prop-field-num :deep(.el-input__wrapper),
|
||||||
background: rgba(255,255,255,0.06);
|
.prop-card :deep(.el-input__wrapper),
|
||||||
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
|
.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 {
|
.prop-field-icon-btn {
|
||||||
appearance: none; border: 0; cursor: pointer;
|
appearance: none;
|
||||||
width: 22px; height: 22px;
|
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||||
border-radius: 4px;
|
cursor: pointer;
|
||||||
background: rgba(255, 100, 130, 0.12);
|
width: 28px;
|
||||||
color: rgba(255, 180, 200, 0.85);
|
height: 28px;
|
||||||
font-size: 14px; line-height: 1;
|
border-radius: 7px;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
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;
|
flex: none;
|
||||||
transition: background .14s, color .14s;
|
transition: background .12s ease, border-color .12s ease, color .12s ease;
|
||||||
}
|
}
|
||||||
.prop-field-icon-btn:hover {
|
.prop-field-icon-btn:hover {
|
||||||
background: rgba(255, 100, 130, 0.32);
|
background: rgba(239, 68, 68, 0.16);
|
||||||
color: #fff;
|
border-color: rgba(239, 68, 68, 0.45);
|
||||||
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prop-empty-row {
|
.prop-empty-row {
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
color: rgba(200, 180, 220, 0.4);
|
color: var(--me-text-muted);
|
||||||
text-align: center;
|
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 {
|
.prop-add-field-btn {
|
||||||
appearance: none; border: 0; cursor: pointer;
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
padding: 6px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: rgba(170, 110, 250, 0.14);
|
background: var(--me-surface);
|
||||||
border: 1px dashed rgba(170, 110, 250, 0.40);
|
border: 1px dashed rgba(var(--mg-primary-rgb), 0.42);
|
||||||
color: rgba(220, 180, 250, 0.92);
|
color: var(--mg-primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
transition: background .12s ease, border-color .12s ease, color .12s ease;
|
||||||
}
|
}
|
||||||
.prop-add-field-btn:hover {
|
.prop-add-field-btn:hover:not(:disabled) {
|
||||||
background: rgba(170, 110, 250, 0.26);
|
background: var(--me-hover);
|
||||||
border-color: rgba(170, 110, 250, 0.65);
|
border-color: rgba(var(--mg-primary-rgb), 0.7);
|
||||||
color: #fff;
|
border-style: solid;
|
||||||
|
color: var(--mg-primary);
|
||||||
}
|
}
|
||||||
|
.prop-add-field-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
.custom-field-list {
|
.custom-field-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-field-item {
|
.custom-field-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--me-inset);
|
||||||
|
border: 1px solid var(--me-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-field-key {
|
.custom-field-key {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 650;
|
||||||
color: rgba(200, 180, 230, 0.88);
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--me-text);
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
cursor: default;
|
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) {
|
.custom-field-input :deep(.el-input__wrapper) {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--me-card-bg) !important;
|
||||||
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
|
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) {
|
.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) {
|
.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 {
|
.prop-status-item {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex;
|
||||||
padding: 5px 8px;
|
justify-content: space-between;
|
||||||
border-radius: 4px;
|
align-items: center;
|
||||||
background: rgba(255,255,255,0.03);
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--me-inset);
|
||||||
|
border: 1px solid var(--me-card-border);
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
.prop-status-key { color: rgba(170, 200, 240, 0.85); font-weight: 500; }
|
.prop-status-item:nth-child(even) {
|
||||||
.prop-status-value {
|
background: rgba(var(--mg-primary-rgb), 0.08);
|
||||||
color: rgba(232, 245, 255, 0.95);
|
}
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
.prop-status-key {
|
||||||
max-width: 60%;
|
color: var(--me-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
flex: none;
|
||||||
|
max-width: 46%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.prop-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.prop-action-btn {
|
.prop-action-btn {
|
||||||
appearance: none; border: 0; cursor: pointer;
|
appearance: none;
|
||||||
padding: 7px 10px;
|
cursor: pointer;
|
||||||
border-radius: 7px;
|
padding: 9px 11px;
|
||||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.20) 0%, rgba(120, 70, 220, 0.20) 100%);
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(170, 110, 250, 0.35);
|
background: var(--mg-primary);
|
||||||
color: rgba(232, 215, 245, 0.95);
|
border: 1px solid var(--mg-primary);
|
||||||
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
display: flex; align-items: center; justify-content: flex-start; gap: 6px;
|
display: flex;
|
||||||
transition: background .14s ease, border-color .14s ease, transform .12s ease, box-shadow .14s ease;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
text-align: left;
|
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) {
|
.prop-action-btn:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.62) 0%, rgba(255, 110, 220, 0.55) 100%);
|
background: var(--mg-primary-hover, var(--mg-primary));
|
||||||
border-color: rgba(255, 130, 220, 0.7);
|
border-color: var(--mg-primary-hover, var(--mg-primary));
|
||||||
color: #fff;
|
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:nth-child(2n):hover:not(:disabled) {
|
||||||
.prop-action-btn:disabled { opacity: 0.6; cursor: progress; }
|
background: rgba(var(--mg-primary-rgb), 0.1);
|
||||||
.prop-action-icon {
|
border-color: var(--mg-primary);
|
||||||
font-size: 9px;
|
color: var(--mg-primary);
|
||||||
color: rgba(255, 200, 250, 0.85);
|
box-shadow: 0 2px 8px var(--me-active-glow);
|
||||||
flex: none;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.prop-action-spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: prop-spin .8s linear infinite;
|
animation: prop-spin .8s linear infinite;
|
||||||
@@ -1237,56 +1375,63 @@ function onDelete() {
|
|||||||
}
|
}
|
||||||
.prop-action-label {
|
.prop-action-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes prop-spin {
|
@keyframes prop-spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.defaults-pane { display: flex; flex-direction: column; gap: 10px; }
|
.defaults-alert { margin-bottom: 0; }
|
||||||
.defaults-alert { margin-bottom: 4px; }
|
.defaults-empty { padding: 32px 12px; }
|
||||||
.defaults-empty { padding: 40px 12px; }
|
|
||||||
.defaults-form .el-button { margin-top: 8px; }
|
.defaults-form .el-button { margin-top: 8px; }
|
||||||
.defaults-form :deep(.el-form-item) { margin-bottom: 10px; }
|
.defaults-form :deep(.el-form-item) { margin-bottom: 10px; }
|
||||||
/* 图层 Tab:白底数据块(深侧栏上的可读「岛」),各主题下表头/单元格均用深字 */
|
.defaults-form :deep(.el-form-item__label) { color: var(--me-text-muted); }
|
||||||
|
|
||||||
.layer-table {
|
.layer-table {
|
||||||
margin: 6px 0;
|
margin: 0;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.14);
|
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) {
|
.layer-table :deep(.el-table__inner-wrapper),
|
||||||
background: #fff;
|
.layer-table :deep(.el-table),
|
||||||
|
.layer-table :deep(.el-table tr) {
|
||||||
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
.layer-table :deep(th.el-table__cell) {
|
.layer-table :deep(th.el-table__cell) {
|
||||||
background: #f5f3ff !important;
|
background: rgba(var(--mg-primary-rgb), 0.16) !important;
|
||||||
color: #2d1b69 !important;
|
color: var(--me-text) !important;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
}
|
}
|
||||||
.layer-table :deep(td.el-table__cell) {
|
.layer-table :deep(td.el-table__cell) {
|
||||||
background: #fff !important;
|
background: transparent !important;
|
||||||
color: #1a0f3d !important;
|
color: var(--me-text) !important;
|
||||||
font-weight: 500;
|
border-color: rgba(255, 255, 255, 0.06) !important;
|
||||||
}
|
|
||||||
.layer-table :deep(.cell) {
|
|
||||||
color: #1a0f3d !important;
|
|
||||||
}
|
}
|
||||||
|
.layer-table :deep(.cell) { color: inherit !important; }
|
||||||
.layer-table :deep(tr:hover > td.el-table__cell) {
|
.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) {
|
.new-layer :deep(.el-input__inner) {
|
||||||
color: #1a0f3d;
|
color: var(--me-text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.new-layer :deep(.el-input__inner::placeholder) {
|
.new-layer :deep(.el-input__inner::placeholder) {
|
||||||
color: #7a6b9a;
|
color: var(--me-text-muted);
|
||||||
}
|
}
|
||||||
.new-layer :deep(.el-input-group__append .el-button) {
|
.new-layer :deep(.el-input-group__append .el-button) {
|
||||||
color: #5b21b6;
|
color: var(--mg-accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ function formatXY(v: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
background: rgba(20, 8, 40, 0.85);
|
background: var(--me-chrome-2);
|
||||||
border-top: 1px solid rgba(255,255,255,0.08);
|
border-top: 1px solid var(--me-border);
|
||||||
color: rgba(232,215,245,0.85);
|
color: var(--me-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
.seg b { color: var(--mg-accent, #c4a4ff); }
|
.seg b { color: var(--mg-accent); }
|
||||||
.seg .on { color: #67c23a; }
|
.seg .on { color: #67c23a; }
|
||||||
.seg .off { color: #a0a0a0; }
|
.seg .off { color: #a0a0a0; }
|
||||||
.seg.ok { color: #67c23a; }
|
.seg.ok { color: #67c23a; }
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ const groups: ToolGroup[] = [
|
|||||||
{ id: 'transform.move', label: '移动', glyph: '✥' },
|
{ id: 'transform.move', label: '移动', glyph: '✥' },
|
||||||
{ id: 'transform.rotate', label: '旋转', glyph: '⟳' },
|
{ id: 'transform.rotate', label: '旋转', glyph: '⟳' },
|
||||||
{ id: 'transform.scale', 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.copy', label: '复制', glyph: '📋', tip: 'Ctrl+C' },
|
||||||
{ id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' },
|
{ id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' },
|
||||||
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' },
|
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' },
|
||||||
@@ -152,12 +151,12 @@ function onClick(id: EditToolId) { emit('select-tool', id) }
|
|||||||
.edit-tool-rail {
|
.edit-tool-rail {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 170px;
|
width: 170px;
|
||||||
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
|
background: linear-gradient(180deg, var(--me-chrome-1) 0%, var(--me-chrome-2) 100%);
|
||||||
border-right: 1px solid rgba(255,255,255,0.10);
|
border-right: 1px solid var(--me-border);
|
||||||
padding: 4px 0 12px;
|
padding: 4px 0 12px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
display: flex; flex-direction: column;
|
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-scroll { height: 100%; }
|
||||||
.rail-group {
|
.rail-group {
|
||||||
@@ -168,12 +167,12 @@ function onClick(id: EditToolId) { emit('select-tool', id) }
|
|||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
color: rgba(190, 160, 230, 0.7);
|
color: var(--me-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 4px 0 6px;
|
margin: 4px 0 6px;
|
||||||
font-weight: 600;
|
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 {
|
.rail-group-list {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -181,7 +180,7 @@ function onClick(id: EditToolId) { emit('select-tool', id) }
|
|||||||
.rail-divider {
|
.rail-divider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1px;
|
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;
|
margin: 6px 0 2px;
|
||||||
}
|
}
|
||||||
.rail-btn {
|
.rail-btn {
|
||||||
@@ -192,8 +191,8 @@ function onClick(id: EditToolId) { emit('select-tool', id) }
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255,255,255,0.04);
|
background: var(--me-surface);
|
||||||
color: rgba(232,215,245,0.92);
|
color: var(--me-text);
|
||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 12.5px;
|
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;
|
transition: background .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
|
||||||
}
|
}
|
||||||
.rail-btn:hover {
|
.rail-btn:hover {
|
||||||
background: rgba(150, 90, 230, 0.20);
|
background: var(--me-hover);
|
||||||
color: #fff;
|
color: var(--mg-primary);
|
||||||
}
|
}
|
||||||
.rail-btn:active {
|
.rail-btn:active {
|
||||||
transform: scale(0.985);
|
transform: scale(0.985);
|
||||||
}
|
}
|
||||||
.rail-btn.rail-btn--active {
|
.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;
|
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 {
|
.rail-btn-glyph {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -236,21 +235,21 @@ function onClick(id: EditToolId) { emit('select-tool', id) }
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 8px; right: 8px; top: 0;
|
left: 8px; right: 8px; top: 0;
|
||||||
height: 1px;
|
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 {
|
.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;
|
color: #fff;
|
||||||
font-weight: 600;
|
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 {
|
.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;
|
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 {
|
.rail-btn--ai .rail-btn-glyph {
|
||||||
font-size: 16px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -148,16 +148,16 @@ function mark(on: boolean): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: linear-gradient(180deg, rgba(50, 22, 88, 0.95) 0%, rgba(34, 14, 64, 0.95) 100%);
|
background: linear-gradient(180deg, var(--me-chrome-1) 0%, var(--me-chrome-2) 100%);
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.12);
|
border-bottom: 1px solid var(--me-border);
|
||||||
backdrop-filter: blur(10px);
|
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; }
|
.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 {
|
.topbar-btn {
|
||||||
color: #fdf6ff !important;
|
color: var(--me-text) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
@@ -166,22 +166,23 @@ function mark(on: boolean): string {
|
|||||||
transition: background .15s ease, color .15s ease;
|
transition: background .15s ease, color .15s ease;
|
||||||
}
|
}
|
||||||
.topbar-btn:hover {
|
.topbar-btn:hover {
|
||||||
color: #fff !important;
|
color: var(--mg-primary) !important;
|
||||||
background: rgba(170, 110, 250, 0.28) !important;
|
background: var(--me-hover) !important;
|
||||||
}
|
}
|
||||||
.topbar-btn:focus {
|
.topbar-btn:focus {
|
||||||
background: rgba(170, 110, 250, 0.30) !important;
|
background: var(--me-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-icon-btn {
|
.topbar-icon-btn {
|
||||||
color: rgba(253, 246, 255, 0.92) !important;
|
color: var(--me-text) !important;
|
||||||
background: rgba(255,255,255,0.05) !important;
|
background: var(--me-surface) !important;
|
||||||
border: 1px solid rgba(255,255,255,0.15) !important;
|
border: 1px solid var(--me-border) !important;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
.topbar-icon-btn:hover:not(.is-disabled) {
|
.topbar-icon-btn:hover:not(.is-disabled) {
|
||||||
background: rgba(170, 110, 250, 0.30) !important;
|
background: var(--me-hover) !important;
|
||||||
border-color: rgba(170, 110, 250, 0.55) !important;
|
border-color: rgba(var(--mg-primary-rgb), 0.55) !important;
|
||||||
|
color: var(--mg-primary) !important;
|
||||||
}
|
}
|
||||||
.topbar-icon-btn.is-disabled {
|
.topbar-icon-btn.is-disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
@@ -194,11 +195,19 @@ function mark(on: boolean): string {
|
|||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-right: 4px;
|
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) {
|
.topbar-filter-menu :deep(.topbar-filter-header) {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(232, 215, 245, 0.55);
|
color: var(--me-text-muted);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
|||||||
@@ -137,10 +137,13 @@ defineExpose({ refresh })
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.plugin-list-card {
|
.plugin-list-card {
|
||||||
background: rgba(30, 14, 55, 0.55) !important;
|
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.94) !important;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
border: 1px solid var(--mg-veil-border, rgba(40, 33, 58, 0.10)) !important;
|
||||||
backdrop-filter: blur(12px);
|
border-radius: 8px !important;
|
||||||
margin-bottom: 12px;
|
box-shadow: none !important;
|
||||||
|
backdrop-filter: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.plg-header {
|
.plg-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -148,9 +151,9 @@ defineExpose({ refresh })
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.plg-title {
|
.plg-title {
|
||||||
font-size: 14px;
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
color: var(--mg-text-light);
|
color: var(--el-text-color-primary, #28213a);
|
||||||
}
|
}
|
||||||
.plg-collapse { margin-left: auto; }
|
.plg-collapse { margin-left: auto; }
|
||||||
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
|
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
|
||||||
|
|||||||
+245
-109
@@ -3,11 +3,14 @@
|
|||||||
<PluginListPanel ref="pluginPanelRef" :visible="true" @changed="refresh" />
|
<PluginListPanel ref="pluginPanelRef" :visible="true" @changed="refresh" />
|
||||||
|
|
||||||
<div class="ref-mgr-header">
|
<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">
|
<div class="ref-mgr-actions">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:placeholder="`搜索 ${kindLabel} (id / 名称 / 类型)`"
|
:placeholder="`搜索 ${kindLabel}`"
|
||||||
size="small"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
class="ref-mgr-search"
|
class="ref-mgr-search"
|
||||||
@@ -77,6 +80,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="ref-mgr-body">
|
||||||
<!-- 左:列表 -->
|
<!-- 左:列表 -->
|
||||||
<el-card shadow="never" class="ref-mgr-list-card">
|
<el-card shadow="never" class="ref-mgr-list-card">
|
||||||
@@ -86,15 +110,22 @@
|
|||||||
:data="filteredObjects"
|
:data="filteredObjects"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
stripe
|
stripe
|
||||||
border
|
|
||||||
size="small"
|
size="small"
|
||||||
highlight-current-row
|
highlight-current-row
|
||||||
class="ref-mgr-table"
|
class="ref-mgr-table"
|
||||||
@current-change="onSelect"
|
@current-change="onSelect"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="72">
|
||||||
<el-table-column prop="name" label="名称" min-width="160" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="typeName" label="类型" width="160" show-overflow-tooltip />
|
<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
|
<el-table-column
|
||||||
v-if="showSummaryColumn"
|
v-if="showSummaryColumn"
|
||||||
prop="summary"
|
prop="summary"
|
||||||
@@ -186,7 +217,7 @@
|
|||||||
<div class="detail-tab-body">
|
<div class="detail-tab-body">
|
||||||
<template v-if="detailTab === 'properties'">
|
<template v-if="detailTab === 'properties'">
|
||||||
<div class="field-section field-section--first">
|
<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
|
<el-descriptions
|
||||||
v-if="typedFields.length > 0"
|
v-if="typedFields.length > 0"
|
||||||
:column="2"
|
:column="2"
|
||||||
@@ -238,7 +269,7 @@
|
|||||||
<!-- 动态字段(Prop.fields) -->
|
<!-- 动态字段(Prop.fields) -->
|
||||||
<div class="field-section">
|
<div class="field-section">
|
||||||
<div class="field-section-title">
|
<div class="field-section-title">
|
||||||
<span>动态字段 ({{ dynamicFields.length }})</span>
|
<span>动态字段</span><b>{{ dynamicFields.length }}</b>
|
||||||
<button class="add-field-btn" @click="onAddField">+ 添加字段</button>
|
<button class="add-field-btn" @click="onAddField">+ 添加字段</button>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions
|
<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 { Search, Plus, Refresh, CaretBottom, Connection, Document } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
reflectionApi,
|
reflectionApi,
|
||||||
|
formatReflectionExecuteMessage,
|
||||||
type ReflectionKind,
|
type ReflectionKind,
|
||||||
type ReflectionMethod,
|
type ReflectionMethod,
|
||||||
type ReflectionObject,
|
type ReflectionObject,
|
||||||
@@ -545,6 +577,7 @@ const savingProject = ref(false)
|
|||||||
const lastProjectSavePath = ref<string | null>(null)
|
const lastProjectSavePath = ref<string | null>(null)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
const scriptActionLoading = ref<'source' | 'status' | null>(null)
|
const scriptActionLoading = ref<'source' | 'status' | null>(null)
|
||||||
const scriptActionLoadingRowId = ref<number | null>(null)
|
const scriptActionLoadingRowId = ref<number | null>(null)
|
||||||
const scriptDialogOpen = ref(false)
|
const scriptDialogOpen = ref(false)
|
||||||
@@ -577,6 +610,11 @@ const autoSaveProjectEnabled = computed(
|
|||||||
() => props.autoSaveProject ?? isProjectPersistableKind(props.kind)
|
() => props.autoSaveProject ?? isProjectPersistableKind(props.kind)
|
||||||
)
|
)
|
||||||
const saveButtonLabel = computed(() => props.saveButtonLabel ?? '保存')
|
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(() => {
|
const projectSaveTooltip = computed(() => {
|
||||||
if (lastProjectSavePath.value) {
|
if (lastProjectSavePath.value) {
|
||||||
return `将当前内存项目写回 JSON;上次保存:${lastProjectSavePath.value}`
|
return `将当前内存项目写回 JSON;上次保存:${lastProjectSavePath.value}`
|
||||||
@@ -592,15 +630,39 @@ const rowActionsWidth = computed(() => {
|
|||||||
return Math.max(w, 72)
|
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 filteredObjects = computed(() => {
|
||||||
const q = search.value.trim().toLowerCase()
|
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) ||
|
String(o.id).includes(q) ||
|
||||||
(o.name ?? '').toLowerCase().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 需要先在下拉里挑插件子类型,再实例化;
|
// process / car-typed 需要先在下拉里挑插件子类型,再实例化;
|
||||||
// site / track / image / text / model 直接弹表单收坐标和图层。
|
// site / track / image / text / model 直接弹表单收坐标和图层。
|
||||||
@@ -1124,7 +1186,7 @@ async function runMethod(m: ReflectionMethod) {
|
|||||||
executing.value = m.methodName
|
executing.value = m.methodName
|
||||||
try {
|
try {
|
||||||
const r = await reflectionApi.execute(realKind, current.value.id, m.methodName, params)
|
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)
|
await onSelect(current.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`执行失败:${(err as Error).message}`)
|
ElMessage.error(`执行失败:${(err as Error).message}`)
|
||||||
@@ -1136,7 +1198,7 @@ async function runMethod(m: ReflectionMethod) {
|
|||||||
executing.value = m.methodName
|
executing.value = m.methodName
|
||||||
try {
|
try {
|
||||||
const r = await reflectionApi.execute(realKind, current.value.id, m.methodName)
|
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)
|
await onSelect(current.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`执行失败:${(err as Error).message}`)
|
ElMessage.error(`执行失败:${(err as Error).message}`)
|
||||||
@@ -1198,7 +1260,11 @@ useMapEditStream({
|
|||||||
onObjectBatchChanged: () => refresh()
|
onObjectBatchChanged: () => refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.kind, refresh)
|
watch(() => props.kind, () => {
|
||||||
|
typeFilter.value = ''
|
||||||
|
search.value = ''
|
||||||
|
void refresh()
|
||||||
|
})
|
||||||
onMounted(refresh)
|
onMounted(refresh)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1207,31 +1273,81 @@ onMounted(refresh)
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
color: var(--mg-text-light);
|
color: var(--mg-text-light);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.ref-mgr-header {
|
.ref-mgr-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
padding: 6px 0 4px;
|
min-height: 40px;
|
||||||
border-bottom: 1px solid var(--mg-divider);
|
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 {
|
.ref-mgr-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
/* 用 CSS 变量自适应深色 / 浅色主题,#fff 兜底深色玻璃语境。 */
|
|
||||||
color: var(--el-text-color-primary, #fff);
|
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 {
|
.ref-mgr-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.ref-type-asm {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
@@ -1242,69 +1358,103 @@ onMounted(refresh)
|
|||||||
.ref-mgr-body {
|
.ref-mgr-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(380px, 0.9fr) 1.4fr;
|
grid-template-columns: minmax(420px, 1.15fr) minmax(320px, 0.95fr);
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ref-mgr-list-card,
|
.ref-mgr-list-card,
|
||||||
.ref-mgr-detail-card {
|
.ref-mgr-detail-card {
|
||||||
background: rgba(30, 14, 55, 0.55) !important;
|
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.94) !important;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
border: 1px solid var(--mg-veil-border, rgba(124, 58, 237, 0.14)) !important;
|
||||||
backdrop-filter: blur(12px);
|
border-radius: 8px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
backdrop-filter: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ref-mgr-list-card :deep(.el-card__body),
|
.ref-mgr-list-card :deep(.el-card__body),
|
||||||
.ref-mgr-detail-card :deep(.el-card__body) {
|
.ref-mgr-detail-card :deep(.el-card__body) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
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 {
|
.detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 8px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px dashed var(--mg-veil-border);
|
border-bottom: 1px solid var(--mg-veil-border, var(--mg-divider));
|
||||||
}
|
}
|
||||||
.detail-title {
|
.detail-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.detail-actions {
|
.detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.detail-id {
|
.detail-id {
|
||||||
color: var(--mg-text-dim);
|
color: var(--mg-text-dim);
|
||||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
|
||||||
font-size: 12.5px;
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.detail-name {
|
.detail-name {
|
||||||
color: var(--el-text-color-primary, #fff);
|
color: var(--el-text-color-primary, #fff);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
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__header) { margin-bottom: 0; }
|
||||||
.detail-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
|
.detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
|
||||||
.detail-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
|
.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 {
|
.detail-tab-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-top: 10px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-section {
|
.field-section {
|
||||||
margin-top: 14px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.field-section--first {
|
.field-section--first {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -1312,13 +1462,21 @@ onMounted(refresh)
|
|||||||
.field-section-title {
|
.field-section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
/* CSS 变量自适应深 / 浅主题,避免白底白字。 */
|
|
||||||
color: var(--el-text-color-primary, rgba(255, 255, 255, 0.85));
|
color: var(--el-text-color-primary, rgba(255, 255, 255, 0.85));
|
||||||
margin-bottom: 8px;
|
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 {
|
.field-empty {
|
||||||
color: var(--mg-text-dim);
|
color: var(--mg-text-dim);
|
||||||
@@ -1345,34 +1503,32 @@ onMounted(refresh)
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动作按钮:紫色渐变 + 白色文字,hover 时加亮 + 阴影。
|
/* Style A:实色主按钮,去掉霓虹渐变。 */
|
||||||
配色参考 EditPropertyPanel 的 .prop-action-btn 与 EditTopBar 的 .topbar-btn。 */
|
|
||||||
.method-btn {
|
.method-btn {
|
||||||
appearance: none; border: 0; cursor: pointer;
|
appearance: none; border: 0; cursor: pointer;
|
||||||
padding: 8px 12px;
|
padding: 7px 10px;
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.30) 0%, rgba(120, 70, 220, 0.30) 100%);
|
background: var(--mg-primary, #7c3aed);
|
||||||
border: 1px solid rgba(170, 110, 250, 0.55);
|
border: 1px solid var(--mg-primary, #7c3aed);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12.5px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 550;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
text-align: left;
|
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) {
|
.method-btn:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.72) 0%, rgba(255, 110, 220, 0.62) 100%);
|
background: var(--mg-primary-hover, #6d28d9);
|
||||||
border-color: rgba(255, 130, 220, 0.78);
|
border-color: var(--mg-primary-hover, #6d28d9);
|
||||||
box-shadow: 0 4px 14px rgba(180, 90, 220, 0.42);
|
|
||||||
}
|
}
|
||||||
.method-btn:active:not(:disabled) { transform: scale(0.985); }
|
.method-btn:active:not(:disabled) { transform: scale(0.985); }
|
||||||
.method-btn:disabled { opacity: 0.55; cursor: progress; }
|
.method-btn:disabled { opacity: 0.55; cursor: progress; }
|
||||||
.method-btn-icon {
|
.method-btn-icon {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 200, 250, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.method-btn-spin {
|
.method-btn-spin {
|
||||||
@@ -1399,39 +1555,38 @@ onMounted(refresh)
|
|||||||
|
|
||||||
/* 「+ 添加字段」按钮:与动作按钮同色系但偏淡,区分等级。 */
|
/* 「+ 添加字段」按钮:与动作按钮同色系但偏淡,区分等级。 */
|
||||||
.add-field-btn {
|
.add-field-btn {
|
||||||
appearance: none; border: 0; cursor: pointer;
|
appearance: none;
|
||||||
padding: 4px 10px;
|
cursor: pointer;
|
||||||
|
padding: 3px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(170, 110, 250, 0.18);
|
background: transparent;
|
||||||
border: 1px dashed rgba(170, 110, 250, 0.45);
|
border: 1px dashed rgba(124, 58, 237, 0.45);
|
||||||
color: rgba(255, 230, 255, 0.95);
|
color: var(--mg-primary, #7c3aed);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 550;
|
||||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
transition: background .12s ease, border-color .12s ease;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.add-field-btn:hover {
|
.add-field-btn:hover {
|
||||||
background: rgba(170, 110, 250, 0.34);
|
background: rgba(124, 58, 237, 0.08);
|
||||||
border-color: rgba(170, 110, 250, 0.75);
|
border-color: rgba(124, 58, 237, 0.7);
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 详情头「在 3D 中高亮」按钮:与动作按钮同款样式。 */
|
|
||||||
.header-action-btn {
|
.header-action-btn {
|
||||||
appearance: none; border: 0; cursor: pointer;
|
appearance: none; border: 0; cursor: pointer;
|
||||||
padding: 6px 14px;
|
padding: 5px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.32) 0%, rgba(120, 70, 220, 0.32) 100%);
|
background: var(--mg-primary, #7c3aed);
|
||||||
border: 1px solid rgba(170, 110, 250, 0.55);
|
border: 1px solid var(--mg-primary, #7c3aed);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12.5px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 550;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.2px;
|
||||||
transition: background .14s ease, border-color .14s ease, box-shadow .14s ease;
|
transition: background .12s ease;
|
||||||
}
|
}
|
||||||
.header-action-btn:hover {
|
.header-action-btn:hover {
|
||||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.72) 0%, rgba(255, 110, 220, 0.62) 100%);
|
background: var(--mg-primary-hover, #6d28d9);
|
||||||
border-color: rgba(255, 130, 220, 0.78);
|
border-color: var(--mg-primary-hover, #6d28d9);
|
||||||
box-shadow: 0 3px 10px rgba(180, 90, 220, 0.40);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.script-dialog-textarea :deep(.el-textarea__inner) {
|
.script-dialog-textarea :deep(.el-textarea__inner) {
|
||||||
@@ -1468,33 +1623,14 @@ onMounted(refresh)
|
|||||||
background: var(--mg-veil-1) !important;
|
background: var(--mg-veil-1) !important;
|
||||||
}
|
}
|
||||||
:deep(.el-table__body tr.current-row > td.el-table__cell) {
|
: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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 浅紫主题:卡片变白后,半透明渐变动作按钮上的白字会看不清,
|
@media (max-width: 1100px) {
|
||||||
改为实色紫底白字(对比 ~6.9:1,过 WCAG AA)。scoped 限本组件,不影响地图编辑画布。 ── */
|
.ref-mgr-body { grid-template-columns: 1fr; }
|
||||||
:root[data-theme="fame-lavender"] .method-btn,
|
.ref-mgr-search { width: min(220px, 100%); }
|
||||||
: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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
@row-contextmenu="onRowContextMenu"
|
@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>
|
<el-table-column label="起点" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span>
|
<span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span>
|
||||||
@@ -103,7 +103,7 @@ import {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
deliveries: DeliveryTask[]
|
deliveries: DeliveryTask[]
|
||||||
selectedId?: number | null
|
selectedId?: string | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -234,7 +234,10 @@ function hideCtx() {
|
|||||||
const actionLabels: Record<DeliveryAction, string> = {
|
const actionLabels: Record<DeliveryAction, string> = {
|
||||||
cancel: '取消任务',
|
cancel: '取消任务',
|
||||||
resend: '重发任务',
|
resend: '重发任务',
|
||||||
'force-complete': '强制完成'
|
'force-complete': '强制完成',
|
||||||
|
pause: '暂停任务',
|
||||||
|
resume: '恢复任务',
|
||||||
|
'change-car': '更换车辆'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAction(action: DeliveryAction) {
|
async function runAction(action: DeliveryAction) {
|
||||||
@@ -384,10 +387,12 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
|
|||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
min-width: 128px;
|
min-width: 128px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
background: #1e1e2e;
|
background: #fffefd;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(40, 33, 58, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
box-shadow:
|
||||||
|
0 1px 2px rgba(40, 33, 58, 0.06),
|
||||||
|
0 12px 28px rgba(54, 35, 78, 0.14);
|
||||||
}
|
}
|
||||||
.ctx-item {
|
.ctx-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -395,16 +400,17 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
|
|||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #fff;
|
color: #28213a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ctx-item:hover:not(:disabled) {
|
.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 {
|
.ctx-item:disabled {
|
||||||
color: rgba(255, 255, 255, 0.35);
|
color: #b8b0c8;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+14
-14
@@ -212,6 +212,11 @@ import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
|
|||||||
import { executeOp } from '@/api/ops'
|
import { executeOp } from '@/api/ops'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache'
|
import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache'
|
||||||
|
import {
|
||||||
|
extractSocFromFieldMap,
|
||||||
|
extractSocFromKv,
|
||||||
|
socToPercent
|
||||||
|
} from '@/utils/batterySoc'
|
||||||
import type { Car } from '@/types/car'
|
import type { Car } from '@/types/car'
|
||||||
import type { Mission } from '@/types/mission'
|
import type { Mission } from '@/types/mission'
|
||||||
import type { SelectedObjectRef } from '@/types/workbench'
|
import type { SelectedObjectRef } from '@/types/workbench'
|
||||||
@@ -374,16 +379,13 @@ function carHasOccupiedTag(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatBatteryText(): { text: string; low: 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 car = findCarInList()
|
||||||
const raw = pickField('batterySoc', 'battery', '电量')
|
const ratio = fromStatus ?? fromFields ?? (car != null && car.batterySoc > 0 ? car.batterySoc : null)
|
||||||
let pct: number | null = null
|
if (ratio == null || !Number.isFinite(ratio) || ratio <= 0) return { text: '—', low: false }
|
||||||
if (raw) {
|
const pct = socToPercent(ratio)
|
||||||
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 }
|
|
||||||
return { text: `${pct}%`, low: pct < 20 }
|
return { text: `${pct}%`, low: pct < 20 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,12 +595,10 @@ function applyCarListPreview(): boolean {
|
|||||||
bundleName.value = car.name
|
bundleName.value = car.name
|
||||||
bundleTypeName.value = car.typeName ?? ''
|
bundleTypeName.value = car.typeName ?? ''
|
||||||
bundleFullTypeName.value = car.typeName ?? ''
|
bundleFullTypeName.value = car.typeName ?? ''
|
||||||
const pct = Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)
|
|
||||||
fieldMap.value = {
|
fieldMap.value = {
|
||||||
x: String(car.x),
|
x: String(car.x),
|
||||||
y: String(car.y),
|
y: String(car.y),
|
||||||
th: String(car.theta),
|
th: String(car.theta)
|
||||||
batterySoc: String(pct)
|
|
||||||
}
|
}
|
||||||
statusRows.value = []
|
statusRows.value = []
|
||||||
allMethods.value = []
|
allMethods.value = []
|
||||||
@@ -906,11 +906,11 @@ watch(
|
|||||||
|
|
||||||
/* fame-lavender 浅紫主题:goto(前往站点)按钮在白底下需实色琥珀 + 白字,
|
/* fame-lavender 浅紫主题:goto(前往站点)按钮在白底下需实色琥珀 + 白字,
|
||||||
否则半透明棕底叠白底→浅棕 + 白字不可读。 */
|
否则半透明棕底叠白底→浅棕 + 白字不可读。 */
|
||||||
:root[data-theme="fame-lavender"] .action-btn--goto {
|
:root[data-shell="outpost"] .action-btn--goto {
|
||||||
border-color: #b45309;
|
border-color: #b45309;
|
||||||
background: #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;
|
border-color: #92400e;
|
||||||
background: #92400e;
|
background: #92400e;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -103,6 +103,7 @@ import { getSelectionDetail } from '@/api/workbench'
|
|||||||
import { getConfig } from '@/api/config'
|
import { getConfig } from '@/api/config'
|
||||||
import {
|
import {
|
||||||
reflectionApi,
|
reflectionApi,
|
||||||
|
formatReflectionExecuteMessage,
|
||||||
type ReflectionKv,
|
type ReflectionKv,
|
||||||
type ReflectionKind,
|
type ReflectionKind,
|
||||||
type ReflectionMethod
|
type ReflectionMethod
|
||||||
@@ -399,9 +400,7 @@ async function onExecute(m: ReflectionMethod) {
|
|||||||
executing.value = m.methodName
|
executing.value = m.methodName
|
||||||
try {
|
try {
|
||||||
const r = await reflectionApi.execute(rk, idNum, m.methodName, params)
|
const r = await reflectionApi.execute(rk, idNum, m.methodName, params)
|
||||||
ElMessage.success(
|
ElMessage.success(formatReflectionExecuteMessage(m.label || m.methodName, r))
|
||||||
r.returnValue ? `执行成功:${r.returnValue}` : `已执行 ${m.label || m.methodName}`
|
|
||||||
)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`执行失败:${(e as Error).message}`)
|
ElMessage.error(`执行失败:${(e as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+256
-20
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="vehicle-monitor">
|
<div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table }">
|
||||||
<div class="overview-row">
|
<div v-if="!compact && !table" class="overview-row">
|
||||||
<div class="overview-item">
|
<div class="overview-item">
|
||||||
<div class="overview-num">{{ cars.length }}</div>
|
<div class="overview-num">{{ cars.length }}</div>
|
||||||
<div class="overview-label">总数</div>
|
<div class="overview-label">总数</div>
|
||||||
@@ -28,10 +28,11 @@
|
|||||||
v-model="search"
|
v-model="search"
|
||||||
size="small"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="搜索 ID / 名称 / 任务"
|
:placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'"
|
||||||
class="search"
|
class="search"
|
||||||
/>
|
/>
|
||||||
<el-select
|
<el-select
|
||||||
|
v-if="!table"
|
||||||
v-model="filterState"
|
v-model="filterState"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="状态"
|
placeholder="状态"
|
||||||
@@ -47,7 +48,51 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="car in filteredCars"
|
v-for="car in filteredCars"
|
||||||
:key="car.id"
|
:key="car.id"
|
||||||
@@ -120,6 +165,10 @@ const props = defineProps<{
|
|||||||
cars: Car[]
|
cars: Car[]
|
||||||
missions: Mission[]
|
missions: Mission[]
|
||||||
selectedId?: string | null
|
selectedId?: string | null
|
||||||
|
/** 紧凑模式:隐藏顶部概览条 */
|
||||||
|
compact?: boolean
|
||||||
|
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局) */
|
||||||
|
table?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -128,6 +177,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const filterState = ref<CarState | ''>('')
|
const filterState = ref<CarState | ''>('')
|
||||||
|
type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault'
|
||||||
|
const quickFilter = ref<QuickFilter>('all')
|
||||||
|
|
||||||
const stateOptions: { value: CarState; label: string }[] = [
|
const stateOptions: { value: CarState; label: string }[] = [
|
||||||
{ value: 'idle', label: '空闲' },
|
{ 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 runningCount = computed(() => props.cars.filter((c) => c.state === 'running').length)
|
||||||
const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length)
|
const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length)
|
||||||
const faultCount = computed(() => props.cars.filter((c) => c.state === 'fault').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 missionByCarId = computed(() => {
|
||||||
const order = (s: MissionStatus) =>
|
const order = (s: MissionStatus) =>
|
||||||
@@ -232,10 +293,16 @@ function missionForCar(car: Car): Mission | undefined {
|
|||||||
|
|
||||||
function batteryPct(car: Car): number {
|
function batteryPct(car: Car): number {
|
||||||
const raw = car.batterySoc ?? 0
|
const raw = car.batterySoc ?? 0
|
||||||
|
if (!raw) return 0
|
||||||
const pct = raw > 1 ? raw : raw * 100
|
const pct = raw > 1 ? raw : raw * 100
|
||||||
return Math.max(0, Math.min(100, Math.round(pct)))
|
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 {
|
function batteryColor(p: number): string {
|
||||||
if (p < 20) return '#f56c6c'
|
if (p < 20) return '#f56c6c'
|
||||||
if (p < 50) return '#e6a23c'
|
if (p < 50) return '#e6a23c'
|
||||||
@@ -248,11 +315,29 @@ function batteryLevel(p: number): 'low' | 'mid' | 'high' {
|
|||||||
return '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 filteredCars = computed(() => {
|
||||||
const q = search.value.trim().toLowerCase()
|
const q = search.value.trim().toLowerCase()
|
||||||
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
|
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
|
||||||
return props.cars.filter((c) => {
|
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
|
if (!tokens.length) return true
|
||||||
const mission = missionForCar(c)
|
const mission = missionForCar(c)
|
||||||
const hay = [
|
const hay = [
|
||||||
@@ -263,7 +348,8 @@ const filteredCars = computed(() => {
|
|||||||
c.ip ?? '',
|
c.ip ?? '',
|
||||||
stateLabel(c.state),
|
stateLabel(c.state),
|
||||||
mission?.name ?? '',
|
mission?.name ?? '',
|
||||||
mission ? missionStatusLabel(mission.status) : ''
|
mission ? missionStatusLabel(mission.status) : '',
|
||||||
|
positionText(c)
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -290,6 +376,24 @@ function detailIdForCar(car: Car): string {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
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 {
|
.overview-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -315,10 +419,10 @@ function detailIdForCar(car: Car): string {
|
|||||||
color: var(--mg-text-light);
|
color: var(--mg-text-light);
|
||||||
line-height: 1.2;
|
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.online { color: var(--mg-accent); }
|
||||||
.overview-num.running { color: var(--mg-status-success); text-shadow: 0 0 12px rgba(var(--mg-status-success-rgb), 0.45); }
|
.overview-num.running { color: var(--mg-status-success); }
|
||||||
.overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); }
|
.overview-num.charging { color: var(--mg-primary-hover); }
|
||||||
.overview-num.fault { color: var(--mg-status-danger); text-shadow: 0 0 12px rgba(var(--mg-status-danger-rgb), 0.5); }
|
.overview-num.fault { color: var(--mg-status-danger); }
|
||||||
.overview-label {
|
.overview-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--mg-text-muted);
|
color: var(--mg-text-muted);
|
||||||
@@ -333,6 +437,143 @@ function detailIdForCar(car: Car): string {
|
|||||||
.toolbar .search { flex: 1; min-width: 0; }
|
.toolbar .search { flex: 1; min-width: 0; }
|
||||||
.toolbar .filter { width: 100px; flex: none; }
|
.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 {
|
.vehicle-list {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -400,17 +641,12 @@ function detailIdForCar(car: Car): string {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
.dot-running { color: #6fcd45; box-shadow: 0 0 8px #6fcd45; animation: pulse 1.5s infinite; }
|
.dot-running { color: #6fcd45; }
|
||||||
.dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); }
|
.dot-charging { color: var(--mg-primary-hover); }
|
||||||
.dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; }
|
.dot-paused { color: #e6a23c; }
|
||||||
.dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; }
|
.dot-fault { color: #ff7396; }
|
||||||
.dot-offline { color: var(--mg-text-faint); }
|
.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); }
|
.dot-idle { color: var(--mg-accent); }
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.35; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-sub {
|
.vehicle-sub {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+17
@@ -249,6 +249,16 @@ const props = withDefaults(
|
|||||||
{ followCarId: null }
|
{ 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`)。
|
* 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。
|
||||||
*
|
*
|
||||||
@@ -318,6 +328,7 @@ const followCarId = computed(() => {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
state.value = await workspaceToolbarApi.getState()
|
state.value = await workspaceToolbarApi.getState()
|
||||||
|
publishState(state.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`同步工具栏状态失败:${(err as Error).message}`)
|
ElMessage.error(`同步工具栏状态失败:${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -328,6 +339,7 @@ async function toggleAlign(key: AlignKey) {
|
|||||||
busy.align = true
|
busy.align = true
|
||||||
try {
|
try {
|
||||||
state.value = await workspaceToolbarApi.setAlign(key, !state.value.align[key])
|
state.value = await workspaceToolbarApi.setAlign(key, !state.value.align[key])
|
||||||
|
publishState(state.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`切换对齐失败:${(err as Error).message}`)
|
ElMessage.error(`切换对齐失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -340,6 +352,7 @@ async function toggleSelect(key: SelectKey) {
|
|||||||
busy.select = true
|
busy.select = true
|
||||||
try {
|
try {
|
||||||
state.value = await workspaceToolbarApi.setSelect(key, !state.value.select[key])
|
state.value = await workspaceToolbarApi.setSelect(key, !state.value.select[key])
|
||||||
|
publishState(state.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`切换选择过滤失败:${(err as Error).message}`)
|
ElMessage.error(`切换选择过滤失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -352,6 +365,7 @@ async function toggleDisplay(key: DisplayKey) {
|
|||||||
busy.display = true
|
busy.display = true
|
||||||
try {
|
try {
|
||||||
state.value = await workspaceToolbarApi.setDisplay(key, !state.value.display[key])
|
state.value = await workspaceToolbarApi.setDisplay(key, !state.value.display[key])
|
||||||
|
publishState(state.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`切换显示内容失败:${(err as Error).message}`)
|
ElMessage.error(`切换显示内容失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -363,6 +377,7 @@ async function toggleLayer(name: string, visible: boolean) {
|
|||||||
busy.layer = true
|
busy.layer = true
|
||||||
try {
|
try {
|
||||||
state.value = await workspaceToolbarApi.setLayerVisibility(name, visible)
|
state.value = await workspaceToolbarApi.setLayerVisibility(name, visible)
|
||||||
|
publishState(state.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`切换图层失败:${(err as Error).message}`)
|
ElMessage.error(`切换图层失败:${(err as Error).message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -467,6 +482,8 @@ async function toggleCameraFollow() {
|
|||||||
try {
|
try {
|
||||||
const enable = !cameraFollowing.value
|
const enable = !cameraFollowing.value
|
||||||
state.value = await workspaceToolbarApi.setCameraFollow(carId, enable)
|
state.value = await workspaceToolbarApi.setCameraFollow(carId, enable)
|
||||||
|
publishState(state.value)
|
||||||
|
emit('follow-changed', { enabled: enable, carId: enable ? carId : null })
|
||||||
if (enable) {
|
if (enable) {
|
||||||
ElMessage.success({ message: `已开始跟随车辆 #${carId}`, duration: 1500, grouping: true })
|
ElMessage.success({ message: `已开始跟随车辆 #${carId}`, duration: 1500, grouping: true })
|
||||||
} else {
|
} 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 =
|
export type EditToolId =
|
||||||
// 选择组
|
// 选择组(左侧仅保留「仅*」过滤;单选/框选等由画布默认交互承担)
|
||||||
| 'select.single'
|
| 'select.single'
|
||||||
| 'select.rect'
|
| 'select.rect'
|
||||||
| 'select.lasso'
|
| 'select.lasso'
|
||||||
| 'select.byType.site'
|
| 'select.byType.site'
|
||||||
|
| 'select.byType.straight'
|
||||||
|
| 'select.byType.curve'
|
||||||
| 'select.byType.bezier'
|
| 'select.byType.bezier'
|
||||||
| 'select.byType.polyline'
|
| 'select.byType.polyline'
|
||||||
| 'select.byType.arc'
|
| 'select.byType.arc'
|
||||||
@@ -28,6 +30,7 @@ export type EditToolId =
|
|||||||
| 'draw.site'
|
| 'draw.site'
|
||||||
| 'draw.sites.continuous'
|
| 'draw.sites.continuous'
|
||||||
| 'draw.track.polyline'
|
| 'draw.track.polyline'
|
||||||
|
| 'draw.tracks.continuous'
|
||||||
| 'draw.track.bezier'
|
| 'draw.track.bezier'
|
||||||
| 'draw.track.arc'
|
| 'draw.track.arc'
|
||||||
| 'draw.track.nurbs'
|
| 'draw.track.nurbs'
|
||||||
@@ -41,7 +44,6 @@ export type EditToolId =
|
|||||||
| 'transform.move'
|
| 'transform.move'
|
||||||
| 'transform.rotate'
|
| 'transform.rotate'
|
||||||
| 'transform.scale'
|
| 'transform.scale'
|
||||||
| 'transform.duplicate'
|
|
||||||
| 'transform.copy'
|
| 'transform.copy'
|
||||||
| 'transform.paste'
|
| 'transform.paste'
|
||||||
| 'transform.delete'
|
| 'transform.delete'
|
||||||
@@ -82,7 +84,8 @@ export interface SnapState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useEditTool() {
|
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 snap = ref<SnapState>({ grid: false, gridSizeMm: 100, object: true, endpoint: true, midpoint: false, toAngleDeg: undefined })
|
||||||
const showControlPoints = ref(true)
|
const showControlPoints = ref(true)
|
||||||
const showControlHandles = ref(true)
|
const showControlHandles = ref(true)
|
||||||
@@ -105,17 +108,20 @@ export function useEditTool() {
|
|||||||
'select.single': '单选',
|
'select.single': '单选',
|
||||||
'select.rect': '矩形框选',
|
'select.rect': '矩形框选',
|
||||||
'select.lasso': '圈选',
|
'select.lasso': '圈选',
|
||||||
'select.byType.site': '按类型选 - 站点',
|
'select.byType.site': '仅站点',
|
||||||
'select.byType.bezier': '按类型选 - 贝塞尔',
|
'select.byType.straight': '仅直线',
|
||||||
'select.byType.polyline': '按类型选 - 折线',
|
'select.byType.curve': '仅曲线',
|
||||||
'select.byType.arc': '按类型选 - 弧',
|
'select.byType.bezier': '仅曲线',
|
||||||
'select.byType.decor': '按类型选 - 装饰物',
|
'select.byType.polyline': '仅直线',
|
||||||
'select.byType.currentLayer': '按类型选 - 当前图层',
|
'select.byType.arc': '仅曲线',
|
||||||
|
'select.byType.decor': '仅装饰',
|
||||||
|
'select.byType.currentLayer': '当前图层',
|
||||||
'select.invert': '反选',
|
'select.invert': '反选',
|
||||||
'select.clear': '清空选中',
|
'select.clear': '清空选中',
|
||||||
'draw.site': '添加站点',
|
'draw.site': '添加站点',
|
||||||
'draw.sites.continuous': '连续添加站点',
|
'draw.sites.continuous': '连续添加站点',
|
||||||
'draw.track.polyline': '添加折线路径',
|
'draw.track.polyline': '添加直线路径',
|
||||||
|
'draw.tracks.continuous': '连续添加直线路径',
|
||||||
'draw.track.bezier': '添加贝塞尔路径',
|
'draw.track.bezier': '添加贝塞尔路径',
|
||||||
'draw.track.arc': '添加弧形路径',
|
'draw.track.arc': '添加弧形路径',
|
||||||
'draw.track.nurbs': '添加 NURBS 路径',
|
'draw.track.nurbs': '添加 NURBS 路径',
|
||||||
@@ -128,7 +134,6 @@ export function useEditTool() {
|
|||||||
'transform.move': '移动',
|
'transform.move': '移动',
|
||||||
'transform.rotate': '旋转',
|
'transform.rotate': '旋转',
|
||||||
'transform.scale': '缩放',
|
'transform.scale': '缩放',
|
||||||
'transform.duplicate': '复制副本',
|
|
||||||
'transform.copy': '复制',
|
'transform.copy': '复制',
|
||||||
'transform.paste': '粘贴',
|
'transform.paste': '粘贴',
|
||||||
'transform.delete': '删除',
|
'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 latencyLabel = computed(() => {
|
||||||
const ms = vehicle.value.latencyMs
|
const ms = vehicle.value.latencyMs
|
||||||
|
if (vehicle.value.reachable === false && ms == null) return '不可达'
|
||||||
if (ms == null) return '—'
|
if (ms == null) return '—'
|
||||||
if (vehicle.value.reachable === false) return '超时'
|
|
||||||
return `${ms} ms`
|
return `${ms} ms`
|
||||||
})
|
})
|
||||||
|
|
||||||
const latencyClass = computed(() => {
|
const latencyClass = computed(() => {
|
||||||
const ms = vehicle.value.latencyMs
|
const ms = vehicle.value.latencyMs
|
||||||
if (vehicle.value.reachable === false) return 'val-danger'
|
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 ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Files,
|
Bell,
|
||||||
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Odometer,
|
Collection,
|
||||||
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera, View
|
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'
|
} from '@element-plus/icons-vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
@@ -16,19 +37,26 @@ export interface NavMenuItem {
|
|||||||
|
|
||||||
export const ADMIN_MENU: NavMenuItem[] = [
|
export const ADMIN_MENU: NavMenuItem[] = [
|
||||||
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
|
{ 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: [
|
children: [
|
||||||
{ path: '/admin/maps', label: '地图管理', icon: Files, key: 'admin-maps', group: '设计与编排' },
|
{ path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' },
|
||||||
{ path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' },
|
{ path: '/admin/tasks', label: '任务管理', icon: List, key: 'admin-tasks', group: '概览' },
|
||||||
{ path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' },
|
{ path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', 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/design', label: '设计与编辑', icon: Tools, group: '设计与编辑',
|
||||||
{ path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' },
|
children: [
|
||||||
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编排' }
|
{ 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'])
|
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
|
||||||
|
|
||||||
/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */
|
/** 旧版别名 key -> 菜单 key */
|
||||||
const LEGACY_KEY_ALIASES: Record<string, string> = {
|
const LEGACY_KEY_ALIASES: Record<string, string> = {
|
||||||
'platform-config': 'admin-map-editor',
|
'platform-config': 'admin-map-editor',
|
||||||
mission: 'admin-task-templates',
|
mission: 'admin-task-templates',
|
||||||
@@ -33,7 +33,7 @@ const LEGACY_KEY_ALIASES: Record<string, string> = {
|
|||||||
tasks: 'admin-config-strategy'
|
tasks: 'admin-config-strategy'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platform 域下固定保留、不可删除的快捷入口(顺序即展示优先级) */
|
/** Platform 域下固定保留、不可删除的快捷入口 */
|
||||||
export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
||||||
'admin-maps',
|
'admin-maps',
|
||||||
'admin-map-editor',
|
'admin-map-editor',
|
||||||
@@ -43,7 +43,7 @@ export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
|||||||
'admin-task-templates'
|
'admin-task-templates'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */
|
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐 */
|
||||||
export const DEFAULT_PLATFORM_QUICK_KEYS = [
|
export const DEFAULT_PLATFORM_QUICK_KEYS = [
|
||||||
...MANDATORY_PLATFORM_QUICK_KEYS,
|
...MANDATORY_PLATFORM_QUICK_KEYS,
|
||||||
'admin-config-system-center',
|
'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))
|
return (MANDATORY_PLATFORM_QUICK_KEYS as readonly string[]).includes(normalizeQuickKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保证固定四项始终存在且排在最前(其余项保持原顺序) */
|
/** 保证固定项始终存在且排在最前 */
|
||||||
export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] {
|
export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] {
|
||||||
const normalized = normalizeQuickKeys(keys)
|
const normalized = normalizeQuickKeys(keys)
|
||||||
if (scope !== 'Platform') return normalized
|
if (scope !== 'Platform') return normalized
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<div class="aside-footer">
|
<div class="aside-footer">
|
||||||
<span class="aside-footer-badge">v1.6</span>
|
<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>
|
</div>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
@@ -82,6 +82,15 @@
|
|||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
|
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
|
||||||
<el-dropdown-item command="status">服务状态</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-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -97,10 +106,13 @@
|
|||||||
</el-main>
|
</el-main>
|
||||||
|
|
||||||
<el-footer class="footer">
|
<el-footer class="footer">
|
||||||
<span>{{ scopeLabel }} · webVRender :{{ vrPort }} · API {{ apiBase }}</span>
|
<span>{{ scopeLabel }} · Projection :{{ slPort }} · API {{ apiBase }}</span>
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<!-- 全局 AI 助手:右侧抽屉 + 右下角悬浮唤起,所有界面可呼出。后端要求 Platform 权限,故按 scope 显示。 -->
|
||||||
|
<AiAssistantDrawer v-if="auth.scope === 'Platform'" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -113,13 +125,14 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
import ScopeSwitcher from '@/components/ScopeSwitcher.vue'
|
import ScopeSwitcher from '@/components/ScopeSwitcher.vue'
|
||||||
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
|
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
|
||||||
|
import AiAssistantDrawer from '@/components/assistant/AiAssistantDrawer.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
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 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())
|
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) ?? '')
|
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
|
||||||
|
|
||||||
function onUserCommand(cmd: string) {
|
function onUserCommand(cmd: string) {
|
||||||
|
if (cmd.startsWith('theme:')) {
|
||||||
|
ui.setThemeId(cmd.slice('theme:'.length))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (cmd === 'logout') {
|
if (cmd === 'logout') {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -187,6 +204,7 @@ function onUserCommand(cmd: string) {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +254,7 @@ function onUserCommand(cmd: string) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────── Sidebar ─────────────── */
|
/* ─────────────── Sidebar ─────────────── */
|
||||||
@@ -549,3 +568,25 @@ function onUserCommand(cmd: string) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
</style>
|
</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'
|
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[]> {
|
export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
||||||
await new Promise((r) => setTimeout(r, 60))
|
await new Promise((r) => setTimeout(r, 60))
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 'a7Kp3',
|
||||||
|
taskId: 'WMS-20260720-001',
|
||||||
missionId: 1,
|
missionId: 1,
|
||||||
missionName: '链式搬运',
|
missionName: '搬运任务进程',
|
||||||
missionTypeName: 'FengTianChainedDeliveryMission',
|
missionTypeName: 'TransportMission',
|
||||||
srcSiteId: 1,
|
srcSiteId: 1,
|
||||||
srcLabel: '1-取货台 A',
|
srcLabel: '1-取货台 A',
|
||||||
dstSiteId: 8,
|
dstSiteId: 8,
|
||||||
@@ -17,14 +22,18 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
|||||||
carId: 1,
|
carId: 1,
|
||||||
carName: 'AGV-01',
|
carName: 'AGV-01',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
createTime: new Date().toISOString(),
|
createTime: hoursAgo(0.5),
|
||||||
|
startTime: hoursAgo(0.4),
|
||||||
|
finishTime: null,
|
||||||
|
stuckReason: null,
|
||||||
overdue: false
|
overdue: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 102,
|
id: 'b2Xq9',
|
||||||
|
taskId: null,
|
||||||
missionId: 1,
|
missionId: 1,
|
||||||
missionName: '链式搬运',
|
missionName: '搬运任务进程',
|
||||||
missionTypeName: 'FengTianChainedDeliveryMission',
|
missionTypeName: 'TransportMission',
|
||||||
srcSiteId: 3,
|
srcSiteId: 3,
|
||||||
srcLabel: '3-缓存区',
|
srcLabel: '3-缓存区',
|
||||||
dstSiteId: 12,
|
dstSiteId: 12,
|
||||||
@@ -34,14 +43,39 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
|||||||
carId: null,
|
carId: null,
|
||||||
carName: null,
|
carName: null,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
createTime: new Date(Date.now() - 45 * 60_000).toISOString(),
|
createTime: hoursAgo(0.8),
|
||||||
|
startTime: null,
|
||||||
|
finishTime: null,
|
||||||
|
stuckReason: '无可用车辆',
|
||||||
overdue: true
|
overdue: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 99,
|
id: 'c9Lm4',
|
||||||
|
taskId: 'WMS-20260720-003',
|
||||||
missionId: 1,
|
missionId: 1,
|
||||||
missionName: '链式搬运',
|
missionName: '搬运任务进程',
|
||||||
missionTypeName: 'FengTianChainedDeliveryMission',
|
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,
|
srcSiteId: 2,
|
||||||
srcLabel: '2-原料区',
|
srcLabel: '2-原料区',
|
||||||
dstSiteId: 5,
|
dstSiteId: 5,
|
||||||
@@ -51,8 +85,53 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
|||||||
carId: 2,
|
carId: 2,
|
||||||
carName: 'AGV-02',
|
carName: 'AGV-02',
|
||||||
priority: 1,
|
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
|
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[] = [
|
const PAGES: PageDef[] = [
|
||||||
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
|
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
|
||||||
{ key: 'admin-map-monitor', 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-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
|
{ 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: '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: '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: '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: '场景管理' } },
|
{ path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } },
|
||||||
@@ -51,8 +53,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
|
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
|
||||||
{ path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } },
|
{ path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } },
|
||||||
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
|
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
|
||||||
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'maintenance' } } },
|
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
|
||||||
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'fleet' } } },
|
{ 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/routing', redirect: { path: '/admin/config/strategy', query: { tab: 'routing' } } },
|
||||||
{ path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } },
|
{ path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } },
|
||||||
{ path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } },
|
{ path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } },
|
||||||
@@ -137,7 +139,8 @@ router.beforeEach(async (to) => {
|
|||||||
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
||||||
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
||||||
const needScope: 'Platform' | 'RCSMonitor' | null =
|
const needScope: 'Platform' | 'RCSMonitor' | null =
|
||||||
to.path.startsWith('/admin') ? 'Platform'
|
to.path.startsWith('/admin')
|
||||||
|
? 'Platform'
|
||||||
: to.path.startsWith('/monitor') ? 'RCSMonitor'
|
: to.path.startsWith('/monitor') ? 'RCSMonitor'
|
||||||
: null
|
: null
|
||||||
if (needScope && auth.scope !== needScope) {
|
if (needScope && auth.scope !== needScope) {
|
||||||
@@ -153,7 +156,8 @@ router.beforeEach(async (to) => {
|
|||||||
// RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。
|
// RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。
|
||||||
// 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。
|
// 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。
|
||||||
const name = typeof to.name === 'string' ? to.name : ''
|
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 list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES
|
||||||
const fallback = list.find((p) => auth.hasPage(p.name))?.path
|
const fallback = list.find((p) => auth.hasPage(p.name))?.path
|
||||||
if (fallback && fallback !== to.path) {
|
if (fallback && fallback !== to.path) {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { defineStore } from 'pinia'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_THEME_ID,
|
DEFAULT_THEME_ID,
|
||||||
|
LEGACY_THEMES,
|
||||||
|
PRIMARY_THEMES,
|
||||||
applyThemeVars,
|
applyThemeVars,
|
||||||
findTheme,
|
findTheme,
|
||||||
|
resolveThemeId,
|
||||||
THEMES,
|
THEMES,
|
||||||
type ThemePreset
|
type ThemePreset
|
||||||
} from '@/styles/themes'
|
} from '@/styles/themes'
|
||||||
@@ -28,6 +31,9 @@ interface UiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'simple.ui.state'
|
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 = {
|
const DEFAULT_STATE: UiState = {
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
@@ -39,14 +45,33 @@ const DEFAULT_STATE: UiState = {
|
|||||||
function loadInitial(): UiState {
|
function loadInitial(): UiState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
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>
|
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,
|
sidebarCollapsed: parsed.sidebarCollapsed ?? DEFAULT_STATE.sidebarCollapsed,
|
||||||
theme: parsed.theme === 'dark' ? 'dark' : 'light',
|
theme: parsed.theme === 'dark' ? 'dark' : 'light',
|
||||||
themeId: parsed.themeId ?? DEFAULT_STATE.themeId,
|
themeId,
|
||||||
themeOverrides: parsed.themeOverrides ?? {}
|
themeOverrides: parsed.themeOverrides ?? {}
|
||||||
}
|
}
|
||||||
|
if (migrated) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ui.store] localStorage 中的 UI 状态解析失败,已回退默认:', err)
|
console.warn('[ui.store] localStorage 中的 UI 状态解析失败,已回退默认:', err)
|
||||||
return { ...DEFAULT_STATE }
|
return { ...DEFAULT_STATE }
|
||||||
@@ -60,8 +85,16 @@ export const useUiStore = defineStore('ui', {
|
|||||||
activeTheme(state): ThemePreset {
|
activeTheme(state): ThemePreset {
|
||||||
return mergeThemePreset(findTheme(state.themeId), state.themeOverrides[state.themeId])
|
return mergeThemePreset(findTheme(state.themeId), state.themeOverrides[state.themeId])
|
||||||
},
|
},
|
||||||
/** 所有可选主题列表 */
|
/** 顶栏精选主题(浅色 / 紫色 / 蓝色) */
|
||||||
availableThemes(): ThemePreset[] {
|
availableThemes(): ThemePreset[] {
|
||||||
|
return PRIMARY_THEMES
|
||||||
|
},
|
||||||
|
/** 高级 → 兼容主题(旧深色霓虹) */
|
||||||
|
legacyThemes(): ThemePreset[] {
|
||||||
|
return LEGACY_THEMES
|
||||||
|
},
|
||||||
|
/** 全量(自定义配色等内部用) */
|
||||||
|
allThemes(): ThemePreset[] {
|
||||||
return THEMES
|
return THEMES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -107,7 +140,7 @@ export const useUiStore = defineStore('ui', {
|
|||||||
this.persist()
|
this.persist()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 切换品牌主题色板 */
|
/** 切换品牌主题色板(精选或兼容均可) */
|
||||||
setThemeId(id: string) {
|
setThemeId(id: string) {
|
||||||
const preset = findTheme(id)
|
const preset = findTheme(id)
|
||||||
this.themeId = preset.id
|
this.themeId = preset.id
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 主题色板预设
|
* 主题色板预设
|
||||||
*
|
*
|
||||||
* 设计原则:
|
* 主路径:Outpost 壳层三套精选(浅色 / 紫色 / 蓝色)
|
||||||
* - 每个主题提供一组 CSS 变量,运行时通过 document.documentElement.style.setProperty 注入
|
* 高级:旧深色霓虹主题(LEGACY_THEMES)
|
||||||
* - 命名遵循 theme.css 中既有的 --mg-* 体系
|
* 规范:docs/superpowers/specs/2026-07-25-migu-outpost-ui-lock-design.md
|
||||||
* - 工业紫色 (industrial-purple) 为默认主题:紫蓝渐变,深蓝底 + 天蓝高光
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ThemePreset {
|
export interface ThemePreset {
|
||||||
@@ -15,20 +14,77 @@ export interface ThemePreset {
|
|||||||
preview: string
|
preview: string
|
||||||
/** 强调高光,用于 UI 预览的次代表色 */
|
/** 强调高光,用于 UI 预览的次代表色 */
|
||||||
previewAccent: string
|
previewAccent: string
|
||||||
|
/** Outpost 浅色壳(纸白主区);legacy 深色为 false */
|
||||||
|
outpostShell?: boolean
|
||||||
vars: Record<string, string>
|
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[] = [
|
/**
|
||||||
{
|
* 地图编辑 chrome(顶栏/工具轨/属性栏/底栏)跟主题色相走。
|
||||||
id: 'fame-lavender',
|
* 默认真色壳(跟侧栏);浅色 Harbor 等可显式覆盖 --me-* 为浅壳。
|
||||||
name: '迷毂浅紫',
|
* 画布始终保持深色。
|
||||||
description: '参考 FAME 系统:深紫侧栏 + 浅紫白主区,柔和耐看,长时间使用不刺眼',
|
*/
|
||||||
preview: '#7c3aed',
|
function withMapEditorChrome(vars: Record<string, string>): Record<string, string> {
|
||||||
previewAccent: '#a855f7',
|
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: {
|
vars: {
|
||||||
// 主色保留紫色(与 FAME 一致),但 hover/accent 使用更明亮的 violet/purple
|
|
||||||
'--mg-primary': '#7c3aed',
|
'--mg-primary': '#7c3aed',
|
||||||
'--mg-primary-rgb': '124, 58, 237',
|
'--mg-primary-rgb': '124, 58, 237',
|
||||||
'--mg-primary-hover': '#8b5cf6',
|
'--mg-primary-hover': '#8b5cf6',
|
||||||
@@ -36,84 +92,139 @@ export const THEMES: ThemePreset[] = [
|
|||||||
'--mg-primary-active': '#5b21b6',
|
'--mg-primary-active': '#5b21b6',
|
||||||
'--mg-accent': '#a855f7',
|
'--mg-accent': '#a855f7',
|
||||||
'--mg-accent-rgb': '168, 85, 247',
|
'--mg-accent-rgb': '168, 85, 247',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#a78bfa',
|
'--mg-primary-light-3': '#a78bfa',
|
||||||
'--mg-primary-light-5': '#c4b5fd',
|
'--mg-primary-light-5': '#c4b5fd',
|
||||||
'--mg-primary-light-7': '#ddd6fe',
|
'--mg-primary-light-7': '#ddd6fe',
|
||||||
'--mg-primary-light-8': '#ede9fe',
|
'--mg-primary-light-8': '#ede9fe',
|
||||||
'--mg-primary-light-9': '#f5f3ff',
|
'--mg-primary-light-9': '#f5f3ff',
|
||||||
'--mg-primary-dark-2': '#6d28d9',
|
'--mg-primary-dark-2': '#6d28d9',
|
||||||
|
'--mg-bg-app-1': '#ffffff',
|
||||||
// 主区背景:浅薰衣草灰(v3:从近白下沉到 ~92% 明度,让白卡片靠阴影清晰浮起)
|
'--mg-bg-app-2': '#ffffff',
|
||||||
'--mg-bg-app-1': '#edeaf5',
|
'--mg-bg-app-3': '#ffffff',
|
||||||
'--mg-bg-app-2': '#e7e4f1',
|
'--mg-bg-app-deep-rgb': '255, 255, 255',
|
||||||
'--mg-bg-app-3': '#f1eef8',
|
'--mg-bg-aside-1': '#ffffff',
|
||||||
'--mg-bg-app-deep-rgb': '231, 228, 241',
|
'--mg-bg-aside-2': '#fafafa',
|
||||||
|
'--mg-bg-aside-rgb': '255, 255, 255',
|
||||||
// 侧栏:FAME 深紫渐变 #2d1b69 -> #1a1040
|
'--mg-shadow-rgb': '76, 29, 149',
|
||||||
'--mg-bg-aside-1': '#2d1b69',
|
|
||||||
'--mg-bg-aside-2': '#1a1040',
|
|
||||||
'--mg-bg-aside-rgb': '45, 27, 105',
|
|
||||||
|
|
||||||
// 卡片:白底(在 fame-lavender 专属规则中走纯 #fff)
|
|
||||||
'--mg-bg-card-rgb': '255, 255, 255',
|
'--mg-bg-card-rgb': '255, 255, 255',
|
||||||
'--mg-bg-card-hi-rgb': '249, 246, 255',
|
'--mg-bg-card-hi-rgb': '255, 255, 255',
|
||||||
'--mg-bg-card-darker-rgb':'245, 240, 251',
|
'--mg-bg-card-darker-rgb': '248, 250, 252',
|
||||||
'--mg-shadow-rgb': '124, 58, 237',
|
|
||||||
|
|
||||||
// 文字:深紫标题 #2d1b69, 中灰正文 #606266
|
|
||||||
'--mg-text-tint-rgb': '45, 27, 105',
|
'--mg-text-tint-rgb': '45, 27, 105',
|
||||||
'--mg-text-hi-rgb': '96, 98, 102',
|
'--mg-text-hi-rgb': '109, 40, 217',
|
||||||
|
/* 地图编辑:纯白壳 */
|
||||||
// 关闭装饰性 orb
|
'--me-chrome-mode': 'light',
|
||||||
'--mg-orb-a-opacity': '0',
|
'--me-chrome-1': '#ffffff',
|
||||||
'--mg-orb-b-opacity': '0',
|
'--me-chrome-2': '#ffffff',
|
||||||
'--mg-orb-c-opacity': '0'
|
'--me-chrome-rgb': '255, 255, 255',
|
||||||
|
'--me-border': 'rgba(124, 58, 237, 0.12)',
|
||||||
|
'--me-text': 'rgba(45, 27, 105, 0.92)',
|
||||||
|
'--me-text-muted': 'rgba(76, 29, 149, 0.55)',
|
||||||
|
'--me-surface': 'rgba(124, 58, 237, 0.04)',
|
||||||
|
'--me-hover': 'rgba(124, 58, 237, 0.08)',
|
||||||
|
'--me-active-glow': 'rgba(124, 58, 237, 0.18)',
|
||||||
|
'--me-card-bg': '#ffffff',
|
||||||
|
'--me-card-border': '#e5e7eb',
|
||||||
|
'--me-inset': '#f8fafc'
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
{
|
outpostPreset({
|
||||||
id: 'industrial-purple',
|
id: 'outpost-purple',
|
||||||
name: '星云紫',
|
name: '紫色',
|
||||||
description: '科幻紫主色,辅以蓝紫渐变,磨砂玻璃 · 霓虹辉光',
|
description: '同壳层、更饱和紫品牌;侧栏深蓝紫',
|
||||||
preview: '#7c3aed',
|
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: {
|
vars: {
|
||||||
// ── 主色:明亮 violet(紫),主导整个画面 ──
|
|
||||||
'--mg-primary': '#7c3aed',
|
'--mg-primary': '#7c3aed',
|
||||||
'--mg-primary-rgb': '124, 58, 237',
|
'--mg-primary-rgb': '124, 58, 237',
|
||||||
// hover/辉光:偏蓝紫 indigo,带来氛围流动感
|
|
||||||
'--mg-primary-hover': '#8b5cf6',
|
'--mg-primary-hover': '#8b5cf6',
|
||||||
'--mg-primary-hover-rgb': '139, 92, 246',
|
'--mg-primary-hover-rgb': '139, 92, 246',
|
||||||
'--mg-primary-active': '#5b21b6',
|
'--mg-primary-active': '#5b21b6',
|
||||||
// accent:浅紫高光,边缘霓虹与点缀
|
|
||||||
'--mg-accent': '#c4b5fd',
|
'--mg-accent': '#c4b5fd',
|
||||||
'--mg-accent-rgb': '196, 181, 253',
|
'--mg-accent-rgb': '196, 181, 253',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#9d6cf2',
|
'--mg-primary-light-3': '#9d6cf2',
|
||||||
'--mg-primary-light-5': '#b594f7',
|
'--mg-primary-light-5': '#b594f7',
|
||||||
'--mg-primary-light-7': '#6f4eab',
|
'--mg-primary-light-7': '#6f4eab',
|
||||||
'--mg-primary-light-8': '#4c357a',
|
'--mg-primary-light-8': '#4c357a',
|
||||||
'--mg-primary-light-9': '#2d1f55',
|
'--mg-primary-light-9': '#2d1f55',
|
||||||
'--mg-primary-dark-2': '#4c1d95',
|
'--mg-primary-dark-2': '#4c1d95',
|
||||||
|
|
||||||
// ── 背景:深紫黑底,带 indigo 余晖 ──
|
|
||||||
'--mg-bg-app-1': '#0e0a24',
|
'--mg-bg-app-1': '#0e0a24',
|
||||||
'--mg-bg-app-2': '#1f1147',
|
'--mg-bg-app-2': '#1f1147',
|
||||||
'--mg-bg-app-3': '#06031a',
|
'--mg-bg-app-3': '#06031a',
|
||||||
'--mg-bg-app-deep-rgb': '6, 3, 26',
|
'--mg-bg-app-deep-rgb': '6, 3, 26',
|
||||||
|
|
||||||
'--mg-bg-aside-1': '#0c0820',
|
'--mg-bg-aside-1': '#0c0820',
|
||||||
'--mg-bg-aside-2': '#03020e',
|
'--mg-bg-aside-2': '#03020e',
|
||||||
'--mg-bg-aside-rgb': '10, 6, 30',
|
'--mg-bg-aside-rgb': '10, 6, 30',
|
||||||
|
|
||||||
'--mg-bg-card-rgb': '38, 24, 78',
|
'--mg-bg-card-rgb': '38, 24, 78',
|
||||||
'--mg-bg-card-hi-rgb': '58, 38, 110',
|
'--mg-bg-card-hi-rgb': '58, 38, 110',
|
||||||
'--mg-bg-card-darker-rgb': '20, 12, 48',
|
'--mg-bg-card-darker-rgb': '20, 12, 48',
|
||||||
'--mg-shadow-rgb': '8, 2, 24',
|
'--mg-shadow-rgb': '8, 2, 24',
|
||||||
|
|
||||||
'--mg-text-tint-rgb': '242, 232, 255',
|
'--mg-text-tint-rgb': '242, 232, 255',
|
||||||
'--mg-text-hi-rgb': '220, 200, 252',
|
'--mg-text-hi-rgb': '220, 200, 252',
|
||||||
|
|
||||||
'--mg-orb-a-opacity': '0.55',
|
'--mg-orb-a-opacity': '0.55',
|
||||||
'--mg-orb-b-opacity': '0.50',
|
'--mg-orb-b-opacity': '0.50',
|
||||||
'--mg-orb-c-opacity': '0.40'
|
'--mg-orb-c-opacity': '0.40'
|
||||||
@@ -122,9 +233,10 @@ export const THEMES: ThemePreset[] = [
|
|||||||
{
|
{
|
||||||
id: 'deep-azure',
|
id: 'deep-azure',
|
||||||
name: '深邃蓝',
|
name: '深邃蓝',
|
||||||
description: '科技感深蓝,沉静稳重',
|
description: '科技感深蓝,沉静稳重(兼容)',
|
||||||
preview: '#1565c0',
|
preview: '#1565c0',
|
||||||
previewAccent: '#82b1ff',
|
previewAccent: '#82b1ff',
|
||||||
|
outpostShell: false,
|
||||||
vars: {
|
vars: {
|
||||||
'--mg-primary': '#1565c0',
|
'--mg-primary': '#1565c0',
|
||||||
'--mg-primary-rgb': '21, 101, 192',
|
'--mg-primary-rgb': '21, 101, 192',
|
||||||
@@ -133,28 +245,23 @@ export const THEMES: ThemePreset[] = [
|
|||||||
'--mg-primary-active': '#0d47a1',
|
'--mg-primary-active': '#0d47a1',
|
||||||
'--mg-accent': '#82b1ff',
|
'--mg-accent': '#82b1ff',
|
||||||
'--mg-accent-rgb': '130, 177, 255',
|
'--mg-accent-rgb': '130, 177, 255',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#5a8fce',
|
'--mg-primary-light-3': '#5a8fce',
|
||||||
'--mg-primary-light-5': '#83aedb',
|
'--mg-primary-light-5': '#83aedb',
|
||||||
'--mg-primary-light-7': '#adcde7',
|
'--mg-primary-light-7': '#adcde7',
|
||||||
'--mg-primary-light-8': '#c4dbed',
|
'--mg-primary-light-8': '#c4dbed',
|
||||||
'--mg-primary-light-9': '#e3eef8',
|
'--mg-primary-light-9': '#e3eef8',
|
||||||
'--mg-primary-dark-2': '#0d47a1',
|
'--mg-primary-dark-2': '#0d47a1',
|
||||||
|
|
||||||
'--mg-bg-app-1': '#051a3a',
|
'--mg-bg-app-1': '#051a3a',
|
||||||
'--mg-bg-app-2': '#0a2f6b',
|
'--mg-bg-app-2': '#0a2f6b',
|
||||||
'--mg-bg-app-3': '#020a1f',
|
'--mg-bg-app-3': '#020a1f',
|
||||||
'--mg-bg-app-deep-rgb': '2, 8, 26',
|
'--mg-bg-app-deep-rgb': '2, 8, 26',
|
||||||
|
|
||||||
'--mg-bg-aside-1': '#06122a',
|
'--mg-bg-aside-1': '#06122a',
|
||||||
'--mg-bg-aside-2': '#02060f',
|
'--mg-bg-aside-2': '#02060f',
|
||||||
'--mg-bg-aside-rgb': '8, 18, 42',
|
'--mg-bg-aside-rgb': '8, 18, 42',
|
||||||
|
|
||||||
'--mg-bg-card-rgb': '12, 36, 80',
|
'--mg-bg-card-rgb': '12, 36, 80',
|
||||||
'--mg-bg-card-hi-rgb': '18, 50, 110',
|
'--mg-bg-card-hi-rgb': '18, 50, 110',
|
||||||
'--mg-bg-card-darker-rgb': '6, 18, 44',
|
'--mg-bg-card-darker-rgb': '6, 18, 44',
|
||||||
'--mg-shadow-rgb': '2, 4, 14',
|
'--mg-shadow-rgb': '2, 4, 14',
|
||||||
|
|
||||||
'--mg-text-tint-rgb': '218, 234, 252',
|
'--mg-text-tint-rgb': '218, 234, 252',
|
||||||
'--mg-text-hi-rgb': '170, 200, 240'
|
'--mg-text-hi-rgb': '170, 200, 240'
|
||||||
}
|
}
|
||||||
@@ -162,9 +269,10 @@ export const THEMES: ThemePreset[] = [
|
|||||||
{
|
{
|
||||||
id: 'emerald-forge',
|
id: 'emerald-forge',
|
||||||
name: '翡翠绿',
|
name: '翡翠绿',
|
||||||
description: '机械翡翠绿,沉稳活力',
|
description: '机械翡翠绿(兼容)',
|
||||||
preview: '#00796b',
|
preview: '#00796b',
|
||||||
previewAccent: '#80cbc4',
|
previewAccent: '#80cbc4',
|
||||||
|
outpostShell: false,
|
||||||
vars: {
|
vars: {
|
||||||
'--mg-primary': '#00796b',
|
'--mg-primary': '#00796b',
|
||||||
'--mg-primary-rgb': '0, 121, 107',
|
'--mg-primary-rgb': '0, 121, 107',
|
||||||
@@ -173,28 +281,23 @@ export const THEMES: ThemePreset[] = [
|
|||||||
'--mg-primary-active': '#004d40',
|
'--mg-primary-active': '#004d40',
|
||||||
'--mg-accent': '#80cbc4',
|
'--mg-accent': '#80cbc4',
|
||||||
'--mg-accent-rgb': '128, 203, 196',
|
'--mg-accent-rgb': '128, 203, 196',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#4ca094',
|
'--mg-primary-light-3': '#4ca094',
|
||||||
'--mg-primary-light-5': '#79b9b0',
|
'--mg-primary-light-5': '#79b9b0',
|
||||||
'--mg-primary-light-7': '#a6d2cc',
|
'--mg-primary-light-7': '#a6d2cc',
|
||||||
'--mg-primary-light-8': '#bfdfdb',
|
'--mg-primary-light-8': '#bfdfdb',
|
||||||
'--mg-primary-light-9': '#e1f0ee',
|
'--mg-primary-light-9': '#e1f0ee',
|
||||||
'--mg-primary-dark-2': '#004d40',
|
'--mg-primary-dark-2': '#004d40',
|
||||||
|
|
||||||
'--mg-bg-app-1': '#06241f',
|
'--mg-bg-app-1': '#06241f',
|
||||||
'--mg-bg-app-2': '#0a4538',
|
'--mg-bg-app-2': '#0a4538',
|
||||||
'--mg-bg-app-3': '#020f0c',
|
'--mg-bg-app-3': '#020f0c',
|
||||||
'--mg-bg-app-deep-rgb': '2, 12, 10',
|
'--mg-bg-app-deep-rgb': '2, 12, 10',
|
||||||
|
|
||||||
'--mg-bg-aside-1': '#061a16',
|
'--mg-bg-aside-1': '#061a16',
|
||||||
'--mg-bg-aside-2': '#020a08',
|
'--mg-bg-aside-2': '#020a08',
|
||||||
'--mg-bg-aside-rgb': '8, 26, 22',
|
'--mg-bg-aside-rgb': '8, 26, 22',
|
||||||
|
|
||||||
'--mg-bg-card-rgb': '10, 50, 42',
|
'--mg-bg-card-rgb': '10, 50, 42',
|
||||||
'--mg-bg-card-hi-rgb': '14, 66, 56',
|
'--mg-bg-card-hi-rgb': '14, 66, 56',
|
||||||
'--mg-bg-card-darker-rgb': '6, 28, 24',
|
'--mg-bg-card-darker-rgb': '6, 28, 24',
|
||||||
'--mg-shadow-rgb': '2, 10, 8',
|
'--mg-shadow-rgb': '2, 10, 8',
|
||||||
|
|
||||||
'--mg-text-tint-rgb': '220, 244, 240',
|
'--mg-text-tint-rgb': '220, 244, 240',
|
||||||
'--mg-text-hi-rgb': '170, 220, 210'
|
'--mg-text-hi-rgb': '170, 220, 210'
|
||||||
}
|
}
|
||||||
@@ -202,9 +305,10 @@ export const THEMES: ThemePreset[] = [
|
|||||||
{
|
{
|
||||||
id: 'crimson-iron',
|
id: 'crimson-iron',
|
||||||
name: '熔铁赤',
|
name: '熔铁赤',
|
||||||
description: '熔铁炉火般的暗红工业风',
|
description: '暗红工业风(兼容)',
|
||||||
preview: '#b71c1c',
|
preview: '#b71c1c',
|
||||||
previewAccent: '#ff8a80',
|
previewAccent: '#ff8a80',
|
||||||
|
outpostShell: false,
|
||||||
vars: {
|
vars: {
|
||||||
'--mg-primary': '#b71c1c',
|
'--mg-primary': '#b71c1c',
|
||||||
'--mg-primary-rgb': '183, 28, 28',
|
'--mg-primary-rgb': '183, 28, 28',
|
||||||
@@ -213,28 +317,23 @@ export const THEMES: ThemePreset[] = [
|
|||||||
'--mg-primary-active': '#8a0c0c',
|
'--mg-primary-active': '#8a0c0c',
|
||||||
'--mg-accent': '#ff8a80',
|
'--mg-accent': '#ff8a80',
|
||||||
'--mg-accent-rgb': '255, 138, 128',
|
'--mg-accent-rgb': '255, 138, 128',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#c95a5a',
|
'--mg-primary-light-3': '#c95a5a',
|
||||||
'--mg-primary-light-5': '#d68585',
|
'--mg-primary-light-5': '#d68585',
|
||||||
'--mg-primary-light-7': '#e4afaf',
|
'--mg-primary-light-7': '#e4afaf',
|
||||||
'--mg-primary-light-8': '#edc8c8',
|
'--mg-primary-light-8': '#edc8c8',
|
||||||
'--mg-primary-light-9': '#f7e6e6',
|
'--mg-primary-light-9': '#f7e6e6',
|
||||||
'--mg-primary-dark-2': '#8a0c0c',
|
'--mg-primary-dark-2': '#8a0c0c',
|
||||||
|
|
||||||
'--mg-bg-app-1': '#2a0606',
|
'--mg-bg-app-1': '#2a0606',
|
||||||
'--mg-bg-app-2': '#4a0c0c',
|
'--mg-bg-app-2': '#4a0c0c',
|
||||||
'--mg-bg-app-3': '#150303',
|
'--mg-bg-app-3': '#150303',
|
||||||
'--mg-bg-app-deep-rgb': '20, 4, 4',
|
'--mg-bg-app-deep-rgb': '20, 4, 4',
|
||||||
|
|
||||||
'--mg-bg-aside-1': '#200505',
|
'--mg-bg-aside-1': '#200505',
|
||||||
'--mg-bg-aside-2': '#0c0202',
|
'--mg-bg-aside-2': '#0c0202',
|
||||||
'--mg-bg-aside-rgb': '36, 6, 6',
|
'--mg-bg-aside-rgb': '36, 6, 6',
|
||||||
|
|
||||||
'--mg-bg-card-rgb': '60, 10, 10',
|
'--mg-bg-card-rgb': '60, 10, 10',
|
||||||
'--mg-bg-card-hi-rgb': '80, 14, 14',
|
'--mg-bg-card-hi-rgb': '80, 14, 14',
|
||||||
'--mg-bg-card-darker-rgb': '34, 5, 5',
|
'--mg-bg-card-darker-rgb': '34, 5, 5',
|
||||||
'--mg-shadow-rgb': '10, 1, 1',
|
'--mg-shadow-rgb': '10, 1, 1',
|
||||||
|
|
||||||
'--mg-text-tint-rgb': '252, 220, 220',
|
'--mg-text-tint-rgb': '252, 220, 220',
|
||||||
'--mg-text-hi-rgb': '240, 175, 175'
|
'--mg-text-hi-rgb': '240, 175, 175'
|
||||||
}
|
}
|
||||||
@@ -242,9 +341,10 @@ export const THEMES: ThemePreset[] = [
|
|||||||
{
|
{
|
||||||
id: 'graphite-steel',
|
id: 'graphite-steel',
|
||||||
name: '石墨钢',
|
name: '石墨钢',
|
||||||
description: '中性冷灰,纯粹工业',
|
description: '中性冷灰(兼容)',
|
||||||
preview: '#455a64',
|
preview: '#455a64',
|
||||||
previewAccent: '#b0bec5',
|
previewAccent: '#b0bec5',
|
||||||
|
outpostShell: false,
|
||||||
vars: {
|
vars: {
|
||||||
'--mg-primary': '#455a64',
|
'--mg-primary': '#455a64',
|
||||||
'--mg-primary-rgb': '69, 90, 100',
|
'--mg-primary-rgb': '69, 90, 100',
|
||||||
@@ -253,28 +353,23 @@ export const THEMES: ThemePreset[] = [
|
|||||||
'--mg-primary-active': '#263238',
|
'--mg-primary-active': '#263238',
|
||||||
'--mg-accent': '#b0bec5',
|
'--mg-accent': '#b0bec5',
|
||||||
'--mg-accent-rgb': '176, 190, 197',
|
'--mg-accent-rgb': '176, 190, 197',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#7a8a92',
|
'--mg-primary-light-3': '#7a8a92',
|
||||||
'--mg-primary-light-5': '#9aa6ac',
|
'--mg-primary-light-5': '#9aa6ac',
|
||||||
'--mg-primary-light-7': '#bac2c7',
|
'--mg-primary-light-7': '#bac2c7',
|
||||||
'--mg-primary-light-8': '#ccd2d6',
|
'--mg-primary-light-8': '#ccd2d6',
|
||||||
'--mg-primary-light-9': '#e8ebed',
|
'--mg-primary-light-9': '#e8ebed',
|
||||||
'--mg-primary-dark-2': '#263238',
|
'--mg-primary-dark-2': '#263238',
|
||||||
|
|
||||||
'--mg-bg-app-1': '#161e24',
|
'--mg-bg-app-1': '#161e24',
|
||||||
'--mg-bg-app-2': '#243038',
|
'--mg-bg-app-2': '#243038',
|
||||||
'--mg-bg-app-3': '#0a0e12',
|
'--mg-bg-app-3': '#0a0e12',
|
||||||
'--mg-bg-app-deep-rgb': '10, 14, 18',
|
'--mg-bg-app-deep-rgb': '10, 14, 18',
|
||||||
|
|
||||||
'--mg-bg-aside-1': '#11181d',
|
'--mg-bg-aside-1': '#11181d',
|
||||||
'--mg-bg-aside-2': '#060a0d',
|
'--mg-bg-aside-2': '#060a0d',
|
||||||
'--mg-bg-aside-rgb': '18, 26, 32',
|
'--mg-bg-aside-rgb': '18, 26, 32',
|
||||||
|
|
||||||
'--mg-bg-card-rgb': '30, 42, 50',
|
'--mg-bg-card-rgb': '30, 42, 50',
|
||||||
'--mg-bg-card-hi-rgb': '42, 56, 66',
|
'--mg-bg-card-hi-rgb': '42, 56, 66',
|
||||||
'--mg-bg-card-darker-rgb': '16, 22, 28',
|
'--mg-bg-card-darker-rgb': '16, 22, 28',
|
||||||
'--mg-shadow-rgb': '4, 6, 10',
|
'--mg-shadow-rgb': '4, 6, 10',
|
||||||
|
|
||||||
'--mg-text-tint-rgb': '226, 232, 238',
|
'--mg-text-tint-rgb': '226, 232, 238',
|
||||||
'--mg-text-hi-rgb': '186, 198, 210'
|
'--mg-text-hi-rgb': '186, 198, 210'
|
||||||
}
|
}
|
||||||
@@ -282,9 +377,10 @@ export const THEMES: ThemePreset[] = [
|
|||||||
{
|
{
|
||||||
id: 'amber-forge',
|
id: 'amber-forge',
|
||||||
name: '琥珀金',
|
name: '琥珀金',
|
||||||
description: '高对比的橙金调,警示与活力并存',
|
description: '橙金调(兼容)',
|
||||||
preview: '#e65100',
|
preview: '#e65100',
|
||||||
previewAccent: '#ffb74d',
|
previewAccent: '#ffb74d',
|
||||||
|
outpostShell: false,
|
||||||
vars: {
|
vars: {
|
||||||
'--mg-primary': '#e65100',
|
'--mg-primary': '#e65100',
|
||||||
'--mg-primary-rgb': '230, 81, 0',
|
'--mg-primary-rgb': '230, 81, 0',
|
||||||
@@ -293,44 +389,69 @@ export const THEMES: ThemePreset[] = [
|
|||||||
'--mg-primary-active': '#bf360c',
|
'--mg-primary-active': '#bf360c',
|
||||||
'--mg-accent': '#ffb74d',
|
'--mg-accent': '#ffb74d',
|
||||||
'--mg-accent-rgb': '255, 183, 77',
|
'--mg-accent-rgb': '255, 183, 77',
|
||||||
|
|
||||||
'--mg-primary-light-3': '#ec8344',
|
'--mg-primary-light-3': '#ec8344',
|
||||||
'--mg-primary-light-5': '#f2a474',
|
'--mg-primary-light-5': '#f2a474',
|
||||||
'--mg-primary-light-7': '#f7c4a3',
|
'--mg-primary-light-7': '#f7c4a3',
|
||||||
'--mg-primary-light-8': '#fad6bc',
|
'--mg-primary-light-8': '#fad6bc',
|
||||||
'--mg-primary-light-9': '#fdeadd',
|
'--mg-primary-light-9': '#fdeadd',
|
||||||
'--mg-primary-dark-2': '#bf360c',
|
'--mg-primary-dark-2': '#bf360c',
|
||||||
|
|
||||||
'--mg-bg-app-1': '#2a1404',
|
'--mg-bg-app-1': '#2a1404',
|
||||||
'--mg-bg-app-2': '#4a2a0c',
|
'--mg-bg-app-2': '#4a2a0c',
|
||||||
'--mg-bg-app-3': '#150a02',
|
'--mg-bg-app-3': '#150a02',
|
||||||
'--mg-bg-app-deep-rgb': '20, 10, 4',
|
'--mg-bg-app-deep-rgb': '20, 10, 4',
|
||||||
|
|
||||||
'--mg-bg-aside-1': '#200f04',
|
'--mg-bg-aside-1': '#200f04',
|
||||||
'--mg-bg-aside-2': '#0c0602',
|
'--mg-bg-aside-2': '#0c0602',
|
||||||
'--mg-bg-aside-rgb': '34, 16, 6',
|
'--mg-bg-aside-rgb': '34, 16, 6',
|
||||||
|
|
||||||
'--mg-bg-card-rgb': '62, 32, 8',
|
'--mg-bg-card-rgb': '62, 32, 8',
|
||||||
'--mg-bg-card-hi-rgb': '82, 44, 12',
|
'--mg-bg-card-hi-rgb': '82, 44, 12',
|
||||||
'--mg-bg-card-darker-rgb': '34, 16, 4',
|
'--mg-bg-card-darker-rgb': '34, 16, 4',
|
||||||
'--mg-shadow-rgb': '10, 4, 1',
|
'--mg-shadow-rgb': '10, 4, 1',
|
||||||
|
|
||||||
'--mg-text-tint-rgb': '252, 232, 210',
|
'--mg-text-tint-rgb': '252, 232, 210',
|
||||||
'--mg-text-hi-rgb': '240, 196, 140'
|
'--mg-text-hi-rgb': '240, 196, 140'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function findTheme(id: string | undefined | null): ThemePreset {
|
/** 全量(查找用):精选 + 兼容 */
|
||||||
if (!id) return THEMES[0]
|
export const THEMES: ThemePreset[] = [...PRIMARY_THEMES, ...LEGACY_THEMES]
|
||||||
return THEMES.find((t) => t.id === id) ?? THEMES[0]
|
|
||||||
|
/** 旧 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 {
|
export function applyThemeVars(preset: ThemePreset): void {
|
||||||
const root = document.documentElement
|
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.style.setProperty(key, value)
|
||||||
}
|
}
|
||||||
root.setAttribute('data-theme', preset.id)
|
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
|
lstatus?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 车队健康探测行(GET /sl/projection/fleet/health) */
|
/** 车队健康探测行(优先 GET /api/fleet/health;延迟为 WatchDog TCP RTT) */
|
||||||
export interface FleetHealthRow {
|
export interface FleetHealthRow {
|
||||||
carId: number
|
carId: number
|
||||||
carName?: string
|
carName?: string
|
||||||
@@ -40,6 +40,8 @@ export interface FleetHealthRow {
|
|||||||
isAlarmActive?: boolean
|
isAlarmActive?: boolean
|
||||||
cpuPercent?: number
|
cpuPercent?: number
|
||||||
memPercent?: number
|
memPercent?: number
|
||||||
|
/** watchdog | onboard | none */
|
||||||
|
latencySource?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 车辆运维卡片合并模型 */
|
/** 车辆运维卡片合并模型 */
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */
|
/** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */
|
||||||
export interface DeliveryTask {
|
export interface DeliveryTask {
|
||||||
id: number
|
/** 内部任务号(StandardScene 为 Base62 雪花串) */
|
||||||
|
id: string
|
||||||
|
/** 外部系统单号(可空) */
|
||||||
|
taskId?: string | null
|
||||||
missionId: number
|
missionId: number
|
||||||
missionName: string
|
missionName: string
|
||||||
missionTypeName: string
|
missionTypeName: string
|
||||||
@@ -14,7 +17,25 @@ export interface DeliveryTask {
|
|||||||
carName?: string | null
|
carName?: string | null
|
||||||
priority: number
|
priority: number
|
||||||
createTime?: string | null
|
createTime?: string | null
|
||||||
|
startTime?: string | null
|
||||||
|
finishTime?: string | null
|
||||||
|
stuckReason?: string | null
|
||||||
overdue?: boolean
|
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-overlay" />
|
||||||
<div class="bg-orb bg-orb-a" />
|
<div class="bg-orb bg-orb-a" />
|
||||||
<div class="bg-orb bg-orb-b" />
|
<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">
|
<div class="login-card">
|
||||||
<!-- 左侧品牌 hero -->
|
<!-- 左侧品牌 hero -->
|
||||||
<div class="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">
|
<div class="hero-brand">
|
||||||
<span class="hero-mark">
|
<span class="hero-mark">
|
||||||
<img src="/FRLD-logo-white-no_title.png" alt="迷毂" class="hero-mark-img" />
|
<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())
|
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() {
|
async function submit() {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (ok) => {
|
await formRef.value.validate(async (ok) => {
|
||||||
@@ -269,13 +243,8 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ════════════════════════════════════════════════════════════════════════
|
/* Outpost 登录:纸白主区 + plum 品牌柱(对齐 fairylandtech.amerc.ai) */
|
||||||
* 登录页固定配色(不随全局主题切换变化)。
|
|
||||||
* 统一用一组本地 --lg-* 变量(午夜靛蓝 + 青色霓虹),保证无论用户切到哪个
|
|
||||||
* 主题,登录界面始终是同一套高级、克制的深色质感。
|
|
||||||
* ════════════════════════════════════════════════════════════════════════ */
|
|
||||||
.login-page {
|
.login-page {
|
||||||
/* 梦幻晨曦渐变:粉紫(左上) → 蓝紫(中) → 淡蓝(右下),高明度柔光,参考产品视觉稿。 */
|
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -283,11 +252,11 @@ async function submit() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 75% 55% at 16% 14%, #dcbef7 0%, transparent 55%),
|
radial-gradient(ellipse 70% 50% at 15% 10%, rgba(117, 67, 232, 0.14), transparent 55%),
|
||||||
radial-gradient(ellipse 80% 65% at 86% 90%, #abc9f5 0%, transparent 58%),
|
radial-gradient(ellipse 60% 45% at 90% 85%, rgba(155, 124, 255, 0.1), transparent 50%),
|
||||||
linear-gradient(135deg, #cbb2f0 0%, #b7adec 46%, #a7c6f2 100%);
|
#f6f3fb;
|
||||||
color: var(--lg-text);
|
color: #28213a;
|
||||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
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;
|
animation: drift 20s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.bg-orb-a {
|
.bg-orb-a {
|
||||||
width: 460px; height: 460px;
|
width: 420px; height: 420px;
|
||||||
top: -150px; left: -130px;
|
top: -140px; left: -100px;
|
||||||
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 52%, transparent 80%);
|
background: radial-gradient(circle, rgba(155, 124, 255, 0.55) 0%, rgba(117, 67, 232, 0.35) 55%, transparent 80%);
|
||||||
opacity: 0.24;
|
opacity: 0.22;
|
||||||
}
|
}
|
||||||
.bg-orb-b {
|
.bg-orb-b {
|
||||||
width: 600px; height: 600px;
|
width: 480px; height: 480px;
|
||||||
bottom: -220px; right: -190px;
|
bottom: -180px; right: -140px;
|
||||||
background: radial-gradient(circle, var(--lg-accent) 0%, var(--lg-primary-deep) 58%, transparent 86%);
|
background: radial-gradient(circle, rgba(117, 67, 232, 0.4) 0%, rgba(155, 124, 255, 0.2) 55%, transparent 85%);
|
||||||
opacity: 0.18;
|
opacity: 0.16;
|
||||||
animation-delay: -7s;
|
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 {
|
@keyframes drift {
|
||||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
50% { transform: translate(18px, -28px) scale(1.05); }
|
50% { transform: translate(18px, -28px) scale(1.05); }
|
||||||
@@ -368,7 +330,7 @@ async function submit() {
|
|||||||
50% { opacity: 0.95; transform: scale(1.2); }
|
50% { opacity: 0.95; transform: scale(1.2); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 卡片:双栏 hero ===== */
|
/* ===== 卡片:Outpost 纸白 + plum 品牌柱 ===== */
|
||||||
.login-card {
|
.login-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@@ -378,34 +340,19 @@ async function submit() {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 360px 1fr;
|
grid-template-columns: 360px 1fr;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 22px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(40, 33, 58, 0.08);
|
||||||
/* 登录框本体:紫 → 靛蓝的「紫蓝混合」渐变,梦幻且不发灰;高不透明度保证文字清晰 */
|
background: #fffefd;
|
||||||
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%);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 44px 110px rgba(0, 0, 0, 0.62),
|
0 1px 2px rgba(40, 33, 58, 0.04),
|
||||||
0 0 70px rgba(var(--lg-primary-rgb), 0.24),
|
0 24px 56px rgba(54, 35, 78, 0.12);
|
||||||
0 0 0 1px rgba(var(--lg-accent-rgb), 0.16) inset,
|
|
||||||
0 1px 0 rgba(255, 255, 255, 0.18) inset;
|
|
||||||
}
|
}
|
||||||
/* 顶部霓虹高光线 */
|
|
||||||
.login-card::before {
|
.login-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 12%; right: 12%;
|
top: 0; left: 12%; right: 12%;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(90deg, transparent 0%, rgba(117, 67, 232, 0.35) 50%, transparent 100%);
|
||||||
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%);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
@@ -415,12 +362,8 @@ async function submit() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding: 44px 34px 34px;
|
padding: 44px 34px 34px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
/* 品牌区紫蓝混合:左上紫光球 + 右下蓝光球,叠在深紫底上 */
|
background: linear-gradient(165deg, #3f2454 0%, #241530 100%);
|
||||||
background:
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -550,56 +493,53 @@ async function submit() {
|
|||||||
padding: 44px 44px 32px;
|
padding: 44px 44px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* 表单侧:紫 → 蓝的洗光,与品牌区一致的「紫蓝混合」基调 */
|
background: #fffefd;
|
||||||
background:
|
color: #28213a;
|
||||||
linear-gradient(135deg, rgba(123, 77, 255, 0.14) 0%, rgba(94, 124, 255, 0.10) 100%);
|
|
||||||
}
|
}
|
||||||
.panel-head { margin-bottom: 26px; }
|
.panel-head { margin-bottom: 26px; }
|
||||||
.panel-title {
|
.panel-title {
|
||||||
font-size: 30px; font-weight: 700;
|
font-size: 28px; font-weight: 700;
|
||||||
letter-spacing: 12px;
|
letter-spacing: 8px;
|
||||||
background: linear-gradient(135deg, #ffffff 0%, var(--lg-accent) 100%);
|
color: #28213a;
|
||||||
-webkit-background-clip: text;
|
background: none;
|
||||||
background-clip: text;
|
-webkit-text-fill-color: unset;
|
||||||
color: transparent;
|
text-shadow: none;
|
||||||
text-shadow: 0 0 22px rgba(var(--lg-primary-hover-rgb), 0.5);
|
|
||||||
}
|
}
|
||||||
.panel-sub {
|
.panel-sub {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--lg-text-dim);
|
color: #756d85;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-form { flex: 1; display: flex; flex-direction: column; }
|
.glass-form { flex: 1; display: flex; flex-direction: column; }
|
||||||
.glass-form :deep(.el-form-item) { margin-bottom: 18px; }
|
.glass-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||||
|
|
||||||
/* 输入框 透明玻璃 */
|
|
||||||
.glass-form :deep(.el-input__wrapper) {
|
.glass-form :deep(.el-input__wrapper) {
|
||||||
background: rgba(255, 255, 255, 0.06) !important;
|
background: #f6f3fb !important;
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset !important;
|
box-shadow: 0 0 0 1px rgba(40, 33, 58, 0.1) inset !important;
|
||||||
border-radius: 10px !important;
|
border-radius: 12px !important;
|
||||||
transition: all .25s;
|
transition: all .2s;
|
||||||
}
|
}
|
||||||
.glass-form :deep(.el-input__wrapper:hover) {
|
.glass-form :deep(.el-input__wrapper:hover) {
|
||||||
background: rgba(255, 255, 255, 0.11) !important;
|
background: #fbf9fd !important;
|
||||||
box-shadow: 0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
|
box-shadow: 0 0 0 1px rgba(117, 67, 232, 0.35) inset !important;
|
||||||
}
|
}
|
||||||
.glass-form :deep(.el-input__wrapper.is-focus) {
|
.glass-form :deep(.el-input__wrapper.is-focus) {
|
||||||
background: rgba(255, 255, 255, 0.13) !important;
|
background: #fff !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(var(--lg-accent-rgb), 0.85) inset,
|
0 0 0 1px rgba(117, 67, 232, 0.55) inset,
|
||||||
0 0 20px rgba(var(--lg-primary-hover-rgb), 0.45) !important;
|
0 0 0 3px rgba(117, 67, 232, 0.12) !important;
|
||||||
}
|
}
|
||||||
.glass-form :deep(.el-input__inner) {
|
.glass-form :deep(.el-input__inner) {
|
||||||
color: #fff !important;
|
color: #28213a !important;
|
||||||
-webkit-text-fill-color: #fff !important;
|
-webkit-text-fill-color: #28213a !important;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
height: 42px;
|
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__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 双卡 */
|
||||||
.scope-item :deep(.el-form-item__content) { width: 100%; }
|
.scope-item :deep(.el-form-item__content) { width: 100%; }
|
||||||
@@ -611,45 +551,42 @@ async function submit() {
|
|||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--lg-text-soft);
|
color: #756d85;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
.launch-mode-title { font-weight: 500; }
|
.launch-mode-title { font-weight: 500; }
|
||||||
.launch-mode-help {
|
.launch-mode-help {
|
||||||
color: var(--lg-text-dim);
|
color: #a8a0c0;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.launch-mode-help:hover { color: var(--lg-accent); }
|
.launch-mode-help:hover { color: #7543e8; }
|
||||||
.launch-wrap { margin-bottom: 4px; }
|
.launch-wrap { margin-bottom: 4px; }
|
||||||
.launch-tip { max-width: 280px; line-height: 1.6; font-size: 12px; }
|
.launch-tip { max-width: 280px; line-height: 1.6; font-size: 12px; }
|
||||||
.launch-tip > div + div { margin-top: 6px; }
|
.launch-tip > div + div { margin-top: 6px; }
|
||||||
.scope-tab {
|
.scope-tab {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: #f6f3fb;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(40, 33, 58, 0.1);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 14px;
|
padding: 14px 14px;
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
color: rgba(255, 255, 255, 0.78);
|
color: #28213a;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all .2s;
|
transition: all .2s;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.scope-tab:hover {
|
.scope-tab:hover {
|
||||||
background: rgba(255, 255, 255, 0.11);
|
background: #fbf9fd;
|
||||||
color: #fff;
|
border-color: rgba(117, 67, 232, 0.3);
|
||||||
|
color: #28213a;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.scope-tab.active {
|
.scope-tab.active {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(135deg, #7543e8 0%, #9b7cff 100%);
|
||||||
rgba(var(--lg-primary-rgb), 0.55) 0%,
|
border-color: transparent;
|
||||||
rgba(var(--lg-accent-rgb), 0.28) 100%);
|
|
||||||
border-color: rgba(var(--lg-accent-rgb), 0.75);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow:
|
box-shadow: 0 8px 20px rgba(117, 67, 232, 0.28);
|
||||||
0 0 22px rgba(var(--lg-primary-hover-rgb), 0.45),
|
|
||||||
0 0 0 1px rgba(var(--lg-accent-rgb), 0.45) inset;
|
|
||||||
}
|
}
|
||||||
.scope-tab .el-icon { font-size: 20px; }
|
.scope-tab .el-icon { font-size: 20px; }
|
||||||
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
|
.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 { display: flex; align-items: center; }
|
||||||
.row-between { justify-content: space-between; margin: -6px 0 10px; }
|
.row-between { justify-content: space-between; margin: -6px 0 10px; }
|
||||||
.remember :deep(.el-checkbox__label) {
|
.remember :deep(.el-checkbox__label) {
|
||||||
color: rgba(255, 255, 255, 0.9) !important;
|
color: #28213a !important;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
text-shadow: 0 1px 2px rgba(6, 9, 18, 0.4);
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
.remember :deep(.el-checkbox__inner) {
|
.remember :deep(.el-checkbox__inner) {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.32);
|
border-color: rgba(40, 33, 58, 0.25);
|
||||||
}
|
}
|
||||||
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
|
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
|
||||||
background: var(--lg-primary);
|
background: #7543e8;
|
||||||
border-color: var(--lg-primary);
|
border-color: #7543e8;
|
||||||
}
|
}
|
||||||
.adv-link { color: var(--lg-text-soft) !important; font-size: 12.5px; }
|
.adv-link { color: #756d85 !important; font-size: 12.5px; }
|
||||||
.adv-link:hover { color: #fff !important; }
|
.adv-link:hover { color: #7543e8 !important; }
|
||||||
|
|
||||||
/* 高级折叠 */
|
/* 高级折叠 */
|
||||||
.adv-collapse { background: transparent !important; border: none !important; margin-bottom: 16px; }
|
.adv-collapse { background: transparent !important; border: none !important; margin-bottom: 16px; }
|
||||||
.adv-collapse :deep(.el-collapse-item__wrap),
|
.adv-collapse :deep(.el-collapse-item__wrap),
|
||||||
.adv-collapse :deep(.el-collapse-item__header) {
|
.adv-collapse :deep(.el-collapse-item__header) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border-color: rgba(255, 255, 255, 0.14) !important;
|
border-color: rgba(40, 33, 58, 0.1) !important;
|
||||||
color: var(--lg-text-soft) !important;
|
color: #756d85 !important;
|
||||||
}
|
}
|
||||||
.adv-title { font-size: 12px; letter-spacing: 1px; }
|
.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 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; padding-top: 6px; }
|
||||||
.adv-grid :deep(.el-form-item__label) {
|
.adv-grid :deep(.el-form-item__label) {
|
||||||
color: var(--lg-text-soft);
|
color: #756d85;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.adv-grid :deep(.el-input-number) { width: 100%; }
|
.adv-grid :deep(.el-input-number) { width: 100%; }
|
||||||
.adv-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
|
.adv-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
|
||||||
|
|
||||||
/* 登录按钮:靛蓝→青 霓虹 · 扫光动效 */
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 46px !important;
|
height: 46px !important;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
letter-spacing: 6px;
|
letter-spacing: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
background: linear-gradient(135deg, var(--lg-primary) 0%, var(--lg-primary-hover) 100%) !important;
|
background: #7543e8 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
box-shadow:
|
box-shadow: 0 10px 24px rgba(117, 67, 232, 0.28) !important;
|
||||||
0 12px 28px rgba(var(--lg-primary-rgb), 0.5),
|
transition: all .2s ease;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
.btn-login::after { display: none; }
|
||||||
.btn-login:hover {
|
.btn-login:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
background: linear-gradient(135deg, var(--lg-primary-hover) 0%, var(--lg-accent) 130%) !important;
|
background: #8660f0 !important;
|
||||||
box-shadow:
|
box-shadow: 0 14px 28px rgba(117, 67, 232, 0.35) !important;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.btn-login:hover::after { left: 120%; }
|
|
||||||
|
|
||||||
.bottom-note {
|
.bottom-note {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: var(--lg-text-dim);
|
color: #756d85;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部水印 */
|
|
||||||
.footer-stamp {
|
.footer-stamp {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 18px; left: 50%;
|
bottom: 18px; left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
color: rgba(45, 32, 78, 0.6);
|
color: rgba(40, 33, 58, 0.45);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 错误提示 */
|
|
||||||
.glass-form :deep(.el-form-item__error) {
|
.glass-form :deep(.el-form-item__error) {
|
||||||
color: #ff9bb8;
|
color: #c23a5c;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 小屏自适应 */
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.login-card { grid-template-columns: 1fr; min-height: auto; }
|
.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; }
|
.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" />
|
<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">
|
<div class="status-actions">
|
||||||
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
||||||
<el-button @click="back">返回</el-button>
|
<el-button @click="back">返回</el-button>
|
||||||
@@ -54,34 +76,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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'
|
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 router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const actionLoading = ref<'stop' | 'restart' | null>(null)
|
||||||
const health = ref<HealthInfo | null>(null)
|
const health = ref<HealthInfo | null>(null)
|
||||||
const sl = ref<SimpleLiteDiagnostics | null>(null)
|
const sl = ref<SimpleLiteDiagnostics | null>(null)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
const canControlSimpleLite = computed(() => auth.token && auth.scope === 'Platform')
|
||||||
|
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
|
|
||||||
const serverStartTime = computed(() =>
|
const serverStartTime = computed(() =>
|
||||||
@@ -117,11 +134,10 @@ async function refresh() {
|
|||||||
try {
|
try {
|
||||||
// /status 是 public 页:未登录只拉匿名 /health,不调需登录的 simplelite 诊断
|
// /status 是 public 页:未登录只拉匿名 /health,不调需登录的 simplelite 诊断
|
||||||
// (401 会触发全局拦截器强制跳转登录页)。
|
// (401 会触发全局拦截器强制跳转登录页)。
|
||||||
const requests: [Promise<{ data: HealthInfo }>, Promise<{ data: SimpleLiteDiagnostics }> | null] = [
|
const [h, d] = await Promise.allSettled([
|
||||||
http.get<HealthInfo>('/health'),
|
getHealth(),
|
||||||
auth.token ? http.get<SimpleLiteDiagnostics>('/health/simplelite') : null
|
auth.token ? getSimpleLiteDiagnostics() : Promise.reject(new Error('skipped'))
|
||||||
]
|
])
|
||||||
const [h, d] = await Promise.allSettled([requests[0], requests[1] ?? Promise.reject(new Error('skipped'))])
|
|
||||||
health.value = h.status === 'fulfilled' ? h.value.data : null
|
health.value = h.status === 'fulfilled' ? h.value.data : null
|
||||||
sl.value = d.status === 'fulfilled' ? d.value.data : null
|
sl.value = d.status === 'fulfilled' ? d.value.data : null
|
||||||
if (h.status === 'rejected') error.value = '无法连接 MiGu.Server(/api/health)'
|
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(() => {
|
onMounted(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
// 10s 轮询:服务端对 goto-site 探测有 60s 缓存,此频率不会对 SimpleLite 产生压力。
|
// 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-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||||
.status-card { width: 720px; }
|
.status-card { width: 720px; }
|
||||||
.status-block { margin-top: 16px; }
|
.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; }
|
.status-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
||||||
</style>
|
</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>
|
<template>
|
||||||
<div class="car-page">
|
<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">
|
<el-tab-pane label="车辆列表" name="list">
|
||||||
<ReflectionManagerPanel
|
<ReflectionManagerPanel
|
||||||
v-if="tab === 'list'"
|
v-if="tab === 'list'"
|
||||||
kind="car"
|
kind="car"
|
||||||
kind-label="车辆"
|
kind-label="车辆"
|
||||||
title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
|
title="车辆管理"
|
||||||
empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。"
|
empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。"
|
||||||
enable-project-save
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="车型样式 / 报警颜色" name="style">
|
<el-tab-pane label="车型样式" name="style">
|
||||||
<CarStyleEditor v-if="tab === 'style'" />
|
<CarStyleEditor v-if="tab === 'style'" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -39,10 +39,11 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.car-page {
|
.car-page {
|
||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
padding: 12px 14px;
|
padding: 10px 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.car-tabs {
|
.car-tabs {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1740,8 +1740,14 @@ onUnmounted(() => {
|
|||||||
.map-editor-page {
|
.map-editor-page {
|
||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
background: radial-gradient(ellipse at 50% 40%, #2d1456 0%, #1a0930 50%, #0f0420 100%);
|
/* 画布区保持深色;色相跟当前主题侧栏 (--me-chrome-*) */
|
||||||
color: rgba(232,215,245,0.9);
|
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 {
|
.me-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1767,13 +1773,13 @@ onUnmounted(() => {
|
|||||||
border-radius: 12px 0 0 12px;
|
border-radius: 12px 0 0 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(135deg, rgba(150, 90, 240, 0.96) 0%, rgba(255, 90, 200, 0.92) 100%);
|
background: var(--mg-primary);
|
||||||
box-shadow: -4px 0 16px rgba(120, 40, 200, 0.45);
|
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;
|
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 {
|
.ai-assistant-fab:hover {
|
||||||
filter: brightness(1.08);
|
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);此处仅作兜底。 */
|
/* 展开时 right 偏移由内联样式按面板实际宽度设置(默认 360);此处仅作兜底。 */
|
||||||
.ai-assistant-fab.is-open {
|
.ai-assistant-fab.is-open {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
<ReflectionManagerPanel
|
<ReflectionManagerPanel
|
||||||
kind="process"
|
kind="process"
|
||||||
kind-label="进程"
|
kind-label="进程"
|
||||||
title="进程管理(Mission / Process)"
|
title="进程管理"
|
||||||
empty-text="当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"
|
empty-text="当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"
|
||||||
show-status-column
|
show-status-column
|
||||||
status-label="状态"
|
status-label="状态"
|
||||||
@@ -20,7 +20,8 @@ import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPan
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.process-page {
|
.process-page {
|
||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
padding: 12px 14px;
|
padding: 10px 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="scene-mgr-page">
|
<div class="scene-mgr-page ops-console-page">
|
||||||
<el-tabs v-model="activeTab" type="border-card" class="scene-tabs admin-tabs" @tab-change="onTabChange">
|
<el-tabs v-model="activeTab" class="scene-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
|
||||||
<el-tab-pane label="站点" name="site">
|
<el-tab-pane label="站点" name="site">
|
||||||
<ReflectionManagerPanel
|
<ReflectionManagerPanel
|
||||||
ref="sitePanelRef"
|
ref="sitePanelRef"
|
||||||
kind="site"
|
kind="site"
|
||||||
kind-label="站点"
|
kind-label="站点"
|
||||||
title="站点管理(Site / UISite,含禁用/启用、必空点等动作)"
|
title="站点管理"
|
||||||
empty-text="当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"
|
empty-text="当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"
|
||||||
enable-project-save
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
ref="trackPanelRef"
|
ref="trackPanelRef"
|
||||||
kind="track"
|
kind="track"
|
||||||
kind-label="路径"
|
kind-label="路径"
|
||||||
title="路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)"
|
title="路径管理"
|
||||||
empty-text="当前没有路径;点击右上角「新建路径」选起止站点添加。"
|
empty-text="当前没有路径;点击右上角「新建路径」选起止站点添加。"
|
||||||
enable-project-save
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
ref="specialPanelRef"
|
ref="specialPanelRef"
|
||||||
kind="special"
|
kind="special"
|
||||||
kind-label="装饰物"
|
kind-label="装饰物"
|
||||||
title="装饰物管理(UI_Image / UI_Text / UI_Model)"
|
title="装饰物管理"
|
||||||
empty-text="当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"
|
empty-text="当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"
|
||||||
enable-project-save
|
enable-project-save
|
||||||
/>
|
/>
|
||||||
@@ -37,14 +37,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 场景管理:用 3 个 ReflectionManagerPanel 实例承载 site/track/special 三个 subKind。
|
* 场景管理:3 个 ReflectionManagerPanel 分别承载 site/track/special。
|
||||||
*
|
* Style A:外层 underline tabs + 紧凑运维控制台壳。
|
||||||
* 这里之所以用三个独立 panel 而不是单一 panel + 顶部切换:
|
|
||||||
* - 每个 subKind 列字段、可创建子类型完全不同(site 要 x/y、track 要 siteA/siteB、
|
|
||||||
* 装饰物要 imagePath/modelPath 等),共享一组 ref 会让创建表单状态错乱;
|
|
||||||
* - ReflectionManagerPanel 已经把 list/详情/方法/字段/SSE 全部包好,三份实例几乎零边际成本。
|
|
||||||
*
|
|
||||||
* SceneManagerView 自己只负责 tab 切换 + 默认走 site tab。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
@@ -57,7 +51,6 @@ const trackPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(nu
|
|||||||
const specialPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
|
const specialPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
|
||||||
|
|
||||||
function onTabChange(name: string | number) {
|
function onTabChange(name: string | number) {
|
||||||
// 切到对应 tab 时主动 refresh 一次,确保 SSE 没接的情况下也能拿到最新列表。
|
|
||||||
const target =
|
const target =
|
||||||
name === 'site' ? sitePanelRef.value :
|
name === 'site' ? sitePanelRef.value :
|
||||||
name === 'track' ? trackPanelRef.value :
|
name === 'track' ? trackPanelRef.value :
|
||||||
@@ -67,19 +60,19 @@ function onTabChange(name: string | number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 会话 N+2:tabs 通用样式提到全局 `.admin-tabs`(见 styles/theme.css),
|
|
||||||
这里只保留容器布局,避免 scoped 选择器把全局规则压住。 */
|
|
||||||
.scene-mgr-page {
|
.scene-mgr-page {
|
||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
padding: 12px 14px;
|
padding: 10px 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.scene-tabs {
|
.scene-tabs {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<ReflectionManagerPanel
|
<ReflectionManagerPanel
|
||||||
kind="script"
|
kind="script"
|
||||||
kind-label="脚本"
|
kind-label="脚本"
|
||||||
title="脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
|
title="脚本管理"
|
||||||
empty-text="当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,请在 SimpleLite 工作台新建 Mission(任务)后由调度运行时自动生成。"
|
empty-text="当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,请在 SimpleLite 工作台新建 Mission(任务)后由调度运行时自动生成。"
|
||||||
disable-create
|
disable-create
|
||||||
disable-delete
|
disable-delete
|
||||||
@@ -31,7 +31,8 @@ import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPan
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.script-page {
|
.script-page {
|
||||||
height: calc(100vh - 56px);
|
height: calc(100vh - 56px);
|
||||||
padding: 12px 14px;
|
padding: 10px 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user