merge
This commit is contained in:
@@ -40,6 +40,7 @@ public static class PageCatalog
|
|||||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||||
|
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ public sealed class RbacStore
|
|||||||
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
|
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
|
||||||
&& hasProcessAndScript)
|
&& hasProcessAndScript)
|
||||||
r.Pages.Add("admin-task-templates");
|
r.Pages.Add("admin-task-templates");
|
||||||
|
|
||||||
|
// Simple 字段管理:与任务编排同属设计与编排,有任务编排权限时自动补齐。
|
||||||
|
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||||
|
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||||
|
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||||
|
r.Pages.Add("admin-simple-fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||||
@@ -206,6 +212,15 @@ public sealed class RbacStore
|
|||||||
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
|
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RbacUser? FindUserById(string id)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id);
|
||||||
|
return u is null ? null : Clone(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope,<c>*</c> 角色覆盖全部)。</summary>
|
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope,<c>*</c> 角色覆盖全部)。</summary>
|
||||||
public List<string> UsableScopes(RbacUser user)
|
public List<string> UsableScopes(RbacUser user)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ public static class DeploymentCatalog
|
|||||||
new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"),
|
new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。</summary>
|
/// <summary>
|
||||||
|
/// 功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。
|
||||||
|
/// 注意:Key 必须是 PageCatalog 当前有效 Key(不能用 LegacyKeyAliases 里的旧名,否则裁剪悄然失效)。
|
||||||
|
/// </summary>
|
||||||
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
|
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
|
||||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["wms"] = new[] { "admin-config-warehouse" },
|
["wms"] = new[] { "admin-config-facility" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
||||||
|
|||||||
@@ -35,15 +35,17 @@ public record DeploymentProfile(
|
|||||||
UpdatedBy: "");
|
UpdatedBy: "");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||||
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
||||||
|
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>;
|
||||||
|
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly IReadOnlyDictionary<string, string> NavKindToSceneId =
|
public static readonly IReadOnlyDictionary<string, string> NavKindToSceneId =
|
||||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["magnetic"] = "scene.magnetic",
|
["magnetic"] = "scene.mag",
|
||||||
["qrcode"] = "scene.qrcode",
|
["qrcode"] = "scene.qrlidar",
|
||||||
["laser"] = "scene.laser",
|
["laser"] = "scene.qrlidar",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using MiGu.Server.Configs;
|
|||||||
namespace MiGu.Server.Controllers;
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
|
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
|
||||||
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
|
// GET (List/Get) 只要登录就放;PUT 按 scope 收紧:Platform 任意节,RCSMonitor 仅 ops 白名单。
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/config")]
|
[Route("api/config")]
|
||||||
@@ -47,15 +47,24 @@ public class ConfigController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
|
/// <summary>RCSMonitor scope 允许写入的 section 白名单(地图监控动作备份等运营自有配置)。</summary>
|
||||||
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
|
private static readonly string[] MonitorWritableSections = { "ops" };
|
||||||
|
|
||||||
|
// Platform scope 可写任意 section;RCSMonitor 仅允许写 ops(保留运营端
|
||||||
|
// 「地图监控动作 ops.monitor 备份」既有功能),其余 section(routing/auth/system 等)一律 403。
|
||||||
[HttpPut("{section}")]
|
[HttpPut("{section}")]
|
||||||
[Authorize]
|
|
||||||
public IActionResult Put(string section, [FromBody] JsonElement payload)
|
public IActionResult Put(string section, [FromBody] JsonElement payload)
|
||||||
{
|
{
|
||||||
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||||
return NotFound(new { message = $"未知 section: {section}" });
|
return NotFound(new { message = $"未知 section: {section}" });
|
||||||
|
|
||||||
|
var scope = User.FindFirst("scope")?.Value;
|
||||||
|
if (!string.Equals(scope, "Platform", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !MonitorWritableSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return StatusCode(403, new { message = $"当前账号无权修改配置节 {section}(需要 Platform 管理端权限)" });
|
||||||
|
}
|
||||||
|
|
||||||
var env = _store.Put(section, payload);
|
var env = _store.Put(section, payload);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,39 +13,33 @@ public class HealthController : ControllerBase
|
|||||||
|
|
||||||
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||||
|
|
||||||
|
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IActionResult Get()
|
public IActionResult Get()
|
||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
status = "ok",
|
status = "ok",
|
||||||
mode = "WebEnabled",
|
|
||||||
startTime = StartTime,
|
startTime = StartTime,
|
||||||
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
|
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds
|
||||||
ports = new
|
|
||||||
{
|
|
||||||
webApi = 7001,
|
|
||||||
webSocket = 7002,
|
|
||||||
platform = 8080,
|
|
||||||
vrender = 8223,
|
|
||||||
vehicle = 8222
|
|
||||||
},
|
|
||||||
architecture = "v1.5"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
||||||
|
/// 含服务器本地路径等敏感信息,要求登录。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("simplelite")]
|
[HttpGet("simplelite")]
|
||||||
|
[Authorize]
|
||||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||||
|
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("simplelite/restart-for-update")]
|
[HttpPost("simplelite/restart-for-update")]
|
||||||
[Authorize]
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
|
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
|
||||||
{
|
{
|
||||||
var result = _launcher.RestartForUpdate(launchMode);
|
var result = _launcher.RestartForUpdate(launchMode);
|
||||||
|
|||||||
@@ -747,9 +747,9 @@ public sealed class LogsController : ControllerBase
|
|||||||
}
|
}
|
||||||
b.Latest = e.Content;
|
b.Latest = e.Content;
|
||||||
b.LatestTime = e.Time;
|
b.LatestTime = e.Time;
|
||||||
b.Recent.Add(e);
|
b.Recent.Enqueue(e);
|
||||||
// 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆。
|
// 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆(Queue 头部出队 O(1))。
|
||||||
if (b.Recent.Count > maxPerTag) b.Recent.RemoveAt(0);
|
if (b.Recent.Count > maxPerTag) b.Recent.Dequeue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object ToBookDto(Book b) => new
|
private static object ToBookDto(Book b) => new
|
||||||
@@ -831,6 +831,6 @@ public sealed class LogsController : ControllerBase
|
|||||||
public DateTime? LastTime { get; set; }
|
public DateTime? LastTime { get; set; }
|
||||||
public string Latest { get; set; } = "";
|
public string Latest { get; set; } = "";
|
||||||
public DateTime? LatestTime { get; set; }
|
public DateTime? LatestTime { get; set; }
|
||||||
public List<LogEntry> Recent { get; } = new();
|
public Queue<LogEntry> Recent { get; } = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ public class MapsContentController : ControllerBase
|
|||||||
return NotFound(new { message = $"地图文件不存在:{fileName}" });
|
return NotFound(new { message = $"地图文件不存在:{fileName}" });
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(fullPath, ct);
|
var content = await System.IO.File.ReadAllTextAsync(fullPath, ct);
|
||||||
|
// 不返回 fullPath:避免向前端泄露服务器目录结构。
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
fileName,
|
fileName,
|
||||||
path = fullPath,
|
|
||||||
content
|
content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ namespace MiGu.Server.Controllers;
|
|||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>GET /api/wizard/options</c>:可选项目录(导航方式 / 模块 / 业务场景模板)。</item>
|
/// <item><c>GET /api/wizard/options</c>:可选项目录(导航方式 / 模块 / 业务场景模板)。</item>
|
||||||
/// <item><c>GET /api/wizard/profile</c>:回显当前部署画像(含由导航选型推导的激活场景 id)。</item>
|
/// <item><c>GET /api/wizard/profile</c>:回显当前部署画像(含由导航选型推导的激活场景 id)。</item>
|
||||||
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>。</item>
|
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>(仅 Platform scope)。</item>
|
||||||
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(保留草稿)。</item>
|
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(仅 Platform scope)。</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
|
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
|
||||||
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,
|
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,
|
||||||
@@ -64,6 +64,7 @@ public class WizardController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("profile")]
|
[HttpPut("profile")]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public IActionResult SaveProfile([FromBody] SaveWizardRequest req)
|
public IActionResult SaveProfile([FromBody] SaveWizardRequest req)
|
||||||
{
|
{
|
||||||
if (req == null)
|
if (req == null)
|
||||||
@@ -82,7 +83,10 @@ public class WizardController : ControllerBase
|
|||||||
|
|
||||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||||
var sceneIds = profile.ToActiveSceneIds();
|
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||||
|
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
||||||
|
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
||||||
|
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
||||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||||
|
|
||||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||||
@@ -92,6 +96,7 @@ public class WizardController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("reset")]
|
[HttpPost("reset")]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
public IActionResult Reset()
|
public IActionResult Reset()
|
||||||
{
|
{
|
||||||
var reset = _store.GetDeployment() with { Configured = false };
|
var reset = _store.GetDeployment() with { Configured = false };
|
||||||
|
|||||||
@@ -50,13 +50,20 @@ public static class SimpleLiteBuildSync
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
|
||||||
|
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
|
||||||
|
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
|
||||||
|
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
|
||||||
|
/// 检查都会真实派车,属严重副作用,严禁回退到真实 id。
|
||||||
|
/// </summary>
|
||||||
public static bool? ProbeGotoSiteRoute(int port = 8222)
|
public static bool? ProbeGotoSiteRoute(int port = 8222)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||||
using var resp = client.PostAsync(
|
using var resp = client.PostAsync(
|
||||||
$"http://127.0.0.1:{port}/projection/reflection/car/0/goto-site?siteId=0",
|
$"http://127.0.0.1:{port}/projection/reflection/car/-1/goto-site?siteId=-1",
|
||||||
null).GetAwaiter().GetResult();
|
null).GetAwaiter().GetResult();
|
||||||
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
private string BuildArguments(string displayMode, string extra)
|
private string BuildArguments(string displayMode, string extra)
|
||||||
{
|
{
|
||||||
var args = $"--display-mode={displayMode}";
|
var args = $"--display-mode={displayMode}";
|
||||||
|
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
|
||||||
|
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
|
||||||
|
if (_opts.EmbeddedCanvas) args += " --migu";
|
||||||
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
||||||
var sceneArg = ReadActiveScenesArg();
|
var sceneArg = ReadActiveScenesArg();
|
||||||
if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg;
|
if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg;
|
||||||
@@ -392,12 +395,33 @@ public sealed class SimpleLiteLauncher : IDisposable
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool? _gotoSiteProbeCache;
|
||||||
|
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
|
||||||
|
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||||||
public SimpleLiteDiagnostics GetDiagnostics()
|
public SimpleLiteDiagnostics GetDiagnostics()
|
||||||
{
|
{
|
||||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||||
var gotoSite = projectionUp ? SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) : null;
|
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
|
||||||
|
bool? gotoSite = null;
|
||||||
|
if (projectionUp)
|
||||||
|
{
|
||||||
|
if (_gotoSiteProbeCache is bool cached && DateTimeOffset.UtcNow - _gotoSiteProbeAt < GotoSiteProbeTtl)
|
||||||
|
{
|
||||||
|
gotoSite = cached;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
||||||
|
if (gotoSite is not null)
|
||||||
|
{
|
||||||
|
_gotoSiteProbeCache = gotoSite;
|
||||||
|
_gotoSiteProbeAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var deployHint = gotoSite == false
|
var deployHint = gotoSite == false
|
||||||
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
|
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -57,4 +57,15 @@ public sealed class SimpleLiteOptions
|
|||||||
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FollowParent { get; set; } = false;
|
public bool FollowParent { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
|
||||||
|
///
|
||||||
|
/// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel,
|
||||||
|
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
|
||||||
|
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
|
||||||
|
///
|
||||||
|
/// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。
|
||||||
|
/// </summary>
|
||||||
|
public bool EmbeddedCanvas { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
||||||
|
<Content Include="OpenApi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simplelite-projection.json')" />
|
||||||
|
<Content Include="..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json')" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using MiGu.Server.Dashboard;
|
||||||
using MiGu.Server.Wms;
|
using MiGu.Server.Wms;
|
||||||
|
using MiGu.Server.SimpleFields;
|
||||||
|
|
||||||
namespace MiGu.Server.Persistence;
|
namespace MiGu.Server.Persistence;
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
||||||
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
||||||
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
||||||
|
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||||
|
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -55,6 +59,46 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
|
|
||||||
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
|
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
ConfigureSimpleField(modelBuilder);
|
||||||
|
ConfigureUserDashboardShortcut(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
var e = modelBuilder.Entity<UserDashboardShortcut>();
|
||||||
|
e.ToTable("user_dashboard_shortcuts");
|
||||||
|
e.HasKey(x => new { x.UserId, x.Scope });
|
||||||
|
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
|
||||||
|
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
|
||||||
|
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
|
||||||
|
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||||
|
v => v.UtcDateTime.ToString("O"),
|
||||||
|
v => DateTimeOffset.Parse(v));
|
||||||
|
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
var e = modelBuilder.Entity<SimpleField>();
|
||||||
|
e.ToTable("simple_fields");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Id).HasColumnName("id");
|
||||||
|
e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
|
||||||
|
e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
|
||||||
|
e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
|
||||||
|
e.Property(x => x.Value).HasColumnName("value");
|
||||||
|
e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
|
||||||
|
e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
|
||||||
|
e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
|
||||||
|
e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
|
||||||
|
e.Property(x => x.IsDefault).HasColumnName("is_default");
|
||||||
|
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||||
|
v => SimpleFieldDateTime.ToStorage(v),
|
||||||
|
v => SimpleFieldDateTime.FromStorage(v));
|
||||||
|
e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
|
||||||
|
e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
|
||||||
|
e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using MiGu.Server.Wms;
|
using MiGu.Server.Wms;
|
||||||
|
using MiGu.Server.SimpleFields;
|
||||||
|
|
||||||
namespace MiGu.Server.Persistence;
|
namespace MiGu.Server.Persistence;
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ public static class PlatformPersistence
|
|||||||
|
|
||||||
services.AddScoped<WmsReferenceValidator>();
|
services.AddScoped<WmsReferenceValidator>();
|
||||||
services.AddScoped<WmsService>();
|
services.AddScoped<WmsService>();
|
||||||
|
services.AddScoped<SimpleFieldService>();
|
||||||
|
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +50,122 @@ public static class PlatformPersistence
|
|||||||
using var scope = services.CreateScope();
|
using var scope = services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
|
||||||
|
await EnsureSimpleFieldsTableAsync(db);
|
||||||
|
await EnsureUserDashboardShortcutsTableAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
||||||
|
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Database.IsSqlite())
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS simple_fields (
|
||||||
|
id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY,
|
||||||
|
car_type TEXT NOT NULL DEFAULT '',
|
||||||
|
field_type TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
data_type TEXT NOT NULL DEFAULT '',
|
||||||
|
chinese TEXT,
|
||||||
|
english TEXT,
|
||||||
|
other TEXT NOT NULL DEFAULT '',
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
update_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
// 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。
|
||||||
|
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
UPDATE simple_fields SET car_type = other
|
||||||
|
WHERE (car_type IS NULL OR car_type = '') AND other <> '';
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
UPDATE simple_fields SET other = ''
|
||||||
|
WHERE other <> '' AND other = car_type;
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key
|
||||||
|
ON simple_fields (car_type, field_type, "key");
|
||||||
|
""");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。
|
||||||
|
if (!await TableExistsAsync(db, "simple_fields"))
|
||||||
|
{
|
||||||
|
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||||
|
await creator.CreateTablesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
|
||||||
|
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Database.IsSqlite())
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
keys_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
|
||||||
|
{
|
||||||
|
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||||
|
await creator.CreateTablesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查表是否存在
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="db">数据库上下文</param>
|
||||||
|
/// <param name="table">表名</param>
|
||||||
|
/// <returns>表是否存在</returns>
|
||||||
|
private static async Task<bool> TableExistsAsync(PlatformDbContext db, string table)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
await conn.OpenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
if (db.Database.IsSqlServer())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t";
|
||||||
|
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||||
|
}
|
||||||
|
else if (db.Database.IsNpgsql())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t";
|
||||||
|
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||||
|
}
|
||||||
|
else if (db.Database.IsMySql())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t";
|
||||||
|
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var result = await cmd.ExecuteScalarAsync();
|
||||||
|
return result != null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (conn.State == System.Data.ConnectionState.Open)
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
|
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
|
||||||
|
|||||||
+15
-15
@@ -6,6 +6,7 @@ using Microsoft.OpenApi.Models;
|
|||||||
using MiGu.Server.Auth;
|
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.Persistence;
|
using MiGu.Server.Persistence;
|
||||||
using Yarp.ReverseProxy.Transforms;
|
using Yarp.ReverseProxy.Transforms;
|
||||||
|
|
||||||
@@ -98,7 +99,13 @@ builder.Services.AddControllers()
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
{
|
{
|
||||||
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
|
c.SwaggerDoc("v1", new()
|
||||||
|
{
|
||||||
|
Title = "MiGu.Server + SimpleLite",
|
||||||
|
Version = "v1",
|
||||||
|
Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。"
|
||||||
|
});
|
||||||
|
c.DocumentFilter<SimpleLiteOpenApiDocumentFilter>();
|
||||||
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
{
|
{
|
||||||
@@ -150,15 +157,6 @@ builder.Services.AddSingleton<InternalTokenStore>();
|
|||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(opt =>
|
.AddJwtBearer(opt =>
|
||||||
{
|
{
|
||||||
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
|
|
||||||
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
|
||||||
{
|
|
||||||
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
|
|
||||||
ValidateIssuer = false,
|
|
||||||
ValidateAudience = false,
|
|
||||||
ValidateIssuerSigningKey = false,
|
|
||||||
ValidateLifetime = false,
|
|
||||||
};
|
|
||||||
opt.Events = new JwtBearerEvents
|
opt.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
OnMessageReceived = ctx =>
|
OnMessageReceived = ctx =>
|
||||||
@@ -169,13 +167,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
||||||
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
||||||
}
|
}
|
||||||
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
|
|
||||||
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
|
|
||||||
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// TokenValidationParameters 由 JwtIssuer(DI 单例)启动期一次性提供,
|
||||||
|
// 替代旧的「每请求在 OnMessageReceived 里改写共享 Options」写法(并发坏味道)。
|
||||||
|
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.Configure<JwtIssuer>((opt, issuer) => opt.TokenValidationParameters = issuer.BuildValidationParameters());
|
||||||
|
|
||||||
builder.Services.AddAuthorization(opts =>
|
builder.Services.AddAuthorization(opts =>
|
||||||
{
|
{
|
||||||
@@ -200,8 +199,9 @@ builder.Services.AddReverseProxy()
|
|||||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||||
.AddTransforms(tctx =>
|
.AddTransforms(tctx =>
|
||||||
{
|
{
|
||||||
// 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
// 对全部 sl-* 路由(兜底 + map-edit/ai-config/reflection 管理面拆分路由)注入
|
||||||
if (tctx.Route.RouteId != "sl-route") return;
|
// internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||||||
|
if (!tctx.Route.RouteId.StartsWith("sl-", StringComparison.Ordinal)) return;
|
||||||
tctx.AddRequestTransform(rt =>
|
tctx.AddRequestTransform(rt =>
|
||||||
{
|
{
|
||||||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||||
|
|||||||
@@ -44,8 +44,48 @@
|
|||||||
"Dispatch": {
|
"Dispatch": {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。",
|
||||||
"ReverseProxy": {
|
"ReverseProxy": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
|
"sl-mapedit-route": {
|
||||||
|
"ClusterId": "sl-cluster",
|
||||||
|
"AuthorizationPolicy": "PlatformScope",
|
||||||
|
"Order": -2,
|
||||||
|
"Match": { "Path": "/api/sl/projection/map-edit/{**catch-all}" },
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/api/sl" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sl-aiconfig-route": {
|
||||||
|
"ClusterId": "sl-cluster",
|
||||||
|
"AuthorizationPolicy": "PlatformScope",
|
||||||
|
"Order": -2,
|
||||||
|
"Match": { "Path": "/api/sl/projection/ai-config/{**catch-all}" },
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/api/sl" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sl-reflection-selection-route": {
|
||||||
|
"ClusterId": "sl-cluster",
|
||||||
|
"AuthorizationPolicy": "AnyAuthed",
|
||||||
|
"Order": -3,
|
||||||
|
"Match": { "Path": "/api/sl/projection/reflection/selection/{**catch-all}" },
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/api/sl" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sl-reflection-write-route": {
|
||||||
|
"ClusterId": "sl-cluster",
|
||||||
|
"AuthorizationPolicy": "PlatformScope",
|
||||||
|
"Order": -1,
|
||||||
|
"Match": {
|
||||||
|
"Path": "/api/sl/projection/reflection/{**catch-all}",
|
||||||
|
"Methods": [ "POST", "PUT", "PATCH", "DELETE" ]
|
||||||
|
},
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/api/sl" }
|
||||||
|
]
|
||||||
|
},
|
||||||
"sl-route": {
|
"sl-route": {
|
||||||
"ClusterId": "sl-cluster",
|
"ClusterId": "sl-cluster",
|
||||||
"AuthorizationPolicy": "AnyAuthed",
|
"AuthorizationPolicy": "AnyAuthed",
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext .ts,.vue --fix"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
|||||||
@@ -204,11 +204,9 @@ export const mapEditApi = {
|
|||||||
dashboardSummary: () =>
|
dashboardSummary: () =>
|
||||||
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
||||||
|
|
||||||
// 资产上传:传 base64
|
// 资产上传:JSON body 传 base64(后端按 JSON 解析;勿手动设 multipart 头 —— body 并非 multipart)。
|
||||||
uploadAsset: (filename: string, dataBase64: string) =>
|
uploadAsset: (filename: string, dataBase64: string) =>
|
||||||
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 }, {
|
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 })),
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
})),
|
|
||||||
|
|
||||||
// AI 生图
|
// AI 生图
|
||||||
aiMapGenerate: (req: AiMapGenerateRequest) =>
|
aiMapGenerate: (req: AiMapGenerateRequest) =>
|
||||||
@@ -335,7 +333,6 @@ export interface MapSaveResult {
|
|||||||
export interface MapContentResult {
|
export interface MapContentResult {
|
||||||
name: string
|
name: string
|
||||||
fileName: string
|
fileName: string
|
||||||
path: string
|
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +423,7 @@ export const mapsApi = {
|
|||||||
* 当前未设置使用地图(400)/ 场景内有车辆任务(409)时 message 不含「已存在」,conflict=false,调用方直接提示。
|
* 当前未设置使用地图(400)/ 场景内有车辆任务(409)时 message 不含「已存在」,conflict=false,调用方直接提示。
|
||||||
*/
|
*/
|
||||||
async merge(sources: string[], target: string, overwrite = false): Promise<MapMergeOutcome> {
|
async merge(sources: string[], target: string, overwrite = false): Promise<MapMergeOutcome> {
|
||||||
|
try {
|
||||||
const { data } = await http.post<MapEditEnvelope<MapMergeResult>>(`${BASE}/maps/merge`, {
|
const { data } = await http.post<MapEditEnvelope<MapMergeResult>>(`${BASE}/maps/merge`, {
|
||||||
sources,
|
sources,
|
||||||
target,
|
target,
|
||||||
@@ -434,5 +432,16 @@ export const mapsApi = {
|
|||||||
if (data?.success) return { ok: true, data: data.data as MapMergeResult }
|
if (data?.success) return { ok: true, data: data.data as MapMergeResult }
|
||||||
const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在')
|
const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在')
|
||||||
return { ok: false, conflict, message: data?.message ?? '合并失败' }
|
return { ok: false, conflict, message: data?.message ?? '合并失败' }
|
||||||
|
} catch (err) {
|
||||||
|
// 与 save() 对齐:后端以 HTTP 409 状态码返回时同样翻译为 conflict,
|
||||||
|
// 让调用方能弹「是否替换」确认而非直接报错。
|
||||||
|
const ax = err as AxiosError<MapEditEnvelope<MapMergeResult>>
|
||||||
|
const body = ax.response?.data
|
||||||
|
if (ax.response?.status === 409 || body?.code === 409) {
|
||||||
|
const message = body?.message ?? '目标地图已存在'
|
||||||
|
return { ok: false, conflict: message.includes('已存在'), message }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -568,6 +568,11 @@ export const reflectionApi = {
|
|||||||
? Promise.resolve<CarStyleTypesPayload>({ globalDefault: defaultCarStyleDto(), types: [] })
|
? Promise.resolve<CarStyleTypesPayload>({ globalDefault: defaultCarStyleDto(), types: [] })
|
||||||
: get<CarStyleTypesPayload>('/car-style/types'),
|
: get<CarStyleTypesPayload>('/car-style/types'),
|
||||||
|
|
||||||
|
/** 车型编码字段元数据(site/track/plan/car 四类字段及默认值)。 */
|
||||||
|
getCarTypeCoderFields: () => MOCK
|
||||||
|
? Promise.resolve<CarTypeCoderFieldsRow[]>([])
|
||||||
|
: get<CarTypeCoderFieldsRow[]>('/car-types/coder-fields'),
|
||||||
|
|
||||||
getCarStyle: (typeFullName: string) => MOCK
|
getCarStyle: (typeFullName: string) => MOCK
|
||||||
? Promise.resolve(defaultCarStyleDto())
|
? Promise.resolve(defaultCarStyleDto())
|
||||||
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
||||||
@@ -741,6 +746,31 @@ export interface CarStyleTypesPayload {
|
|||||||
types: CarStyleTypeRow[]
|
types: CarStyleTypeRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CoderFieldDef {
|
||||||
|
name: string
|
||||||
|
typeName: string
|
||||||
|
defaultValue: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoderFieldGroup {
|
||||||
|
typeName: string
|
||||||
|
shortName: string
|
||||||
|
assemblyName: string
|
||||||
|
baseTypeName?: string
|
||||||
|
fields: CoderFieldDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarTypeCoderFieldsRow {
|
||||||
|
typeName: string
|
||||||
|
shortName: string
|
||||||
|
label: string
|
||||||
|
assemblyName: string
|
||||||
|
siteFields: CoderFieldGroup
|
||||||
|
trackFields: CoderFieldGroup
|
||||||
|
planFields: CoderFieldGroup
|
||||||
|
carFields: CoderFieldGroup
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlarmColorEntry {
|
export interface AlarmColorEntry {
|
||||||
key: string
|
key: string
|
||||||
colorArgb: number
|
colorArgb: number
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { Refresh, FullScreen, Loading } from '@element-plus/icons-vue'
|
import { Refresh, FullScreen, Loading } from '@element-plus/icons-vue'
|
||||||
import type { Scope } from '@/types/auth'
|
import type { Scope } from '@/types/auth'
|
||||||
|
import { defaultVrHost } from '@/utils/vrender'
|
||||||
|
|
||||||
interface PickEvent { x: number; y: number }
|
interface PickEvent { x: number; y: number }
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ const lastPick = ref<PickEvent | null>(null)
|
|||||||
const lastSelect = ref<string[]>([])
|
const lastSelect = ref<string[]>([])
|
||||||
const iframeSrc = ref<string>('')
|
const iframeSrc = ref<string>('')
|
||||||
|
|
||||||
const resolvedHost = computed(() => props.host ?? (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223')
|
const resolvedHost = computed(() => props.host ?? defaultVrHost())
|
||||||
|
|
||||||
const vrUrl = computed(() => {
|
const vrUrl = computed(() => {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="fleet-alloc">
|
<section class="fleet-alloc" :class="{ 'is-collapsed': collapsed }">
|
||||||
<div class="fa-header">
|
<div class="fa-header">
|
||||||
|
<button type="button" class="fa-fold" :aria-expanded="!collapsed" @click="toggleCollapsed">
|
||||||
|
<el-icon class="fold-chevron" :class="{ 'is-collapsed': collapsed }"><ArrowDown /></el-icon>
|
||||||
<span class="fa-title">车队分配</span>
|
<span class="fa-title">车队分配</span>
|
||||||
<el-tag size="small" type="info" effect="plain">区域管理</el-tag>
|
</button>
|
||||||
<span class="fa-sub">将车辆分配到车队,并设定车队名称 / 区域 / 楼层</span>
|
<el-tag v-if="collapsed" size="small" type="info" effect="plain">{{ fleets.length }} 个车队</el-tag>
|
||||||
|
<el-tag v-else size="small" type="info" effect="plain">区域管理</el-tag>
|
||||||
|
<span v-if="!collapsed" class="fa-sub">将车辆分配到车队,并设定车队名称 / 区域 / 楼层</span>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
|
<template v-if="!collapsed">
|
||||||
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
|
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
|
||||||
<el-button size="small" :icon="Plus" :disabled="!canWrite" @click="addFleet">新建车队</el-button>
|
<el-button size="small" :icon="Plus" :disabled="!canWrite" @click="addFleet">新建车队</el-button>
|
||||||
<el-button size="small" type="primary" :icon="Check" :loading="saving" :disabled="!canWrite || !dirty" @click="save">
|
<el-button size="small" type="primary" :icon="Check" :loading="saving" :disabled="!canWrite || !dirty" @click="save">
|
||||||
保存
|
保存
|
||||||
</el-button>
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-else size="small" text type="primary" @click="toggleCollapsed">展开</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading" class="fa-body">
|
<div v-show="!collapsed" v-loading="loading" class="fa-body">
|
||||||
<el-empty v-if="!fleets.length" description="暂无车队,点击「新建车队」开始分配" />
|
<el-empty v-if="!fleets.length" description="暂无车队,点击「新建车队」开始分配" />
|
||||||
|
|
||||||
<div v-for="(fleet, idx) in fleets" :key="fleet.id" class="fleet-card">
|
<div v-for="(fleet, idx) in fleets" :key="fleet.id" class="fleet-card">
|
||||||
@@ -77,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="unknownCarIds.length" class="fa-note">
|
<p v-if="!collapsed && unknownCarIds.length" class="fa-note">
|
||||||
提示:以下已分配的车辆 ID 不在当前在册车辆中:{{ unknownCarIds.join('、') }}
|
提示:以下已分配的车辆 ID 不在当前在册车辆中:{{ unknownCarIds.join('、') }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -85,15 +92,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { Refresh, Check, Plus, Delete } from '@element-plus/icons-vue'
|
import { Refresh, Check, Plus, Delete, ArrowDown } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { reflectionApi } from '@/api/reflection'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useFleetGroups } from '@/composables/useFleetGroups'
|
import { useFleetGroups } from '@/composables/useFleetGroups'
|
||||||
import { DEFAULT_FLEET } from '@/mock/data/configs'
|
import { DEFAULT_FLEET } from '@/mock/data/configs'
|
||||||
|
import type { VehicleCardModel } from '@/types/car'
|
||||||
import type { FleetGroup, FleetLifecycleConfig } from '@/types/config'
|
import type { FleetGroup, FleetLifecycleConfig } from '@/types/config'
|
||||||
|
|
||||||
|
type FleetAllocCar = Pick<VehicleCardModel, 'id' | 'name' | 'rawId'>
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
cars: { id: string; name?: string }[]
|
cars: FleetAllocCar[]
|
||||||
canWrite: boolean
|
canWrite: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -101,9 +112,31 @@ const emit = defineEmits<{ saved: [] }>()
|
|||||||
|
|
||||||
const store = useConfigStore()
|
const store = useConfigStore()
|
||||||
const { reload: reloadShared } = useFleetGroups()
|
const { reload: reloadShared } = useFleetGroups()
|
||||||
|
const COLLAPSE_KEY = 'vehicle-hub.fleet-alloc-collapsed'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
|
const collapsed = ref(readCollapsed())
|
||||||
|
|
||||||
|
function readCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(COLLAPSE_KEY)
|
||||||
|
if (v === null) return true
|
||||||
|
return v === '1'
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, collapsed.value ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保留 fleet 配置中除 groups 以外的字段(OTA / 批量 / 诊断),保存时原样回写。
|
// 保留 fleet 配置中除 groups 以外的字段(OTA / 批量 / 诊断),保存时原样回写。
|
||||||
const rest = ref<Omit<FleetLifecycleConfig, 'groups'>>({
|
const rest = ref<Omit<FleetLifecycleConfig, 'groups'>>({
|
||||||
@@ -118,8 +151,8 @@ function markDirty() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const carIndex = computed(() => {
|
const carIndex = computed(() => {
|
||||||
const m = new Map<string, string>()
|
const m = new Map<string, FleetAllocCar>()
|
||||||
for (const c of props.cars) m.set(c.id, c.name ?? c.id)
|
for (const c of props.cars) m.set(c.id, c)
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,6 +191,38 @@ function newFleetId(): string {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCarReflectionId(car: FleetAllocCar): number | null {
|
||||||
|
if (car.rawId != null && Number.isFinite(car.rawId)) return car.rawId
|
||||||
|
const parsed = Number.parseInt(car.id.replace(/\D/g, ''), 10)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCarGroupFields(): Promise<number> {
|
||||||
|
const assignments = new Map<string, { reflectionId: number; fleetName: string }>()
|
||||||
|
const cars = carIndex.value
|
||||||
|
|
||||||
|
for (const fleet of fleets.value) {
|
||||||
|
const fleetName = fleet.name.trim() || fleet.id
|
||||||
|
for (const carId of fleet.carIds) {
|
||||||
|
const car = cars.get(carId)
|
||||||
|
if (!car) continue
|
||||||
|
const reflectionId = resolveCarReflectionId(car)
|
||||||
|
if (reflectionId == null) continue
|
||||||
|
assignments.set(carId, { reflectionId, fleetName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [...assignments.values()].map(({ reflectionId, fleetName }) =>
|
||||||
|
reflectionApi.setField('car', reflectionId, 'carGroup', fleetName)
|
||||||
|
)
|
||||||
|
const results = await Promise.allSettled(tasks)
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected')
|
||||||
|
if (failed.length) {
|
||||||
|
throw new Error(`${failed.length}/${tasks.length} 辆车同步失败`)
|
||||||
|
}
|
||||||
|
return tasks.length
|
||||||
|
}
|
||||||
|
|
||||||
function addFleet() {
|
function addFleet() {
|
||||||
fleets.value.push({ id: newFleetId(), name: '新车队', floor: '', region: '', carIds: [] })
|
fleets.value.push({ id: newFleetId(), name: '新车队', floor: '', region: '', carIds: [] })
|
||||||
markDirty()
|
markDirty()
|
||||||
@@ -195,11 +260,17 @@ async function save() {
|
|||||||
...rest.value,
|
...rest.value,
|
||||||
groups: JSON.parse(JSON.stringify(fleets.value)) as FleetGroup[]
|
groups: JSON.parse(JSON.stringify(fleets.value)) as FleetGroup[]
|
||||||
}
|
}
|
||||||
const env = await store.save<FleetLifecycleConfig>('fleet', body)
|
await store.save<FleetLifecycleConfig>('fleet', body)
|
||||||
|
let syncedCars = 0
|
||||||
|
try {
|
||||||
|
syncedCars = await syncCarGroupFields()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.warning(`车队配置已保存,但同步 carGroup 字段失败:${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
}
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
await reloadShared(true)
|
await reloadShared(true)
|
||||||
emit('saved')
|
emit('saved')
|
||||||
ElMessage.success(`车队分配已保存 v${env.version}`)
|
ElMessage.success(syncedCars ? `车队分配已保存,已同步 ${syncedCars} 辆车 carGroup` : '车队分配已保存')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -220,6 +291,11 @@ onMounted(() => reload())
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--mg-veil-2, rgba(255, 255, 255, 0.03));
|
background: var(--mg-veil-2, rgba(255, 255, 255, 0.03));
|
||||||
|
transition: padding 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-alloc.is-collapsed {
|
||||||
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fa-header {
|
.fa-header {
|
||||||
@@ -229,10 +305,37 @@ onMounted(() => reload())
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fa-fold {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-fold:hover .fa-title {
|
||||||
|
color: var(--mg-accent, #c4b5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-chevron {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-chevron.is-collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
.fa-title {
|
.fa-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--mg-text-light, #fff);
|
color: var(--mg-text-light, #fff);
|
||||||
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fa-sub {
|
.fa-sub {
|
||||||
|
|||||||
@@ -1,109 +1,108 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<article
|
||||||
class="vehicle-health-card"
|
class="v-card"
|
||||||
:class="cardClass"
|
:class="cardClass"
|
||||||
@click="onCardClick"
|
@click="onCardClick"
|
||||||
@dblclick="onCardDblClick"
|
@dblclick="onCardDblClick"
|
||||||
>
|
>
|
||||||
<div class="battery-strip">
|
<!-- 顶栏:状态色带 + 车名 -->
|
||||||
<el-progress
|
<header class="v-card__banner" :class="accentTone">
|
||||||
:percentage="batteryPct"
|
<div class="v-card__banner-glow" aria-hidden="true" />
|
||||||
:stroke-width="4"
|
<div class="v-card__banner-row">
|
||||||
:show-text="false"
|
<div class="v-card__live">
|
||||||
:color="batteryColor"
|
<span class="live-dot" />
|
||||||
/>
|
<span>{{ statusPill.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="v-card__tools" @click.stop>
|
||||||
<div class="card-head">
|
<VehicleMaintenanceSelect
|
||||||
<div class="title-block">
|
:vehicle="vehicle"
|
||||||
<span class="vid">{{ vehicle.id }}</span>
|
:can-write="canWrite"
|
||||||
<span class="vname" :title="vehicle.name">{{ vehicle.name }}</span>
|
@changed="emit('maintenanceChanged')"
|
||||||
</div>
|
|
||||||
<div class="head-actions" @click.stop>
|
|
||||||
<el-dropdown trigger="click" @command="onMaintenanceCommand">
|
|
||||||
<el-button size="small" text :icon="MoreFilled" />
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item command="online">上线</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="offline">下线维护</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="repair">现场检修</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="blown" divided>返厂检修</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
<el-switch
|
|
||||||
:model-value="switchOn"
|
|
||||||
size="small"
|
|
||||||
:disabled="!canWrite"
|
|
||||||
inline-prompt
|
|
||||||
active-text="开"
|
|
||||||
inactive-text="关"
|
|
||||||
@change="(v: string | number | boolean) => onToggleMaintenance(Boolean(v))"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="v-card__identity">
|
||||||
|
<h3 class="v-card__name" :title="vehicle.name">{{ vehicle.name }}</h3>
|
||||||
|
<span class="v-card__id">{{ vehicle.id }}{{ missionIdSuffix }}</span>
|
||||||
|
<span v-if="vehicle.group" class="v-card__fleet">{{ vehicle.group }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="card-body">
|
<!-- 主体:环形电量 + 指标格 -->
|
||||||
<div class="thumb">
|
<div class="v-card__body">
|
||||||
<el-icon :size="36"><Van /></el-icon>
|
<div class="v-card__ring" :class="batteryTone" :title="`电量 ${batteryPct}%`">
|
||||||
</div>
|
<svg viewBox="0 0 88 88" class="ring-svg" aria-hidden="true">
|
||||||
<div class="metrics">
|
<circle class="ring-track" cx="44" cy="44" r="36" />
|
||||||
<div class="metric-row">
|
<circle
|
||||||
<span class="label">IP</span>
|
class="ring-progress"
|
||||||
<span class="value mono">{{ vehicle.ip ?? '—' }}</span>
|
cx="44"
|
||||||
</div>
|
cy="44"
|
||||||
<div class="metric-row">
|
r="36"
|
||||||
<span class="label">延迟</span>
|
:stroke-dasharray="ringCircumference"
|
||||||
<span class="value" :class="latencyClass">{{ latencyLabel }}</span>
|
:stroke-dashoffset="ringOffset"
|
||||||
</div>
|
/>
|
||||||
<div class="metric-row">
|
</svg>
|
||||||
<span class="label">故障率</span>
|
<div class="ring-center">
|
||||||
<span class="value" :class="faultClass">{{ faultLabel }}</span>
|
<span class="ring-label">电量</span>
|
||||||
</div>
|
<span class="ring-pct">{{ batteryPct }}%</span>
|
||||||
<div class="metric-row">
|
|
||||||
<span class="label">群组</span>
|
|
||||||
<span class="value">{{ vehicle.group ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-row">
|
|
||||||
<span class="label">状态</span>
|
|
||||||
<span class="value">{{ vehicle.lstatus ?? stateLabel }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="icon-grid">
|
<dl class="v-card__grid">
|
||||||
<div class="icon-cell" title="CPU">
|
<div class="cell">
|
||||||
<el-icon><Cpu /></el-icon>
|
<dt>IP</dt>
|
||||||
<span>{{ cpuLabel }}</span>
|
<dd class="mono">{{ vehicle.ip ?? '—' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="icon-cell" title="内存">
|
<div class="cell">
|
||||||
<el-icon><Coin /></el-icon>
|
<dt>延迟</dt>
|
||||||
<span>{{ memLabel }}</span>
|
<dd :class="latencyClass">{{ latencyLabel }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="icon-cell" title="报警">
|
<div class="cell">
|
||||||
<el-icon :class="{ 'is-alarm': vehicle.isAlarmActive }"><Warning /></el-icon>
|
<dt>故障率</dt>
|
||||||
<span>{{ vehicle.isAlarmActive ? '报警' : '正常' }}</span>
|
<dd :class="faultClass">{{ faultLabel }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="icon-cell" title="连接">
|
<div class="cell">
|
||||||
<el-icon :class="{ 'is-down': vehicle.reachable === false }"><Connection /></el-icon>
|
<dt>运行状态</dt>
|
||||||
<span>{{ vehicle.reachable === false ? '不可达' : '可达' }}</span>
|
<dd>{{ runtimeStatusLabel }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell cell--wide">
|
||||||
|
<dt>资源</dt>
|
||||||
|
<dd class="resource-line">
|
||||||
|
<span :class="cpuChipClass">CPU {{ cpuLabel }}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span :class="memChipClass">内存 {{ memLabel }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-foot">
|
<!-- 底栏:连通性指示灯 -->
|
||||||
<span>电量 {{ batteryPct }}%</span>
|
<footer class="v-card__dock">
|
||||||
<span class="hint">双击打开车载界面</span>
|
<div class="dock-signals">
|
||||||
</div>
|
<span class="signal" :class="vehicle.isAlarmActive ? 'is-bad' : 'is-ok'">
|
||||||
|
<el-icon><Warning /></el-icon>
|
||||||
|
{{ vehicle.isAlarmActive ? '报警' : '无报警' }}
|
||||||
|
</span>
|
||||||
|
<span class="signal" :class="vehicle.reachable === false ? 'is-bad' : 'is-ok'">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
{{ vehicle.reachable === false ? '不可达' : '在线' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="dock-action">
|
||||||
|
双击打开车载界面
|
||||||
|
<el-icon><Right /></el-icon>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { Warning, Connection, Right } from '@element-plus/icons-vue'
|
||||||
import { Van, Cpu, Coin, Warning, Connection, MoreFilled } from '@element-plus/icons-vue'
|
|
||||||
import type { VehicleCardModel } from '@/types/car'
|
import type { VehicleCardModel } from '@/types/car'
|
||||||
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
|
import { openOnboardWeb } from '@/api/vehicleOps'
|
||||||
import { openOnboardWeb, setVehicleMaintenance } from '@/api/vehicleOps'
|
import { useVehicleCardState } from '@/composables/useVehicleCardState'
|
||||||
|
import VehicleMaintenanceSelect from '@/components/fleet/VehicleMaintenanceSelect.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
vehicle: VehicleCardModel
|
vehicle: VehicleCardModel
|
||||||
@@ -116,305 +115,373 @@ const emit = defineEmits<{
|
|||||||
maintenanceChanged: []
|
maintenanceChanged: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const stateLabels: Record<string, string> = {
|
const RING_R = 36
|
||||||
idle: '空闲',
|
const ringCircumference = 2 * Math.PI * RING_R
|
||||||
running: '运行',
|
|
||||||
charging: '充电',
|
|
||||||
paused: '暂停',
|
|
||||||
fault: '故障',
|
|
||||||
offline: '离线'
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateLabel = computed(() => stateLabels[props.vehicle.state] ?? props.vehicle.state)
|
const {
|
||||||
|
batteryPct,
|
||||||
|
batteryTone,
|
||||||
|
statusPill,
|
||||||
|
missionIdSuffix,
|
||||||
|
runtimeStatusLabel,
|
||||||
|
accentTone,
|
||||||
|
latencyLabel,
|
||||||
|
latencyClass,
|
||||||
|
faultLabel,
|
||||||
|
faultClass,
|
||||||
|
cpuLabel,
|
||||||
|
memLabel,
|
||||||
|
cpuChipClass,
|
||||||
|
memChipClass
|
||||||
|
} = useVehicleCardState({ vehicle: () => props.vehicle })
|
||||||
|
|
||||||
const batteryPct = computed(() => {
|
const ringOffset = computed(() =>
|
||||||
const raw = props.vehicle.batterySoc ?? 0
|
ringCircumference * (1 - batteryPct.value / 100)
|
||||||
const pct = raw > 1 ? raw : raw * 100
|
)
|
||||||
return Math.max(0, Math.min(100, Math.round(pct)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const batteryColor = computed(() => {
|
|
||||||
const p = batteryPct.value
|
|
||||||
if (p < 20) return '#f56c6c'
|
|
||||||
if (p < 50) return '#e6a23c'
|
|
||||||
return '#67c23a'
|
|
||||||
})
|
|
||||||
|
|
||||||
const switchOn = computed(() => props.vehicle.maintenanceMode === 'online')
|
|
||||||
|
|
||||||
const cardClass = computed(() => ({
|
const cardClass = computed(() => ({
|
||||||
'is-selected': props.selected,
|
'is-selected': props.selected,
|
||||||
'is-alarm': props.vehicle.isAlarmActive,
|
'is-alarm': props.vehicle.isAlarmActive,
|
||||||
'is-offline': props.vehicle.maintenanceMode === 'offline' || props.vehicle.state === 'offline',
|
'is-unreachable': props.vehicle.reachable === false,
|
||||||
'is-maintenance': props.vehicle.maintenanceMode === 'repair' || props.vehicle.maintenanceMode === 'blown',
|
[statusPill.value.tone]: true
|
||||||
'is-unreachable': props.vehicle.reachable === false
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const latencyLabel = computed(() => {
|
|
||||||
const ms = props.vehicle.latencyMs
|
|
||||||
if (ms == null) return '—'
|
|
||||||
if (props.vehicle.reachable === false) return '超时'
|
|
||||||
return `${ms} ms`
|
|
||||||
})
|
|
||||||
|
|
||||||
const latencyClass = computed(() => {
|
|
||||||
const ms = props.vehicle.latencyMs
|
|
||||||
if (props.vehicle.reachable === false) return 'danger'
|
|
||||||
if (ms != null && ms > 80) return 'warn'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const faultLabel = computed(() => {
|
|
||||||
const v = props.vehicle.faultRatePercent
|
|
||||||
if (v == null) return '—'
|
|
||||||
return `${v.toFixed(2)}%`
|
|
||||||
})
|
|
||||||
|
|
||||||
const faultClass = computed(() => {
|
|
||||||
const v = props.vehicle.faultRatePercent ?? 0
|
|
||||||
if (v >= 5) return 'danger'
|
|
||||||
if (v >= 1) return 'warn'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const cpuLabel = computed(() => {
|
|
||||||
const v = props.vehicle.cpuPercent
|
|
||||||
return v != null ? `${Math.round(v)}%` : '—'
|
|
||||||
})
|
|
||||||
|
|
||||||
const memLabel = computed(() => {
|
|
||||||
const v = props.vehicle.memPercent
|
|
||||||
return v != null ? `${Math.round(v)}%` : '—'
|
|
||||||
})
|
|
||||||
|
|
||||||
function onCardClick() {
|
function onCardClick() {
|
||||||
emit('select', props.vehicle.id)
|
emit('select', props.vehicle.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCardDblClick(e: MouseEvent) {
|
function onCardDblClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (target.closest('.head-actions')) return
|
if (target.closest('.v-card__tools')) return
|
||||||
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
|
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyMaintenance(mode: VehicleMaintenanceMode) {
|
|
||||||
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
|
|
||||||
if (!Number.isFinite(rawId)) return
|
|
||||||
const ok = await setVehicleMaintenance(rawId, mode)
|
|
||||||
if (ok) {
|
|
||||||
ElMessage.success('维护状态已更新')
|
|
||||||
emit('maintenanceChanged')
|
|
||||||
} else {
|
|
||||||
ElMessage.error('维护操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onToggleMaintenance(on: boolean) {
|
|
||||||
if (!props.canWrite) return
|
|
||||||
const mode: VehicleMaintenanceMode = on ? 'online' : 'offline'
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
on ? '确认将车辆上线?' : '确认将车辆下线维护?',
|
|
||||||
'维护确认',
|
|
||||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
|
|
||||||
)
|
|
||||||
await applyMaintenance(mode)
|
|
||||||
} catch {
|
|
||||||
/* cancelled */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMaintenanceCommand(cmd: string) {
|
|
||||||
if (!props.canWrite) return
|
|
||||||
const mode = cmd as VehicleMaintenanceMode
|
|
||||||
if (mode === 'blown') {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
|
|
||||||
type: 'error',
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消'
|
|
||||||
})
|
|
||||||
await applyMaintenance(mode)
|
|
||||||
} catch {
|
|
||||||
/* cancelled */
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (mode === 'repair') {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
|
|
||||||
type: 'warning'
|
|
||||||
})
|
|
||||||
await applyMaintenance(mode)
|
|
||||||
} catch {
|
|
||||||
/* cancelled */
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await applyMaintenance(mode)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.vehicle-health-card {
|
/* ── 全新车辆卡片:顶栏色带 + 环形电量 + 指标格 + 底栏信号 ── */
|
||||||
border: 1px solid var(--el-border-color-light);
|
.v-card {
|
||||||
border-radius: 8px;
|
--vc-radius: 16px;
|
||||||
background: var(--el-bg-color);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: start;
|
||||||
|
height: auto;
|
||||||
|
min-height: 220px;
|
||||||
|
border-radius: var(--vc-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
background: rgba(var(--mg-bg-card-rgb, 38, 24, 78), 0.92);
|
||||||
|
border: 1px solid var(--mg-glass-border, rgba(var(--mg-accent-rgb, 196, 181, 253), 0.22));
|
||||||
|
box-shadow: var(--mg-glass-shadow, 0 10px 28px rgba(var(--mg-shadow-rgb, 8, 2, 24), 0.5));
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-health-card:hover {
|
.v-card:hover {
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
transform: translateY(-4px);
|
||||||
|
border-color: var(--mg-glass-border-hi, rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.55));
|
||||||
|
box-shadow: var(--mg-glass-shadow-hi, 0 16px 40px rgba(var(--mg-primary-rgb, 124, 58, 237), 0.32));
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-health-card.is-selected {
|
.v-card.is-selected {
|
||||||
border-color: var(--el-color-primary);
|
border-color: rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.9);
|
||||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
|
box-shadow: 0 0 0 2px rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-health-card.is-alarm {
|
/* ── 顶栏 ── */
|
||||||
border-color: var(--el-color-danger-light-5);
|
.v-card__banner {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
background: linear-gradient(
|
||||||
|
125deg,
|
||||||
|
rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.55) 0%,
|
||||||
|
rgba(var(--mg-primary-rgb, 124, 58, 237), 0.35) 55%,
|
||||||
|
rgba(var(--mg-bg-card-darker-rgb, 20, 12, 48), 0.2) 100%
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-health-card.is-offline,
|
.v-card__banner.tone-danger {
|
||||||
.vehicle-health-card.is-maintenance {
|
background: linear-gradient(125deg, rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.45), rgba(var(--mg-bg-card-darker-rgb, 20, 12, 48), 0.5));
|
||||||
border-color: var(--el-color-warning-light-5);
|
}
|
||||||
|
.v-card__banner.tone-warning {
|
||||||
|
background: linear-gradient(125deg, rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.4), rgba(var(--mg-primary-rgb, 124, 58, 237), 0.3));
|
||||||
|
}
|
||||||
|
.v-card__banner.tone-success {
|
||||||
|
background: linear-gradient(125deg, rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.28), rgba(var(--mg-primary-rgb, 124, 58, 237), 0.38));
|
||||||
|
}
|
||||||
|
.v-card__banner.tone-info {
|
||||||
|
background: linear-gradient(125deg, rgba(var(--mg-status-info-rgb, 59, 130, 246), 0.35), rgba(var(--mg-primary-rgb, 124, 58, 237), 0.35));
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-health-card.is-unreachable {
|
.v-card__banner-glow {
|
||||||
opacity: 0.85;
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
right: -20px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.18) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-strip {
|
.v-card__banner-row {
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-head {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 10px 4px;
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-success .live-dot { background: var(--mg-status-success, #22c55e); box-shadow: 0 0 8px var(--mg-status-success, #22c55e); }
|
||||||
|
.tone-danger .live-dot { background: var(--mg-status-danger, #ef4444); box-shadow: 0 0 8px var(--mg-status-danger, #ef4444); }
|
||||||
|
.tone-warning .live-dot { background: var(--mg-status-warning, #f59e0b); box-shadow: 0 0 8px var(--mg-status-warning, #f59e0b); }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.65; transform: scale(0.85); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-block {
|
.v-card__name {
|
||||||
min-width: 0;
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__id {
|
||||||
|
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__fleet {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 主体 ── */
|
||||||
|
.v-card__body {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__ring {
|
||||||
|
position: relative;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-svg {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-track {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.18);
|
||||||
|
stroke-width: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-progress {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--mg-status-success, #22c55e);
|
||||||
|
stroke-width: 6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.4s ease;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__ring.tone-warning .ring-progress {
|
||||||
|
stroke: var(--mg-status-warning, #f59e0b);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.45));
|
||||||
|
}
|
||||||
|
.v-card__ring.tone-danger .ring-progress {
|
||||||
|
stroke: var(--mg-status-danger, #ef4444);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-center {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vid {
|
.ring-label {
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
color: var(--el-text-color-secondary);
|
font-weight: 700;
|
||||||
font-family: ui-monospace, monospace;
|
color: var(--mg-text-dim, rgba(var(--mg-accent-rgb, 196, 181, 253), 0.95));
|
||||||
|
letter-spacing: 1px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vname {
|
.ring-pct {
|
||||||
font-weight: 600;
|
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||||
font-size: 14px;
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--mg-text-light, #1a0f3d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__ring.tone-warning .ring-pct {
|
||||||
|
color: var(--mg-status-warning, #f59e0b);
|
||||||
|
}
|
||||||
|
.v-card__ring.tone-danger .ring-pct {
|
||||||
|
color: var(--mg-status-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card__grid {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--mg-veil-1, rgba(255, 255, 255, 0.05));
|
||||||
|
border: 1px solid var(--mg-veil-border, rgba(255, 255, 255, 0.07));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell dt {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mg-text-light, #fff);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.head-actions {
|
.cell dd.mono {
|
||||||
display: flex;
|
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 4px 10px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row {
|
.cell dd.val-danger { color: var(--mg-status-danger, #ef4444); }
|
||||||
|
.cell dd.val-warn { color: var(--mg-status-warning, #f59e0b); }
|
||||||
|
.cell dd.val-ok { color: var(--mg-status-success, #22c55e); }
|
||||||
|
|
||||||
|
.resource-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-line .sep {
|
||||||
|
opacity: 0.35;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 底栏 ── */
|
||||||
|
.v-card__dock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--mg-veil-2, rgba(0, 0, 0, 0.22));
|
||||||
|
border-top: 1px solid var(--mg-veil-border, rgba(255, 255, 255, 0.06));
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row .label {
|
.dock-signals {
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row .value {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row .value.mono {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row .value.danger {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row .value.warn {
|
|
||||||
color: var(--el-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0 10px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-cell {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
font-size: 10px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-cell .is-alarm {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-cell .is-down {
|
|
||||||
color: var(--el-color-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-foot {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--el-text-color-secondary);
|
font-weight: 600;
|
||||||
background: var(--el-fill-color-lighter);
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
|
||||||
border-top: 1px solid var(--el-border-color-lighter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot .hint {
|
.signal .el-icon { font-size: 13px; }
|
||||||
opacity: 0.7;
|
|
||||||
|
.signal.is-ok { color: var(--mg-status-success, #22c55e); }
|
||||||
|
.signal.is-bad { color: var(--mg-status-danger, #ef4444); }
|
||||||
|
|
||||||
|
.dock-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--mg-text-dim, rgba(255, 255, 255, 0.45));
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card:hover .dock-action {
|
||||||
|
color: var(--mg-primary, #7c3aed);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import type { SelectionItem } from './useSelection'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑器剪贴板:保存最近一次「复制」操作的对象快照(含字段值),
|
|
||||||
* 用于「粘贴 (Ctrl+V)」与「复制字段 (Copy Fields)」。
|
|
||||||
*
|
|
||||||
* 粘贴策略:调用方在拿到目标坐标后,用 mapEditApi.batch 创建副本(带偏移)。
|
|
||||||
* 复制字段:调用方调 mapEditApi.copyFieldsTo 把指定字段名写到目标对象(们)。
|
|
||||||
*
|
|
||||||
* 注意:剪贴板里保存的是对象的"逻辑快照",不是 DOM 文本剪贴板。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ClipboardSnapshot {
|
|
||||||
items: Array<{
|
|
||||||
kind: string
|
|
||||||
sourceId: number
|
|
||||||
typeName: string
|
|
||||||
/** 对象的几何 / 样式字段(含 x, y 用于偏移粘贴)。 */
|
|
||||||
fields: Record<string, string>
|
|
||||||
}>
|
|
||||||
fieldNames: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClipboard() {
|
|
||||||
const data = ref<ClipboardSnapshot | null>(null)
|
|
||||||
|
|
||||||
function copy(items: SelectionItem[], allFields: Record<number, Record<string, string>>) {
|
|
||||||
if (items.length === 0) {
|
|
||||||
data.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const fieldNamesSet = new Set<string>()
|
|
||||||
const snapshot: ClipboardSnapshot = {
|
|
||||||
items: items.map((it) => {
|
|
||||||
const f = allFields[it.id] ?? {}
|
|
||||||
Object.keys(f).forEach((k) => fieldNamesSet.add(k))
|
|
||||||
return {
|
|
||||||
kind: it.kind,
|
|
||||||
sourceId: it.id,
|
|
||||||
typeName: it.typeName,
|
|
||||||
fields: f
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
fieldNames: []
|
|
||||||
}
|
|
||||||
snapshot.fieldNames = [...fieldNamesSet]
|
|
||||||
data.value = snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
data.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data, copy, clear }
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||||
import { listCars } from '@/api/projection'
|
import { listCars, listMissions } from '@/api/projection'
|
||||||
import { fetchFleetHealth } from '@/api/fleetHealth'
|
import { fetchFleetHealth } from '@/api/fleetHealth'
|
||||||
import { useProjectionStream } from '@/composables/useProjectionStream'
|
import { useProjectionStream } from '@/composables/useProjectionStream'
|
||||||
import type { Car, FleetHealthRow, VehicleCardModel } from '@/types/car'
|
import type { Car, FleetHealthRow, VehicleCardModel } from '@/types/car'
|
||||||
|
import type { Mission, MissionStatus } from '@/types/mission'
|
||||||
|
|
||||||
const CAR_POLL_MS = 5000
|
const CAR_POLL_MS = 5000
|
||||||
const HEALTH_POLL_MS = 20000
|
const HEALTH_POLL_MS = 20000
|
||||||
@@ -15,9 +16,41 @@ function inferMaintenanceMode(car: Car): VehicleCardModel['maintenanceMode'] {
|
|||||||
return 'online'
|
return 'online'
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
|
function missionStatusOrder(status: MissionStatus): number {
|
||||||
|
if (status === 'running') return 0
|
||||||
|
if (status === 'paused') return 1
|
||||||
|
if (status === 'assigned') return 2
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMissionId(car: Car, missions: Mission[]): string | undefined {
|
||||||
|
if (car.missionId) {
|
||||||
|
const id = String(car.missionId).trim()
|
||||||
|
if (id && id !== '0') return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawKey = car.rawId != null ? String(car.rawId) : undefined
|
||||||
|
let best: Mission | undefined
|
||||||
|
|
||||||
|
for (const mission of missions) {
|
||||||
|
if (!mission.carId) continue
|
||||||
|
if (mission.carId !== car.id && mission.carId !== rawKey) continue
|
||||||
|
if (mission.status !== 'running' && mission.status !== 'paused' && mission.status !== 'assigned') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!best || missionStatusOrder(mission.status) < missionStatusOrder(best.status)) {
|
||||||
|
best = mission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCarHealth(car: Car, health?: FleetHealthRow, missions: Mission[] = []): VehicleCardModel {
|
||||||
|
const missionId = resolveMissionId(car, missions)
|
||||||
return {
|
return {
|
||||||
...car,
|
...car,
|
||||||
|
missionId,
|
||||||
ip: health?.ip ?? car.ip,
|
ip: health?.ip ?? car.ip,
|
||||||
onboardUrl: health?.onboardUrl ?? car.onboardUrl ?? (car.ip ? `http://${car.ip}:8081` : undefined),
|
onboardUrl: health?.onboardUrl ?? car.onboardUrl ?? (car.ip ? `http://${car.ip}:8081` : undefined),
|
||||||
latencyMs: health?.latencyMs,
|
latencyMs: health?.latencyMs,
|
||||||
@@ -32,6 +65,7 @@ function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
|
|||||||
|
|
||||||
export function useVehicleHub() {
|
export function useVehicleHub() {
|
||||||
const cars = shallowRef<Car[]>([])
|
const cars = shallowRef<Car[]>([])
|
||||||
|
const missions = shallowRef<Mission[]>([])
|
||||||
const healthRows = shallowRef<FleetHealthRow[]>([])
|
const healthRows = shallowRef<FleetHealthRow[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const healthLoading = ref(false)
|
const healthLoading = ref(false)
|
||||||
@@ -52,7 +86,7 @@ export function useVehicleHub() {
|
|||||||
cars.value.map((car) => {
|
cars.value.map((car) => {
|
||||||
const rawId = car.rawId ?? parseInt(car.id.replace(/\D/g, ''), 10)
|
const rawId = car.rawId ?? parseInt(car.id.replace(/\D/g, ''), 10)
|
||||||
const health = Number.isFinite(rawId) ? healthByCarId.value.get(rawId) : undefined
|
const health = Number.isFinite(rawId) ? healthByCarId.value.get(rawId) : undefined
|
||||||
return mergeCarHealth(car, health)
|
return mergeCarHealth(car, health, missions.value)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +101,9 @@ export function useVehicleHub() {
|
|||||||
async function loadCars() {
|
async function loadCars() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
cars.value = await listCars()
|
const [carList, missionList] = await Promise.all([listCars(), listMissions()])
|
||||||
|
cars.value = carList
|
||||||
|
missions.value = missionList
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -101,12 +137,13 @@ export function useVehicleHub() {
|
|||||||
|
|
||||||
stream.on((evt) => {
|
stream.on((evt) => {
|
||||||
if (evt.kind === 'car-state' && evt.payload && typeof evt.payload === 'object') {
|
if (evt.kind === 'car-state' && evt.payload && typeof evt.payload === 'object') {
|
||||||
const p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string }
|
const p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string; missionId?: string }
|
||||||
const rawId = p.rawId
|
const rawId = p.rawId
|
||||||
if (rawId != null) {
|
if (rawId != null) {
|
||||||
patchCarFromStream(rawId, {
|
patchCarFromStream(rawId, {
|
||||||
state: p.state as Car['state'],
|
state: p.state as Car['state'],
|
||||||
lstatus: p.lstatus
|
lstatus: p.lstatus,
|
||||||
|
missionId: p.missionId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,10 +107,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import { Fold, Expand, CaretBottom } from '@element-plus/icons-vue'
|
||||||
Fold, Expand, CaretBottom, Monitor, Setting, Histogram, Tools,
|
import { ADMIN_MENU, MONITOR_MENU, type NavMenuItem } from '@/config/navMenu'
|
||||||
MapLocation, Van, Promotion, Box, OfficeBuilding, Notebook
|
|
||||||
} from '@element-plus/icons-vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
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'
|
||||||
@@ -139,45 +137,7 @@ const runModeLabel = computed(() => {
|
|||||||
|
|
||||||
// key 即「权限页面 Key」(= vue-router route.name),用于按 auth.allowedPages 过滤菜单。
|
// key 即「权限页面 Key」(= vue-router route.name),用于按 auth.allowedPages 过滤菜单。
|
||||||
// 分组节点(无 key)只要还有可见子项就保留。
|
// 分组节点(无 key)只要还有可见子项就保留。
|
||||||
interface MenuItem { path: string; label: string; icon?: unknown; key?: string; children?: MenuItem[] }
|
type MenuItem = NavMenuItem
|
||||||
|
|
||||||
const ADMIN_MENU: MenuItem[] = [
|
|
||||||
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard' },
|
|
||||||
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor' },
|
|
||||||
{
|
|
||||||
path: '/admin/design', label: '设计与编排', icon: Tools,
|
|
||||||
children: [
|
|
||||||
{ path: '/admin/maps', label: '地图管理', key: 'admin-maps' },
|
|
||||||
{ path: '/admin/map-editor', label: '地图编辑', key: 'admin-map-editor' },
|
|
||||||
{ path: '/admin/project-properties', label: '项目属性', key: 'admin-project-properties' },
|
|
||||||
{ path: '/admin/tracks', label: '场景管理', key: 'admin-tracks' },
|
|
||||||
{ path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
|
|
||||||
{ path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
|
|
||||||
{ path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
|
|
||||||
{ path: '/admin/task-templates', label: '任务编排', key: 'admin-task-templates' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/config', label: '平台配置中心', icon: Setting,
|
|
||||||
children: [
|
|
||||||
{ path: '/admin/config/strategy', label: '调度策略', key: 'admin-config-strategy' },
|
|
||||||
{ path: '/admin/config/vehicle-hub', label: '车辆运维', key: 'admin-vehicle-hub' },
|
|
||||||
{ path: '/admin/config/facility', label: '设备接入', key: 'admin-config-facility' },
|
|
||||||
{ path: '/admin/config/warehouse', label: '库位管理', key: 'admin-config-warehouse' },
|
|
||||||
{ path: '/admin/config/business', label: '业务与集成', key: 'admin-config-business' },
|
|
||||||
{ path: '/admin/config/ops-center', label: '运维与回放', key: 'admin-config-ops-center' },
|
|
||||||
{ path: '/admin/config/system-center', label: '系统与权限', key: 'admin-config-system-center' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const MONITOR_MENU: MenuItem[] = [
|
|
||||||
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard' },
|
|
||||||
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub' },
|
|
||||||
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map' },
|
|
||||||
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops' },
|
|
||||||
{ path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes' }
|
|
||||||
]
|
|
||||||
|
|
||||||
function filterMenu(items: MenuItem[]): MenuItem[] {
|
function filterMenu(items: MenuItem[]): MenuItem[] {
|
||||||
const out: MenuItem[] = []
|
const out: MenuItem[] = []
|
||||||
@@ -210,8 +170,6 @@ const activePath = computed(() => {
|
|||||||
|
|
||||||
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
|
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
|
||||||
|
|
||||||
void Van; void Box; void OfficeBuilding
|
|
||||||
|
|
||||||
function onUserCommand(cmd: string) {
|
function onUserCommand(cmd: string) {
|
||||||
if (cmd === 'logout') {
|
if (cmd === 'logout') {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const PAGES: PageDef[] = [
|
|||||||
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
|
||||||
|
{ key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
||||||
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
||||||
{ key: 'admin-config-facility', label: '设备接入', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-config-facility', label: '设备接入', group: '平台配置中心', scope: 'Platform' },
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
|
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
|
||||||
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
|
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
|
||||||
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
||||||
|
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||||
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
||||||
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
||||||
|
|||||||
@@ -957,6 +957,12 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
|
|||||||
--mg-status-idle: #64748b;
|
--mg-status-idle: #64748b;
|
||||||
--mg-status-idle-rgb: 100, 116, 139;
|
--mg-status-idle-rgb: 100, 116, 139;
|
||||||
|
|
||||||
|
/* 卡片 / 列表行:浅底白卡(与 industrial-purple 深紫卡区分) */
|
||||||
|
--mg-bg-card-rgb: 255, 255, 255;
|
||||||
|
--mg-bg-card-hi-rgb: 249, 246, 255;
|
||||||
|
--mg-bg-card-darker-rgb: 245, 240, 251;
|
||||||
|
--mg-accent-rgb: 124, 58, 237;
|
||||||
|
|
||||||
/* 内嵌表面 veil:浅紫主题翻成紫色微透,保证白底卡片内的嵌套行 / 子卡可见 */
|
/* 内嵌表面 veil:浅紫主题翻成紫色微透,保证白底卡片内的嵌套行 / 子卡可见 */
|
||||||
--mg-veil-1: rgba(124, 58, 237, 0.05);
|
--mg-veil-1: rgba(124, 58, 237, 0.05);
|
||||||
--mg-veil-2: rgba(124, 58, 237, 0.10);
|
--mg-veil-2: rgba(124, 58, 237, 0.10);
|
||||||
@@ -1345,6 +1351,11 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
|
|||||||
color: #5b21b6 !important;
|
color: #5b21b6 !important;
|
||||||
border-color: #ddd6fe !important;
|
border-color: #ddd6fe !important;
|
||||||
}
|
}
|
||||||
|
:root[data-theme="fame-lavender"] .mg-content .el-tag--primary {
|
||||||
|
background: #ede9fe !important;
|
||||||
|
color: #5b21b6 !important;
|
||||||
|
border-color: #c4b5fd !important;
|
||||||
|
}
|
||||||
:root[data-theme="fame-lavender"] .mg-content .el-tag--success {
|
:root[data-theme="fame-lavender"] .mg-content .el-tag--success {
|
||||||
background: #ecfdf5 !important;
|
background: #ecfdf5 !important;
|
||||||
color: #047857 !important;
|
color: #047857 !important;
|
||||||
|
|||||||
@@ -1,26 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="status-page mg-content">
|
<div class="status-page mg-content">
|
||||||
<el-card shadow="never" class="status-card">
|
<el-card shadow="never" class="status-card" v-loading="loading">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>SimpleLite Service Status</span>
|
<span>服务状态</span>
|
||||||
<el-tag type="success" effect="dark" style="margin-left: 8px">Web-Enabled (Mock)</el-tag>
|
<el-tag v-if="health" type="success" effect="dark" style="margin-left: 8px">在线</el-tag>
|
||||||
|
<el-tag v-else-if="!loading" type="danger" effect="dark" style="margin-left: 8px">不可达</el-tag>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions :column="2" border>
|
|
||||||
<el-descriptions-item label="模式">Web-Enabled</el-descriptions-item>
|
<el-descriptions :column="2" border title="MiGu.Server">
|
||||||
<el-descriptions-item label="启动时间">{{ startTime }}</el-descriptions-item>
|
<el-descriptions-item label="状态">
|
||||||
<el-descriptions-item label="运行时长">{{ uptime }}</el-descriptions-item>
|
<el-tag :type="health ? 'success' : 'danger'" size="small">{{ health ? 'ok' : 'unreachable' }}</el-tag>
|
||||||
<el-descriptions-item label="节点角色"><el-tag type="success">Active (ROSE)</el-tag></el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="WebAPI">http://0.0.0.0:7001 <el-tag size="small">OK</el-tag></el-descriptions-item>
|
<el-descriptions-item label="启动时间">{{ serverStartTime || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="WebSocket">ws://0.0.0.0:7002 <el-tag size="small">OK</el-tag></el-descriptions-item>
|
<el-descriptions-item label="运行时长" :span="2">{{ serverUptime || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="webVRender">http://0.0.0.0:8223 <el-tag size="small" type="success">OK</el-tag></el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Platform.Server">:8080 (pid=12345)</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="在线 Vue 客户端">admin=3, monitor=4</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="调度循环 / 任务">50 Hz · 14 / 32</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-descriptions v-if="sl" :column="2" border title="SimpleLite" class="status-block">
|
||||||
|
<el-descriptions-item label="托管启用">
|
||||||
|
<el-tag :type="sl.enabled ? 'success' : 'info'" size="small">{{ sl.enabled ? '是' : '否' }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="进程运行中">
|
||||||
|
<el-tag :type="sl.isRunning ? 'success' : 'info'" size="small">{{ sl.isRunning ? '是' : '否(或外部启动)' }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="启动模式">{{ sl.lastLaunchMode || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="`投影端口 :${sl.projectionPort}`">
|
||||||
|
<el-tag :type="sl.projectionPortReachable ? 'success' : 'danger'" size="small">
|
||||||
|
{{ sl.projectionPortReachable ? '可达' : '不可达' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="前往站点 API">
|
||||||
|
<el-tag :type="gotoSiteTagType" size="small">{{ gotoSiteLabel }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="可执行文件">
|
||||||
|
<el-tag :type="sl.executableExists ? 'success' : 'warning'" size="small">
|
||||||
|
{{ sl.executableExists ? '已找到' : '未找到' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="sl.deployHint" label="部署提示" :span="2">
|
||||||
|
{{ sl.deployHint }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-alert v-if="error" :title="error" type="warning" :closable="false" class="status-block" />
|
||||||
|
|
||||||
<div class="status-actions">
|
<div class="status-actions">
|
||||||
<el-button>查看日志</el-button>
|
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
||||||
<el-button type="warning" plain>重启 Web</el-button>
|
|
||||||
<el-button type="danger" plain>关闭服务</el-button>
|
|
||||||
<el-button @click="back">返回</el-button>
|
<el-button @click="back">返回</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -28,28 +52,89 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { 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 { 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 startTime = new Date().toLocaleString('zh-CN')
|
const auth = useAuthStore()
|
||||||
const uptime = ref('00:00:00')
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const health = ref<HealthInfo | null>(null)
|
||||||
|
const sl = ref<SimpleLiteDiagnostics | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
const t0 = Date.now()
|
|
||||||
|
|
||||||
function tick() {
|
const serverStartTime = computed(() =>
|
||||||
const ms = Date.now() - t0
|
health.value ? new Date(health.value.startTime).toLocaleString('zh-CN') : ''
|
||||||
const s = Math.floor(ms / 1000)
|
)
|
||||||
|
|
||||||
|
const serverUptime = computed(() => {
|
||||||
|
if (!health.value) return ''
|
||||||
|
const s = Math.max(0, Math.floor(health.value.uptimeSec))
|
||||||
const h = String(Math.floor(s / 3600)).padStart(2, '0')
|
const h = String(Math.floor(s / 3600)).padStart(2, '0')
|
||||||
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
|
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
|
||||||
const ss = String(s % 60).padStart(2, '0')
|
const ss = String(s % 60).padStart(2, '0')
|
||||||
uptime.value = `${h}:${m}:${ss}`
|
return `${h}:${m}:${ss}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const gotoSiteLabel = computed(() => {
|
||||||
|
const v = sl.value?.gotoSiteApiAvailable
|
||||||
|
if (v === true) return '可用'
|
||||||
|
if (v === false) return '缺失(旧版 DLL)'
|
||||||
|
return '未知'
|
||||||
|
})
|
||||||
|
|
||||||
|
const gotoSiteTagType = computed(() => {
|
||||||
|
const v = sl.value?.gotoSiteApiAvailable
|
||||||
|
if (v === true) return 'success'
|
||||||
|
if (v === false) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
// /status 是 public 页:未登录只拉匿名 /health,不调需登录的 simplelite 诊断
|
||||||
|
// (401 会触发全局拦截器强制跳转登录页)。
|
||||||
|
const requests: [Promise<{ data: HealthInfo }>, Promise<{ data: SimpleLiteDiagnostics }> | null] = [
|
||||||
|
http.get<HealthInfo>('/health'),
|
||||||
|
auth.token ? http.get<SimpleLiteDiagnostics>('/health/simplelite') : null
|
||||||
|
]
|
||||||
|
const [h, d] = await Promise.allSettled([requests[0], requests[1] ?? Promise.reject(new Error('skipped'))])
|
||||||
|
health.value = h.status === 'fulfilled' ? h.value.data : null
|
||||||
|
sl.value = d.status === 'fulfilled' ? d.value.data : null
|
||||||
|
if (h.status === 'rejected') error.value = '无法连接 MiGu.Server(/api/health)'
|
||||||
|
else if (auth.token && d.status === 'rejected') error.value = 'SimpleLite 诊断获取失败(/api/health/simplelite)'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
tick()
|
void refresh()
|
||||||
timer = window.setInterval(tick, 1000)
|
// 10s 轮询:服务端对 goto-site 探测有 60s 缓存,此频率不会对 SimpleLite 产生压力。
|
||||||
|
timer = window.setInterval(() => void refresh(), 10_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -62,5 +147,6 @@ function back() { router.back() }
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.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-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>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PermissionGuard widget-id="CadToolbar">
|
|
||||||
<el-card shadow="never">
|
|
||||||
<template #header><span>CAD 工具栏(cad.tool.run)</span></template>
|
|
||||||
<el-tabs v-model="active">
|
|
||||||
<el-tab-pane v-for="g in groups" :key="g.key" :name="g.key" :label="g.label">
|
|
||||||
<el-row :gutter="12">
|
|
||||||
<el-col v-for="t in g.tools" :key="t.id" :span="6" style="margin-bottom: 12px">
|
|
||||||
<el-card shadow="hover" class="tool-card" @click="run(t)">
|
|
||||||
<el-icon size="24" class="tool-icon"><Tools /></el-icon>
|
|
||||||
<div class="tool-name">{{ t.label }}</div>
|
|
||||||
<div class="tool-desc">{{ t.desc }}</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</el-card>
|
|
||||||
</PermissionGuard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Tools } from '@element-plus/icons-vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import PermissionGuard from '@/components/PermissionGuard.vue'
|
|
||||||
|
|
||||||
interface CadTool { id: string; label: string; desc: string }
|
|
||||||
|
|
||||||
const active = ref('Scene')
|
|
||||||
|
|
||||||
const groups: Array<{ key: string; label: string; tools: CadTool[] }> = [
|
|
||||||
{
|
|
||||||
key: 'Scene', label: '场景 (Scene)',
|
|
||||||
tools: [
|
|
||||||
{ id: 'grid', label: '生成网格', desc: '按间距生成站点网格' },
|
|
||||||
{ id: 'mirror', label: '镜像', desc: '镜像复制选中对象' },
|
|
||||||
{ id: 'align', label: '对齐', desc: '横向/纵向对齐多选' },
|
|
||||||
{ id: 'distribute', label: '等距分布', desc: '在两端之间均匀分布站点' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Car', label: '车辆 (Car)',
|
|
||||||
tools: [
|
|
||||||
{ id: 'spawn', label: '批量创建', desc: '从模板批量创建车辆' },
|
|
||||||
{ id: 'reset', label: '回原点', desc: '将选中车辆送回原点' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Project', label: '工程 (Project)',
|
|
||||||
tools: [
|
|
||||||
{ id: 'validate', label: '工程校验', desc: '检查轨道连通性 / 重复站点' },
|
|
||||||
{ id: 'snapshot', label: '快照', desc: '输出 problems/simplelite-scene-*.json' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
function run(t: CadTool) {
|
|
||||||
ElMessage.info(`占位:执行 CAD 工具 [${t.id}] ${t.label}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
|
|
||||||
.tool-name { font-weight: 600; margin-top: 6px; }
|
|
||||||
.tool-desc { color: var(--mg-text-dim); font-size: 12px; margin-top: 4px; }
|
|
||||||
.tool-icon {
|
|
||||||
color: var(--mg-accent);
|
|
||||||
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
|
|
||||||
}
|
|
||||||
.tool-card:hover .tool-icon {
|
|
||||||
color: var(--mg-text-light);
|
|
||||||
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -44,17 +44,38 @@
|
|||||||
<span>智能调度 · 一站式平台</span>
|
<span>智能调度 · 一站式平台</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero-title">迷毂智能调度平台</h1>
|
<h1 class="hero-title">迷毂智能调度平台</h1>
|
||||||
<p class="hero-desc">以下是系统快捷入口,也可以通过点击「添加」功能进行调整</p>
|
<p class="hero-desc">长按图标拖动可交换位置,或点击「添加」新增入口</p>
|
||||||
|
|
||||||
<div class="quick-grid">
|
<div class="quick-grid" :class="{ 'is-dragging': dragKey }">
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIdx) in quickEntryRowList"
|
||||||
|
:key="rowIdx"
|
||||||
|
class="quick-row"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="(item, idx) in quickEntries"
|
v-for="(item, idx) in row"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
class="quick-item"
|
class="quick-item"
|
||||||
:class="{ primary: item.primary }"
|
:class="{
|
||||||
:style="{ animationDelay: `${idx * 60}ms` }"
|
primary: item.primary,
|
||||||
:title="item.hint ?? item.label"
|
'is-add': item.key === 'add',
|
||||||
@click="onQuickClick(item)">
|
'is-dragging-source': dragKey === item.key,
|
||||||
|
'is-drag-hover-target': hoverTargetKey === item.key
|
||||||
|
}"
|
||||||
|
:data-quick-key="item.key"
|
||||||
|
:style="{ animationDelay: `${(rowIdx === 0 ? 0 : quickEntryRows.top.length) + idx * 60}ms` }"
|
||||||
|
:title="quickItemTitle(item)"
|
||||||
|
@pointerdown="onQuickPointerDown(item, $event)"
|
||||||
|
@click="onQuickClick(item)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="item.key !== 'add'"
|
||||||
|
class="quick-item-remove"
|
||||||
|
title="移除"
|
||||||
|
@pointerdown.stop
|
||||||
|
@click.stop="onRemoveQuick(item.key)"
|
||||||
|
>×</span>
|
||||||
<span class="quick-icon">
|
<span class="quick-icon">
|
||||||
<el-icon :size="22"><component :is="item.icon" /></el-icon>
|
<el-icon :size="22"><component :is="item.icon" /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
@@ -62,6 +83,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 右侧工业 3D 插画(SVG 绘制紫色风格场景) -->
|
<!-- 右侧工业 3D 插画(SVG 绘制紫色风格场景) -->
|
||||||
<div class="hero-right" aria-hidden="true">
|
<div class="hero-right" aria-hidden="true">
|
||||||
@@ -334,18 +356,41 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QuickEntryPickerDialog
|
||||||
|
v-model="pickerOpen"
|
||||||
|
:items="pickerCatalog"
|
||||||
|
:pinned-keys="pinnedKeys"
|
||||||
|
:can-add="canAddMore"
|
||||||
|
@pick="onAddQuick" />
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="dragKey && dragGhostItem"
|
||||||
|
class="quick-drag-ghost"
|
||||||
|
:style="{ left: `${ghostPos.x}px`, top: `${ghostPos.y}px` }"
|
||||||
|
>
|
||||||
|
<span class="quick-icon">
|
||||||
|
<el-icon :size="22"><component :is="dragGhostItem.icon" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="quick-label">{{ dragGhostItem.label }}</span>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Avatar, Bell, Box, Coordinate, Cpu, Lightning,
|
Bell, Box, Coordinate, Cpu, Lightning,
|
||||||
List, Operation, PieChart, Plus, Setting,
|
Operation, PieChart, Plus,
|
||||||
Tools, TrendCharts, Van, Warning
|
TrendCharts, Van
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
import QuickEntryPickerDialog from '@/components/dashboard/QuickEntryPickerDialog.vue'
|
||||||
|
import { useDashboardQuickEntries } from '@/composables/useDashboardQuickEntries'
|
||||||
|
import { useQuickEntryDragSwap } from '@/composables/useQuickEntryDragSwap'
|
||||||
import { listCars, listMissions, listSites, listTracks } from '@/api/projection'
|
import { listCars, listMissions, listSites, listTracks } from '@/api/projection'
|
||||||
import type { Site, Track } from '@/types/map'
|
import type { Site, Track } from '@/types/map'
|
||||||
import type { Car } from '@/types/car'
|
import type { Car } from '@/types/car'
|
||||||
@@ -353,6 +398,112 @@ import type { Mission } from '@/types/mission'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const {
|
||||||
|
resolvedEntries,
|
||||||
|
pickerCatalog,
|
||||||
|
pinnedKeys,
|
||||||
|
pickerOpen,
|
||||||
|
canAddMore,
|
||||||
|
openPicker,
|
||||||
|
addKey,
|
||||||
|
removeKey,
|
||||||
|
swapKeys
|
||||||
|
} = useDashboardQuickEntries()
|
||||||
|
|
||||||
|
interface QuickTile {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: unknown
|
||||||
|
hint?: string
|
||||||
|
primary?: boolean
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayQuickEntries = computed<QuickTile[]>(() => {
|
||||||
|
const items: QuickTile[] = resolvedEntries.value.map((e) => ({
|
||||||
|
key: e.key,
|
||||||
|
label: e.label,
|
||||||
|
icon: e.icon,
|
||||||
|
hint: e.hint,
|
||||||
|
primary: e.primary,
|
||||||
|
path: e.path
|
||||||
|
}))
|
||||||
|
if (canAddMore.value) {
|
||||||
|
items.push({
|
||||||
|
key: 'add',
|
||||||
|
label: '添加',
|
||||||
|
icon: Plus,
|
||||||
|
hint: '从菜单添加快捷入口'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 上下两行均衡分布:5 个 → 上 3 / 下 2,6 个 → 上 3 / 下 3 */
|
||||||
|
const quickEntryRows = computed(() => {
|
||||||
|
const all = displayQuickEntries.value
|
||||||
|
const topCount = Math.ceil(all.length / 2)
|
||||||
|
return {
|
||||||
|
top: all.slice(0, topCount),
|
||||||
|
bottom: all.slice(topCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const quickEntryRowList = computed(() => {
|
||||||
|
const { top, bottom } = quickEntryRows.value
|
||||||
|
return bottom.length ? [top, bottom] : [top]
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
dragKey,
|
||||||
|
hoverTargetKey,
|
||||||
|
ghostPos,
|
||||||
|
onPointerDown: onQuickPointerDown,
|
||||||
|
shouldSuppressClick
|
||||||
|
} = useQuickEntryDragSwap(async (from, to) => {
|
||||||
|
await swapKeys(from, to)
|
||||||
|
ElMessage.success('已交换位置')
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragGhostItem = computed(() => {
|
||||||
|
if (!dragKey.value) return null
|
||||||
|
return displayQuickEntries.value.find((i) => i.key === dragKey.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
function quickItemTitle(item: QuickTile): string {
|
||||||
|
if (item.key === 'add') return item.hint ?? item.label
|
||||||
|
if (dragKey.value === item.key) return '拖动到目标图标上松开以交换位置'
|
||||||
|
return item.hint ?? item.label
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQuickClick(item: QuickTile) {
|
||||||
|
if (shouldSuppressClick()) return
|
||||||
|
|
||||||
|
if (item.key === 'add') {
|
||||||
|
openPicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.path) router.push(item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddQuick(key: string) {
|
||||||
|
await addKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoveQuick(key: string) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定从快捷入口移除此项?', '移除快捷入口', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '移除',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await removeKey(key)
|
||||||
|
} catch {
|
||||||
|
/* cancel */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const tracks = ref<Track[]>([])
|
const tracks = ref<Track[]>([])
|
||||||
const cars = ref<Car[]>([])
|
const cars = ref<Car[]>([])
|
||||||
@@ -388,23 +539,6 @@ const activeMissionCount = computed(() =>
|
|||||||
missions.value.filter((m) => m.status === 'running' || m.status === 'assigned').length
|
missions.value.filter((m) => m.status === 'running' || m.status === 'assigned').length
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── 快捷入口 ───
|
|
||||||
interface QuickEntry { key: string; label: string; icon: unknown; hint?: string; primary?: boolean; to?: string; action?: () => void }
|
|
||||||
const quickEntries = computed<QuickEntry[]>(() => [
|
|
||||||
{ key: 'platform-config', label: '平台配置', icon: Setting, primary: true, hint: '进入地图编辑 / 平台搭建(map-editor)', to: '/admin/map-editor' },
|
|
||||||
{ key: 'mission', label: '任务编排', icon: Operation, to: '/admin/task-templates' },
|
|
||||||
{ key: 'cars', label: 'AGV 配置', icon: Van, to: '/admin/cars' },
|
|
||||||
{ key: 'auth', label: '权限配置', icon: Avatar, to: '/admin/config/system-center?tab=auth' },
|
|
||||||
{ key: 'system', label: '系统配置', icon: Tools, to: '/admin/config/system-center?tab=system' },
|
|
||||||
{ key: 'ops', label: '异常处理', icon: Warning, to: '/admin/config/ops-center?tab=ops' },
|
|
||||||
{ key: 'tasks', label: '任务管理', icon: List, to: '/admin/config/strategy?tab=task' },
|
|
||||||
{ key: 'add', label: '添加', icon: Plus, hint: '自定义快捷入口(待实现)', action: () => { ElMessage.info('自定义快捷入口 — 即将在 v1.8 中开放') } }
|
|
||||||
])
|
|
||||||
function onQuickClick(item: QuickEntry) {
|
|
||||||
if (item.action) item.action()
|
|
||||||
else if (item.to) router.push(item.to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── KPI(每张卡片含 trend + sparkline) ───
|
// ─── KPI(每张卡片含 trend + sparkline) ───
|
||||||
type KpiTone = 'info' | 'success' | 'warning' | 'danger' | 'idle'
|
type KpiTone = 'info' | 'success' | 'warning' | 'danger' | 'idle'
|
||||||
interface KpiTrend { dir: 'up' | 'down' | 'flat'; arrow: '↑' | '↓' | '→'; text: string }
|
interface KpiTrend { dir: 'up' | 'down' | 'flat'; arrow: '↑' | '↓' | '→'; text: string }
|
||||||
@@ -830,12 +964,19 @@ onUnmounted(() => {
|
|||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───── 快捷入口栅格(按截图 8 项,两行 4 列) ───── */
|
/* ───── 快捷入口:上下两行均衡左对齐(最多 16 项 + 添加) ───── */
|
||||||
.quick-grid {
|
.quick-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
.quick-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 14px 18px;
|
gap: 14px 18px;
|
||||||
max-width: 540px;
|
|
||||||
}
|
}
|
||||||
.quick-item {
|
.quick-item {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -843,6 +984,8 @@ onUnmounted(() => {
|
|||||||
border: 1px solid rgba(var(--mg-accent-rgb), 0.35);
|
border: 1px solid rgba(var(--mg-accent-rgb), 0.35);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 14px 10px 12px;
|
padding: 14px 10px 12px;
|
||||||
|
width: 118px;
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -854,6 +997,84 @@ onUnmounted(() => {
|
|||||||
0 4px 12px rgba(0, 0, 0, 0.25),
|
0 4px 12px rgba(0, 0, 0, 0.25),
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||||
}
|
}
|
||||||
|
.quick-item-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 8px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s, background .2s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.quick-item:hover .quick-item-remove { opacity: 1; }
|
||||||
|
.quick-item-remove:hover {
|
||||||
|
background: rgba(var(--mg-status-danger-rgb), 0.75);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.quick-item.is-add .quick-item-remove { display: none; }
|
||||||
|
.quick-grid.is-dragging {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.quick-grid.is-dragging .quick-item:not(.is-add) {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.quick-item.is-dragging-source {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.quick-item.is-drag-hover-target {
|
||||||
|
border-color: rgba(var(--mg-accent-rgb), 0.95);
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.35);
|
||||||
|
transform: translateY(-2px) scale(1.05);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(var(--mg-accent-rgb), 0.65),
|
||||||
|
0 12px 28px rgba(var(--mg-primary-rgb), 0.45);
|
||||||
|
}
|
||||||
|
.quick-drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 118px;
|
||||||
|
padding: 14px 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(var(--mg-accent-rgb), 0.85);
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.92);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 40px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 0 2px rgba(var(--mg-accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
.quick-drag-ghost .quick-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
.quick-drag-ghost .quick-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
@keyframes quick-in {
|
@keyframes quick-in {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -898,11 +1119,19 @@ onUnmounted(() => {
|
|||||||
0 0 0 1px rgba(255, 255, 255, 0.35) inset;
|
0 0 0 1px rgba(255, 255, 255, 0.35) inset;
|
||||||
}
|
}
|
||||||
.quick-label {
|
.quick-label {
|
||||||
|
width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
.quick-item.primary {
|
.quick-item.primary {
|
||||||
border-color: rgba(var(--mg-accent-rgb), 0.75);
|
border-color: rgba(var(--mg-accent-rgb), 0.75);
|
||||||
@@ -1551,8 +1780,12 @@ onUnmounted(() => {
|
|||||||
.bento-agv { grid-column: span 12; }
|
.bento-agv { grid-column: span 12; }
|
||||||
.bento-ratio { grid-column: span 12; }
|
.bento-ratio { grid-column: span 12; }
|
||||||
}
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.quick-item { width: 104px; }
|
||||||
|
.quick-row { gap: 10px 12px; flex-wrap: wrap; max-width: 100%; }
|
||||||
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.quick-grid { grid-template-columns: repeat(2, 1fr); }
|
.quick-item { width: 92px; }
|
||||||
.bento-kpi { grid-column: span 12; }
|
.bento-kpi { grid-column: span 12; }
|
||||||
.agv-row { flex-direction: column; align-items: stretch; }
|
.agv-row { flex-direction: column; align-items: stretch; }
|
||||||
.agv-chart { width: 100%; height: 220px; }
|
.agv-chart { width: 100%; height: 220px; }
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ import {
|
|||||||
promptSaveMode,
|
promptSaveMode,
|
||||||
resolveCurrentMapName
|
resolveCurrentMapName
|
||||||
} from '@/utils/projectSaveFlow'
|
} from '@/utils/projectSaveFlow'
|
||||||
|
import { defaultVrHost } from '@/utils/vrender'
|
||||||
import {
|
import {
|
||||||
reflectionApi,
|
reflectionApi,
|
||||||
normalizeViewportPayload,
|
normalizeViewportPayload,
|
||||||
@@ -190,7 +191,7 @@ import {
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
const vrHost = defaultVrHost()
|
||||||
|
|
||||||
// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入;
|
// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入;
|
||||||
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图(?new=1)时为空。
|
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图(?new=1)时为空。
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function loadMapContent(name: string) {
|
|||||||
viewingPath.value = ''
|
viewingPath.value = ''
|
||||||
try {
|
try {
|
||||||
const r = await mapsApi.readContent(name)
|
const r = await mapsApi.readContent(name)
|
||||||
viewingPath.value = r.path
|
viewingPath.value = r.fileName
|
||||||
viewingJsonRaw.value = r.content
|
viewingJsonRaw.value = r.content
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`加载地图配置失败:${(err as Error).message}`)
|
ElMessage.error(`加载地图配置失败:${(err as Error).message}`)
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import type { DeliveryTask } from '@/types/delivery'
|
|||||||
import type { SelectedObjectRef } from '@/types/workbench'
|
import type { SelectedObjectRef } from '@/types/workbench'
|
||||||
import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache'
|
import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache'
|
||||||
import type { MapFocusKind } from '@/utils/mapObjectFocus'
|
import type { MapFocusKind } from '@/utils/mapObjectFocus'
|
||||||
|
import { defaultVrHost } from '@/utils/vrender'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
|
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
|
||||||
@@ -109,7 +110,7 @@ defineProps<{
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
const vrHost = defaultVrHost()
|
||||||
|
|
||||||
const cars = ref<Car[]>([])
|
const cars = ref<Car[]>([])
|
||||||
const missions = ref<Mission[]>([])
|
const missions = ref<Mission[]>([])
|
||||||
|
|||||||
@@ -58,12 +58,37 @@
|
|||||||
<el-button size="small" :icon="Refresh" :loading="loading || healthLoading" @click="refreshAll">
|
<el-button size="small" :icon="Refresh" :loading="loading || healthLoading" @click="refreshAll">
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-radio-group
|
||||||
|
v-model="viewMode"
|
||||||
|
size="small"
|
||||||
|
class="view-toggle"
|
||||||
|
@change="viewModeTouched = true"
|
||||||
|
>
|
||||||
|
<el-radio-button value="grid">卡片</el-radio-button>
|
||||||
|
<el-radio-button value="list">列表</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-loading="loading && !cardModels.length" class="card-grid">
|
<div
|
||||||
|
v-loading="loading && !cardModels.length"
|
||||||
|
class="vehicle-scroll"
|
||||||
|
:class="viewMode === 'list' ? 'is-list' : 'is-grid'"
|
||||||
|
>
|
||||||
|
<div v-if="viewMode === 'list' && sortedCards.length" class="list-head">
|
||||||
|
<span>车辆</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>电量</span>
|
||||||
|
<span>IP</span>
|
||||||
|
<span>延迟</span>
|
||||||
|
<span>故障率</span>
|
||||||
|
<span>群组</span>
|
||||||
|
<span class="head-actions">操作</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="viewMode === 'grid'">
|
||||||
<VehicleHealthCard
|
<VehicleHealthCard
|
||||||
v-for="v in filteredCards"
|
v-for="v in sortedCards"
|
||||||
:key="v.id"
|
:key="v.id"
|
||||||
:vehicle="v"
|
:vehicle="v"
|
||||||
:selected="selectedId === v.id"
|
:selected="selectedId === v.id"
|
||||||
@@ -71,7 +96,20 @@
|
|||||||
@select="selectedId = $event"
|
@select="selectedId = $event"
|
||||||
@maintenance-changed="refreshAll"
|
@maintenance-changed="refreshAll"
|
||||||
/>
|
/>
|
||||||
<el-empty v-if="!filteredCards.length && !loading" description="无匹配车辆" />
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<VehicleHealthRow
|
||||||
|
v-for="v in sortedCards"
|
||||||
|
:key="v.id"
|
||||||
|
:vehicle="v"
|
||||||
|
:selected="selectedId === v.id"
|
||||||
|
:can-write="canWrite"
|
||||||
|
@select="selectedId = $event"
|
||||||
|
@maintenance-changed="refreshAll"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty v-if="!sortedCards.length && !loading" description="无匹配车辆" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
|
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
|
||||||
@@ -101,13 +139,14 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
||||||
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
|
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
|
||||||
|
import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue'
|
||||||
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
|
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
|
||||||
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
|
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
|
||||||
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
|
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
|
||||||
import { useVehicleHub } from '@/composables/useVehicleHub'
|
import { useVehicleHub } from '@/composables/useVehicleHub'
|
||||||
import { useFleetGroups } from '@/composables/useFleetGroups'
|
import { useFleetGroups } from '@/composables/useFleetGroups'
|
||||||
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||||
import type { CarState } from '@/types/car'
|
import type { CarState, VehicleCardModel } from '@/types/car'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -147,6 +186,10 @@ const {
|
|||||||
const { groups: fleetGroups, reload: reloadFleetGroups, fleetNameForCarId, regionForCarId } = useFleetGroups()
|
const { groups: fleetGroups, reload: reloadFleetGroups, fleetNameForCarId, regionForCarId } = useFleetGroups()
|
||||||
onMounted(() => void reloadFleetGroups())
|
onMounted(() => void reloadFleetGroups())
|
||||||
|
|
||||||
|
const DENSE_THRESHOLD = 12
|
||||||
|
const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
|
const viewModeTouched = ref(false)
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const filterState = ref<CarState | ''>('')
|
const filterState = ref<CarState | ''>('')
|
||||||
const filterFleet = ref('')
|
const filterFleet = ref('')
|
||||||
@@ -191,11 +234,37 @@ const filteredCards = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function vehicleSortPriority(v: VehicleCardModel): number {
|
||||||
|
if (v.isAlarmActive) return 0
|
||||||
|
if (v.reachable === false) return 1
|
||||||
|
if (v.state === 'fault') return 2
|
||||||
|
if (v.maintenanceMode && v.maintenanceMode !== 'online') return 3
|
||||||
|
if (v.state === 'running') return 4
|
||||||
|
if (v.state === 'charging') return 5
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedCards = computed(() =>
|
||||||
|
[...filteredCards.value].sort((a, b) => {
|
||||||
|
const d = vehicleSortPriority(a) - vehicleSortPriority(b)
|
||||||
|
return d !== 0 ? d : a.name.localeCompare(b.name, 'zh-CN')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sortedCards.value.length,
|
||||||
|
(n) => {
|
||||||
|
if (viewModeTouched.value) return
|
||||||
|
viewMode.value = n > DENSE_THRESHOLD ? 'list' : 'grid'
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const selectedIds = computed(() => (selectedId.value ? [selectedId.value] : []))
|
const selectedIds = computed(() => (selectedId.value ? [selectedId.value] : []))
|
||||||
|
|
||||||
async function onBatchCommand(cmd: string) {
|
async function onBatchCommand(cmd: string) {
|
||||||
const mode = cmd as VehicleMaintenanceMode
|
const mode = cmd as VehicleMaintenanceMode
|
||||||
const targets = filteredCards.value.filter((c) => selectedId.value ? c.id === selectedId.value : true)
|
const targets = sortedCards.value.filter((c) => selectedId.value ? c.id === selectedId.value : true)
|
||||||
if (!targets.length) return
|
if (!targets.length) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`对 ${targets.length} 辆车执行「${cmd}」?`, '批量维护', { type: 'warning' })
|
await ElMessageBox.confirm(`对 ${targets.length} 辆车执行「${cmd}」?`, '批量维护', { type: 'warning' })
|
||||||
@@ -317,16 +386,61 @@ async function onBatchCommand(cmd: string) {
|
|||||||
width: 160px;
|
width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.view-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
align-content: start;
|
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vehicle-scroll.is-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
grid-auto-rows: max-content;
|
||||||
|
gap: 14px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-scroll.is-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 1.4fr) 72px 80px 100px 64px 64px 56px 88px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px 4px 14px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.45));
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(var(--mg-bg-card-darker-rgb, 20, 12, 48), 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .head-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.list-head { display: none; }
|
||||||
|
.vehicle-scroll.is-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.footnote {
|
.footnote {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"dev": "pnpm --filter simple-platform-vue dev",
|
"dev": "pnpm --filter simple-platform-vue dev",
|
||||||
"build": "pnpm --filter simple-platform-vue build",
|
"build": "pnpm --filter simple-platform-vue build",
|
||||||
"preview": "pnpm --filter simple-platform-vue preview",
|
"preview": "pnpm --filter simple-platform-vue preview",
|
||||||
"lint": "pnpm --filter simple-platform-vue lint"
|
"typecheck": "pnpm --filter simple-platform-vue typecheck"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user