fix(platform): 代码审查整改——反代按域拆分授权、根除探测副作用与死代码清理
- YARP: map-edit/ai-config 全方法、reflection 写方法挂 PlatformScope, reflection/selection 单独放行(运营端 3D 高亮),堵住运营账号直达地图编辑/反射调用 - goto-site 探测改用不存在的 car/-1(消除健康检查真实派车风险)并加 60s 缓存 - Config PUT 按 scope 收紧:RCSMonitor 仅可写 ops 节;wizard 写操作与 simplelite/restart-for-update 限 PlatformScope;/api/health 去除虚假端口表 - 修复 wms 模块菜单裁剪失效(admin-config-location → admin-config-facility) - vrHost 默认 location.hostname:8223(新增 utils/vrender.ts),远程访问 3D 视口可用 - /status 页改接真实 /api/health* 诊断;uploadAsset 移除矛盾 multipart 头; mapsApi.merge 对齐 save 的 409 冲突处理;JWT 验签参数改启动期 DI 一次性配置 - 清理死代码:ProjectionController、DataTablePro、useClipboard、CadToolbarView、 AppShell 未用导入;lint 脚本替换为 typecheck;日志窗口 List 改 Queue
This commit is contained in:
@@ -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-location" },
|
["wms"] = new[] { "admin-config-facility" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace MiGu.Server.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
|
||||||
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
|
||||||
///
|
|
||||||
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/projection")]
|
|
||||||
public class ProjectionController : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet("sites")]
|
|
||||||
public IActionResult Sites() => Ok(new[]
|
|
||||||
{
|
|
||||||
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
|
|
||||||
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
|
|
||||||
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
|
|
||||||
});
|
|
||||||
|
|
||||||
[HttpGet("tracks")]
|
|
||||||
public IActionResult Tracks() => Ok(new[]
|
|
||||||
{
|
|
||||||
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
|
|
||||||
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
|
|
||||||
});
|
|
||||||
|
|
||||||
[HttpGet("cars")]
|
|
||||||
public IActionResult Cars() => Ok(new[]
|
|
||||||
{
|
|
||||||
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
|
|
||||||
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
|
|
||||||
});
|
|
||||||
|
|
||||||
[HttpGet("missions")]
|
|
||||||
public IActionResult Missions() => Ok(new[]
|
|
||||||
{
|
|
||||||
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
|
|
||||||
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -92,6 +93,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)
|
||||||
|
|||||||
@@ -392,12 +392,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;
|
||||||
|
|||||||
+7
-14
@@ -156,15 +156,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 =>
|
||||||
@@ -175,13 +166,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 =>
|
||||||
{
|
{
|
||||||
@@ -206,8 +198,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-card class="dtp-card" shadow="never">
|
|
||||||
<template #header>
|
|
||||||
<div class="dtp-header">
|
|
||||||
<span class="dtp-title">{{ title }}</span>
|
|
||||||
<div class="dtp-actions">
|
|
||||||
<el-input v-if="searchable" v-model="kw" :placeholder="searchPlaceholder" clearable size="small" style="width: 220px" />
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-table :data="filtered" stripe size="small" :max-height="maxHeight" border>
|
|
||||||
<el-table-column v-for="c in columns" :key="c.prop" :prop="c.prop" :label="c.label" :width="c.width" :min-width="c.minWidth">
|
|
||||||
<template #default="scope">
|
|
||||||
<slot :name="`col-${c.prop}`" :row="scope.row">
|
|
||||||
{{ scope.row[c.prop] }}
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<slot name="extra-columns" />
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
interface Column { prop: string; label: string; width?: number | string; minWidth?: number | string }
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
title: string
|
|
||||||
data: Array<Record<string, unknown>>
|
|
||||||
columns: Column[]
|
|
||||||
searchable?: boolean
|
|
||||||
searchPlaceholder?: string
|
|
||||||
maxHeight?: number | string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const kw = ref('')
|
|
||||||
|
|
||||||
const filtered = computed(() => {
|
|
||||||
if (!props.searchable || !kw.value) return props.data
|
|
||||||
const q = kw.value.trim().toLowerCase()
|
|
||||||
return props.data.filter((row) =>
|
|
||||||
Object.values(row).some((v) => String(v ?? '').toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dtp-header { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.dtp-title { font-weight: 600; }
|
|
||||||
.dtp-actions { display: flex; gap: 8px; align-items: center; }
|
|
||||||
</style>
|
|
||||||
@@ -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,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 }
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,7 @@ import { computed } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Fold, Expand, CaretBottom, Monitor, Setting, Histogram, Tools,
|
Fold, Expand, CaretBottom, Monitor, Setting, Histogram, Tools,
|
||||||
MapLocation, Van, Promotion, Box, OfficeBuilding, Notebook
|
MapLocation, Van, Promotion, Notebook
|
||||||
} from '@element-plus/icons-vue'
|
} 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'
|
||||||
@@ -209,8 +209,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()
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* webVRender (SimpleLite 3D 视口, 默认 :8223) 的 host 解析。
|
||||||
|
*
|
||||||
|
* 优先级:显式 VITE_VRENDER_HOST > 当前页面 hostname:8223。
|
||||||
|
* 不能写死 localhost —— 从远程浏览器访问平台时 iframe 会去连访问者本机而非服务器。
|
||||||
|
*/
|
||||||
|
export function defaultVrHost(): string {
|
||||||
|
const env = import.meta.env.VITE_VRENDER_HOST as string | undefined
|
||||||
|
if (env && env.trim()) return env.trim()
|
||||||
|
return `${window.location.hostname}:8223`
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -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[]>([])
|
||||||
|
|||||||
@@ -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