Merge branch 'main' of http://it.fairylandtech.com:8666/Third_Dev_Dept/Migu2.0
This commit is contained in:
@@ -29,6 +29,7 @@ public static class PageCatalog
|
||||
{
|
||||
// ── 管理端 / Platform:概览 ──
|
||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||
new("admin-setup", "初始配置", "概览", ScopePlatform),
|
||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
||||
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
||||
@@ -45,6 +46,9 @@ public static class PageCatalog
|
||||
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
|
||||
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
|
||||
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||
@@ -87,6 +91,11 @@ public static class PageCatalog
|
||||
["admin-config-map-monitor"] = "admin-config-ops-center",
|
||||
["admin-config-system"] = "admin-config-system-center",
|
||||
["admin-config-auth"] = "admin-config-system-center",
|
||||
["admin-data-center-stations"] = "admin-data-center",
|
||||
["admin-data-center-docks"] = "admin-data-center",
|
||||
["admin-data-center-handshake"] = "admin-data-center",
|
||||
["admin-data-center-release"] = "admin-data-center",
|
||||
["admin-data-center-mag-control"] = "admin-data-center",
|
||||
};
|
||||
|
||||
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||
|
||||
@@ -126,6 +126,12 @@ public sealed class RbacStore
|
||||
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-simple-fields");
|
||||
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||
&& !r.Pages.Contains("admin-setup", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-cars", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-maps", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-setup");
|
||||
}
|
||||
|
||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||
|
||||
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
|
||||
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
||||
/// <summary>
|
||||
/// 场景插件 → 平台页面 Key。选 SPS / Pack 时 ToLauncherSceneIds 会并入
|
||||
/// <see cref="DeploymentProfile.SignalSceneId"/>,从而点亮「数据中心」。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> SceneToPages =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。</summary>
|
||||
public static IReadOnlyCollection<string> TailorablePages()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
||||
foreach (var v in SceneToPages.Values) foreach (var p in v) set.Add(p);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
|
||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (dp == null) return set;
|
||||
foreach (var m in dp.Modules ?? new List<string>())
|
||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
||||
|
||||
var activeScenes = dp.ToLauncherSceneIds();
|
||||
foreach (var (sceneId, pages) in SceneToPages)
|
||||
{
|
||||
if (!activeScenes.Contains(sceneId, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
foreach (var p in pages) set.Add(p);
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,4 +64,36 @@ public record DeploymentProfile(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>信号交互场景 id(PLC 握手)。仅 SPS / Pack 业务场景时加载。</summary>
|
||||
public const string SignalSceneId = "scene.signal";
|
||||
|
||||
/// <summary>设备驱动场景 id(门/充电桩等,各类项目通用)。</summary>
|
||||
public const string DeviceSceneId = "scene.device";
|
||||
|
||||
public const string ScenarioSps = "tpl-sps";
|
||||
public const string ScenarioPack = "tpl-pack";
|
||||
|
||||
/// <summary>是否选了磁导航。</summary>
|
||||
public bool UsesMagnetic() =>
|
||||
NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
|
||||
/// <summary>是否选了需要 scene.signal 的业务场景(SPS 物料配送 / 电池 Pack 产线)。</summary>
|
||||
public bool UsesSignalPlugin() =>
|
||||
Scenarios?.Any(s =>
|
||||
string.Equals(s, ScenarioSps, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(s, ScenarioPack, StringComparison.OrdinalIgnoreCase)) == true;
|
||||
|
||||
/// <summary>
|
||||
/// 写入 active-scenes.json 的完整场景集合:导航插件 + SPS/Pack 时并入 scene.signal + scene.device。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ToLauncherSceneIds()
|
||||
{
|
||||
var result = ToActiveSceneIds().ToList();
|
||||
if (UsesSignalPlugin() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase))
|
||||
result.Add(SignalSceneId);
|
||||
if (!result.Contains(DeviceSceneId, StringComparer.OrdinalIgnoreCase))
|
||||
result.Add(DeviceSceneId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Signal;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
|
||||
/// 文件落在 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,不依赖 SimpleLite 进程是否在跑。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
[Route("api/signal-data")]
|
||||
public sealed class SignalDataController : ControllerBase
|
||||
{
|
||||
private readonly SignalDataStore _store;
|
||||
private readonly ConfigStore _config;
|
||||
|
||||
public SignalDataController(SignalDataStore store, ConfigStore config)
|
||||
{
|
||||
_store = store;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult List([FromQuery] bool summary = false)
|
||||
{
|
||||
var enabled = SignalEnabled();
|
||||
var tables = summary
|
||||
? _store.GetTables().Select(ProjectSummary).ToList()
|
||||
: _store.GetTables().Select(ProjectTable).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
signalEnabled = enabled,
|
||||
workingDirectory = _store.ResolveWorkingDirectory(),
|
||||
tables
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public IActionResult Get(string id)
|
||||
{
|
||||
var table = _store.Find(id);
|
||||
if (table == null)
|
||||
return NotFound(new { message = $"未知数据表:{id}" });
|
||||
return Ok(ProjectTable(table));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public IActionResult Save(string id, [FromBody] JsonElement body)
|
||||
{
|
||||
var table = _store.Find(id);
|
||||
if (table == null)
|
||||
return NotFound(new { message = $"未知数据表:{id}" });
|
||||
|
||||
if (!body.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
return BadRequest(new { message = "请求体需要 rows 数组" });
|
||||
|
||||
JsonArray rows;
|
||||
try
|
||||
{
|
||||
rows = JsonNode.Parse(rowsEl.GetRawText()) as JsonArray ?? new JsonArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = $"rows 不是合法 JSON 数组:{ex.Message}" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_store.SaveRows(table, rows);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { message = $"保存失败:{ex.Message}" });
|
||||
}
|
||||
|
||||
return Ok(ProjectTable(table));
|
||||
}
|
||||
|
||||
private object ProjectSummary(SignalTableDef table) => new
|
||||
{
|
||||
id = table.Id,
|
||||
title = table.Title,
|
||||
category = table.Category,
|
||||
fileName = table.FileName
|
||||
};
|
||||
|
||||
private object ProjectTable(SignalTableDef table)
|
||||
{
|
||||
var (path, error) = _store.ResolveFile(table, createDir: false);
|
||||
var exists = path != null && System.IO.File.Exists(path);
|
||||
return new
|
||||
{
|
||||
id = table.Id,
|
||||
title = table.Title,
|
||||
category = table.Category,
|
||||
fileName = table.FileName,
|
||||
exists,
|
||||
error,
|
||||
columns = table.Columns,
|
||||
rows = _store.LoadRows(table)
|
||||
};
|
||||
}
|
||||
|
||||
private bool SignalEnabled() =>
|
||||
_config.GetDeployment().ToLauncherSceneIds()
|
||||
.Contains(DeploymentProfile.SignalSceneId, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -84,9 +84,8 @@ public class WizardController : ControllerBase
|
||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
||||
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
||||
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
||||
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手)。
|
||||
var sceneIds = profile.ToLauncherSceneIds();
|
||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||
|
||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
|
||||
navigationKinds = dp.NavigationKinds,
|
||||
scenarios = dp.Scenarios,
|
||||
updatedBy = dp.UpdatedBy,
|
||||
activeSceneIds = dp.ToActiveSceneIds(),
|
||||
activeSceneIds = dp.ToLauncherSceneIds(),
|
||||
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
||||
activeScenesWrite = write == null ? null : new
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ public static class DashboardShortcutCatalog
|
||||
private static readonly ShortcutDef[] PlatformShortcuts =
|
||||
[
|
||||
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
|
||||
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
|
||||
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
|
||||
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
|
||||
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
|
||||
@@ -36,6 +37,7 @@ public static class DashboardShortcutCatalog
|
||||
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
|
||||
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
|
||||
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
||||
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MiGu.Server</RootNamespace>
|
||||
@@ -24,6 +25,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
|
||||
<ProjectReference Include="..\..\..\StandardSence\StandardScene.Signal\StandardScene.Signal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,6 +8,7 @@ using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.OpenApi;
|
||||
using MiGu.Server.Ota;
|
||||
using MiGu.Server.Signal;
|
||||
using MiGu.DB.Kernel.Hosting;
|
||||
using MiGu.Server.Persistence;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
@@ -264,6 +265,7 @@ builder.Services.AddPlatformPersistence(builder.Configuration);
|
||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||
builder.Services.AddSingleton<SignalDataStore>();
|
||||
|
||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Signal;
|
||||
|
||||
public sealed record SignalColumn(string Key, string Label, string Type, IReadOnlyList<string>? Options = null, string? Group = null);
|
||||
|
||||
public sealed record SignalTableDef(
|
||||
string Id,
|
||||
string Title,
|
||||
string Category,
|
||||
string FileName,
|
||||
IReadOnlyList<SignalColumn> Columns);
|
||||
|
||||
/// <summary>
|
||||
/// 读写 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
|
||||
/// 表结构优先从 StandardScene.Signal.dll 反射;无插件时才读 signal-tables.json。
|
||||
/// </summary>
|
||||
public sealed class SignalDataStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions FileJson = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private static readonly object FileLock = new();
|
||||
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private IReadOnlyList<SignalTableDef>? _cachedTables;
|
||||
private long _cachedSignature;
|
||||
|
||||
public SignalDataStore(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||
|
||||
public IReadOnlyList<SignalTableDef> GetTables()
|
||||
{
|
||||
var sig = SignalTableManifestLoader.ComputeManifestSignature(_launcher);
|
||||
if (_cachedTables == null || sig != _cachedSignature)
|
||||
{
|
||||
_cachedTables = SignalTableManifestLoader.Load(_launcher);
|
||||
_cachedSignature = sig;
|
||||
}
|
||||
|
||||
return _cachedTables;
|
||||
}
|
||||
|
||||
public SignalTableDef? Find(string id) =>
|
||||
GetTables().FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public string? ResolveWorkingDirectory() => _launcher.ResolveWorkingDirectory();
|
||||
|
||||
public (string? Path, string? Error) ResolveFile(SignalTableDef table, bool createDir)
|
||||
{
|
||||
var wd = _launcher.ResolveWorkingDirectory();
|
||||
if (string.IsNullOrWhiteSpace(wd))
|
||||
return (null, "未找到 SimpleLite 工作目录,无法定位 Config/Signal");
|
||||
|
||||
var dest = Path.GetFullPath(Path.Combine(wd, "Config", "Signal", table.FileName));
|
||||
if (File.Exists(dest))
|
||||
return (dest, null);
|
||||
|
||||
if (createDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(dest);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return (dest, null);
|
||||
}
|
||||
|
||||
public JsonArray LoadRows(SignalTableDef table)
|
||||
{
|
||||
var (path, _) = ResolveFile(table, createDir: false);
|
||||
if (path == null || !File.Exists(path))
|
||||
return new JsonArray();
|
||||
|
||||
lock (FileLock)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new JsonArray();
|
||||
var node = JsonNode.Parse(json);
|
||||
if (node is JsonArray arr)
|
||||
return arr;
|
||||
return new JsonArray();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveRows(SignalTableDef table, JsonArray rows)
|
||||
{
|
||||
var (path, error) = ResolveFile(table, createDir: true);
|
||||
if (path == null)
|
||||
throw new InvalidOperationException(error ?? "无法解析信号配置文件路径");
|
||||
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
lock (FileLock)
|
||||
File.WriteAllText(path, rows.ToJsonString(FileJson));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization;
|
||||
using StandardScene.Signal.Model;
|
||||
|
||||
namespace MiGu.Server.Signal;
|
||||
|
||||
/// <summary>
|
||||
/// 从 StandardScene.Signal 程序集反射数据中心表和列。
|
||||
/// 优先使用 MiGu.Server 编译期引用的程序集;否则再从 SimpleLite plugins 加载。
|
||||
/// </summary>
|
||||
public static class SignalModelSchemaResolver
|
||||
{
|
||||
private const string ModelNamespace = "StandardScene.Signal.Model";
|
||||
private const string AssemblyFileName = "StandardScene.Signal.dll";
|
||||
|
||||
public static string? FindAssemblyPath(string? pluginsDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginsDir))
|
||||
return null;
|
||||
var path = Path.Combine(pluginsDir, AssemblyFileName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SignalTableDef> ResolveTables(string? pluginsDir, string? workingDirectory = null)
|
||||
{
|
||||
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
|
||||
if (assembly == null)
|
||||
return Array.Empty<SignalTableDef>();
|
||||
|
||||
try
|
||||
{
|
||||
return BuildTablesFromAssembly(assembly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<SignalTableDef>();
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SignalColumn> ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
return Array.Empty<SignalColumn>();
|
||||
|
||||
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
|
||||
if (assembly == null)
|
||||
return Array.Empty<SignalColumn>();
|
||||
|
||||
try
|
||||
{
|
||||
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
|
||||
if (type == null)
|
||||
return Array.Empty<SignalColumn>();
|
||||
|
||||
return DiscoverColumns(type);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<SignalColumn>();
|
||||
}
|
||||
}
|
||||
|
||||
private static Assembly? TryGetSignalAssembly(string? pluginsDir, string? workingDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var referenced = typeof(PlcStationModel).Assembly;
|
||||
if (HasModelTypes(referenced))
|
||||
return referenced;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* 未引用插件工程时继续走 LoadFrom */
|
||||
}
|
||||
|
||||
var assemblyPath = FindAssemblyPath(pluginsDir);
|
||||
if (assemblyPath == null)
|
||||
return null;
|
||||
|
||||
var probeDirs = BuildProbeDirs(pluginsDir, workingDirectory);
|
||||
ResolveEventHandler? handler = null;
|
||||
handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
|
||||
AppDomain.CurrentDomain.AssemblyResolve += handler;
|
||||
try
|
||||
{
|
||||
var loaded = Assembly.LoadFrom(assemblyPath);
|
||||
return HasModelTypes(loaded) ? loaded : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handler != null)
|
||||
AppDomain.CurrentDomain.AssemblyResolve -= handler;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasModelTypes(Assembly assembly)
|
||||
{
|
||||
return SafeGetTypes(assembly).Any(t =>
|
||||
t != null && string.Equals(t.Namespace, ModelNamespace, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildProbeDirs(string? pluginsDir, string? workingDirectory)
|
||||
{
|
||||
var dirs = new List<string>();
|
||||
void Add(string? dir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) return;
|
||||
var full = Path.GetFullPath(dir);
|
||||
if (Directory.Exists(full) && !dirs.Contains(full, StringComparer.OrdinalIgnoreCase))
|
||||
dirs.Add(full);
|
||||
}
|
||||
|
||||
Add(pluginsDir);
|
||||
Add(workingDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(pluginsDir))
|
||||
Add(Path.GetDirectoryName(pluginsDir));
|
||||
Add(AppContext.BaseDirectory);
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
private static Assembly? ResolveAssembly(string? assemblyName, IReadOnlyList<string> probeDirs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assemblyName))
|
||||
return null;
|
||||
|
||||
string simpleName;
|
||||
try
|
||||
{
|
||||
simpleName = new AssemblyName(assemblyName).Name ?? assemblyName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
simpleName = assemblyName.Split(',')[0];
|
||||
}
|
||||
|
||||
foreach (var dir in probeDirs)
|
||||
{
|
||||
var path = Path.Combine(dir, simpleName + ".dll");
|
||||
if (!File.Exists(path))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
return Assembly.LoadFrom(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* try next dir */
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SignalTableDef> BuildTablesFromAssembly(Assembly assembly)
|
||||
{
|
||||
var found = new List<(int Order, SignalTableDef Table)>();
|
||||
foreach (var type in SafeGetTypes(assembly))
|
||||
{
|
||||
if (type == null || !string.Equals(type.Namespace, ModelNamespace, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var attr = type.GetCustomAttributes(inherit: false)
|
||||
.FirstOrDefault(a => a.GetType().Name == "SignalTableAttribute");
|
||||
if (attr == null)
|
||||
continue;
|
||||
|
||||
var attrType = attr.GetType();
|
||||
var id = (attrType.GetProperty("Id")?.GetValue(attr) as string ?? "").Trim();
|
||||
var fileName = (attrType.GetProperty("FileName")?.GetValue(attr) as string ?? "").Trim();
|
||||
var title = (attrType.GetProperty("Title")?.GetValue(attr) as string ?? "").Trim();
|
||||
var order = attrType.GetProperty("Order")?.GetValue(attr) as int? ?? 0;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(fileName))
|
||||
continue;
|
||||
|
||||
var category = type.GetCustomAttribute<CategoryAttribute>()?.Category ?? "";
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
title = type.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? id;
|
||||
|
||||
found.Add((order, new SignalTableDef(id, title, category, fileName, DiscoverColumns(type))));
|
||||
}
|
||||
|
||||
return found.OrderBy(x => x.Order).ThenBy(x => x.Table.Id).Select(x => x.Table).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(t => t != null)!;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SignalColumn> DiscoverColumns(Type type)
|
||||
{
|
||||
var list = new List<SignalColumn>();
|
||||
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
|
||||
.Where(p => !IsJsonIgnored(p))
|
||||
.OrderBy(p => p.MetadataToken))
|
||||
{
|
||||
var label = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
label = prop.Name;
|
||||
|
||||
var columnType = MapType(prop.PropertyType);
|
||||
string[]? options = null;
|
||||
var group = prop.GetCustomAttribute<CategoryAttribute>(inherit: false)?.Category;
|
||||
if (string.IsNullOrWhiteSpace(group))
|
||||
group = null;
|
||||
|
||||
var select = ReadSelectOptions(prop);
|
||||
if (select is { Length: > 0 })
|
||||
{
|
||||
columnType = "enum";
|
||||
options = select;
|
||||
}
|
||||
else if (columnType == "enum" && (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType).IsEnum)
|
||||
{
|
||||
options = Enum.GetNames(Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType);
|
||||
}
|
||||
|
||||
list.Add(new SignalColumn(prop.Name, label, columnType, options, group));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool IsJsonIgnored(PropertyInfo prop)
|
||||
{
|
||||
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() != null)
|
||||
return true;
|
||||
|
||||
foreach (var attr in prop.GetCustomAttributes(inherit: true))
|
||||
{
|
||||
if (attr.GetType().FullName == "Newtonsoft.Json.JsonIgnoreAttribute")
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string[]? ReadSelectOptions(PropertyInfo prop)
|
||||
{
|
||||
var attr = prop.GetCustomAttributes(inherit: false)
|
||||
.FirstOrDefault(a => a.GetType().Name == "SignalSelectAttribute");
|
||||
if (attr == null)
|
||||
return null;
|
||||
var options = attr.GetType().GetProperty("Options")?.GetValue(attr) as string[];
|
||||
return options is { Length: > 0 } ? options : null;
|
||||
}
|
||||
|
||||
private static string MapType(Type type)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
if (underlying == typeof(bool))
|
||||
return "bool";
|
||||
if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) ||
|
||||
underlying == typeof(byte) || underlying == typeof(uint) || underlying == typeof(ulong))
|
||||
return "int";
|
||||
if (underlying.IsEnum)
|
||||
return "enum";
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace MiGu.Server.Signal;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public sealed class SignalTableManifest
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
public List<SignalTableManifestEntry> Tables { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SignalTableManifestEntry
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
public string Category { get; set; } = "";
|
||||
|
||||
public string FileName { get; set; } = "";
|
||||
|
||||
public string Model { get; set; } = "";
|
||||
|
||||
public List<SignalTableColumnEntry>? Columns { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SignalTableColumnEntry
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
public string Type { get; set; } = "string";
|
||||
|
||||
[JsonPropertyName("options")]
|
||||
public string[]? Options { get; set; }
|
||||
|
||||
[JsonPropertyName("group")]
|
||||
public string? Group { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Signal;
|
||||
|
||||
/// <summary>
|
||||
/// 优先从 plugins/StandardScene.Signal.dll 反射表和列(Model 上的 SignalTable / DisplayName)。
|
||||
/// 没有插件 DLL 时才读 signal-tables.json 或内置清单。
|
||||
/// </summary>
|
||||
public static class SignalTableManifestLoader
|
||||
{
|
||||
private const string ManifestFileName = "signal-tables.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static IReadOnlyList<SignalTableDef> Load(SimpleLiteLauncher launcher)
|
||||
{
|
||||
var workingDirectory = launcher.ResolveWorkingDirectory();
|
||||
var pluginsDir = launcher.ResolvePluginsDir();
|
||||
var fromPlugin = SignalModelSchemaResolver.ResolveTables(pluginsDir, workingDirectory);
|
||||
if (fromPlugin.Count > 0)
|
||||
return fromPlugin;
|
||||
|
||||
var manifest = TryLoadManifest(launcher);
|
||||
return manifest.Tables
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.Id) && !string.IsNullOrWhiteSpace(t.FileName))
|
||||
.Select(e => ToDef(e, pluginsDir, workingDirectory))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static long ComputeManifestSignature(SimpleLiteLauncher launcher)
|
||||
{
|
||||
long sig = 0;
|
||||
foreach (var path in ResolveManifestPaths(launcher))
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
sig ^= info.LastWriteTimeUtc.Ticks;
|
||||
sig ^= info.Length;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var pluginsDir = launcher.ResolvePluginsDir();
|
||||
var dll = pluginsDir == null ? null : Path.Combine(pluginsDir, "StandardScene.Signal.dll");
|
||||
if (dll != null && File.Exists(dll))
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(dll);
|
||||
sig ^= info.LastWriteTimeUtc.Ticks;
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return sig;
|
||||
}
|
||||
|
||||
private static SignalTableManifest TryLoadManifest(SimpleLiteLauncher launcher)
|
||||
{
|
||||
foreach (var path in ResolveManifestPaths(launcher))
|
||||
{
|
||||
if (!File.Exists(path)) continue;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var manifest = JsonSerializer.Deserialize<SignalTableManifest>(json, JsonOptions);
|
||||
if (manifest?.Tables is { Count: > 0 })
|
||||
return manifest;
|
||||
}
|
||||
catch { /* try next */ }
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<SignalTableManifest>(EmbeddedFallbackJson, JsonOptions)
|
||||
?? new SignalTableManifest();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveManifestPaths(SimpleLiteLauncher launcher)
|
||||
{
|
||||
var wd = launcher.ResolveWorkingDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(wd))
|
||||
yield return Path.Combine(wd, "Config", "Signal", ManifestFileName);
|
||||
|
||||
var plugins = launcher.ResolvePluginsDir();
|
||||
if (string.IsNullOrWhiteSpace(plugins)) yield break;
|
||||
|
||||
yield return Path.Combine(plugins, ManifestFileName);
|
||||
yield return Path.Combine(plugins, "Config", "Signal", ManifestFileName);
|
||||
}
|
||||
|
||||
private static SignalTableDef ToDef(SignalTableManifestEntry entry, string? pluginsDir, string? workingDirectory)
|
||||
{
|
||||
var columns = BuildColumns(entry, pluginsDir, workingDirectory);
|
||||
return new SignalTableDef(
|
||||
entry.Id.Trim(),
|
||||
string.IsNullOrWhiteSpace(entry.Title) ? entry.Id.Trim() : entry.Title.Trim(),
|
||||
entry.Category ?? "",
|
||||
entry.FileName.Trim(),
|
||||
columns);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SignalColumn> BuildColumns(
|
||||
SignalTableManifestEntry entry,
|
||||
string? pluginsDir,
|
||||
string? workingDirectory)
|
||||
{
|
||||
var reflected = SignalModelSchemaResolver.ResolveColumns(entry.Model, pluginsDir, workingDirectory);
|
||||
if (reflected.Count > 0)
|
||||
return reflected;
|
||||
|
||||
if (entry.Columns is { Count: > 0 })
|
||||
{
|
||||
return entry.Columns
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Key))
|
||||
.Select(c => new SignalColumn(
|
||||
c.Key.Trim(),
|
||||
string.IsNullOrWhiteSpace(c.Label) ? c.Key.Trim() : c.Label.Trim(),
|
||||
string.IsNullOrWhiteSpace(c.Type) ? "string" : c.Type.Trim(),
|
||||
c.Options,
|
||||
string.IsNullOrWhiteSpace(c.Group) ? null : c.Group.Trim()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Array.Empty<SignalColumn>();
|
||||
}
|
||||
|
||||
private const string EmbeddedFallbackJson = """
|
||||
{
|
||||
"version": 1,
|
||||
"tables": [
|
||||
{
|
||||
"id": "stations",
|
||||
"title": "PLC机构",
|
||||
"category": "PLC数据管理",
|
||||
"fileName": "stations.json",
|
||||
"model": "PlcStationModel"
|
||||
},
|
||||
{
|
||||
"id": "docks",
|
||||
"title": "机构工位",
|
||||
"category": "PLC数据管理",
|
||||
"fileName": "station-docks.json",
|
||||
"model": "PlcStationDockModel"
|
||||
},
|
||||
{
|
||||
"id": "handshake",
|
||||
"title": "握手点",
|
||||
"category": "握手点数据管理",
|
||||
"fileName": "handshake-points.json",
|
||||
"model": "HandshakePointModel"
|
||||
},
|
||||
{
|
||||
"id": "release",
|
||||
"title": "放行点",
|
||||
"category": "放行点数据管理",
|
||||
"fileName": "release-points.json",
|
||||
"model": "ReleasePointModel"
|
||||
},
|
||||
{
|
||||
"id": "mag-control",
|
||||
"title": "磁条管控区",
|
||||
"category": "磁条交管",
|
||||
"fileName": "mag-control-areas.json",
|
||||
"model": "MagControlAreaModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
|
||||
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
|
||||
"ExecutablePath": "D:\\工作\\stand\\SimpleLite\\SimpleLite.exe",
|
||||
"WorkingDirectory": "D:\\工作\\stand\\SimpleLite",
|
||||
"ProjectionPort": 8222,
|
||||
"ReadinessTimeoutMs": 8000,
|
||||
"FollowParent": false
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
|
||||
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
|
||||
"ExecutablePath": "D:\\工作\\stand\\SimpleLite\\SimpleLite.exe",
|
||||
"WorkingDirectory": "D:\\工作\\stand\\SimpleLite",
|
||||
"Arguments": "",
|
||||
"ReadinessTimeoutMs": 15000,
|
||||
"ReadinessPollIntervalMs": 250,
|
||||
|
||||
Reference in New Issue
Block a user