From 134c7480b301460c2ca4a980a96bb5f672a2a3ef Mon Sep 17 00:00:00 2001 From: ykkokluo <984278063@qq.com> Date: Mon, 24 Aug 2026 16:16:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=A3=81=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E5=86=85=E9=83=A8=E4=BA=A4=E7=AE=A1=E5=92=8C=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MiGu.Server/Auth/PageCatalog.cs | 8 + MiGu.Server/Configs/DeploymentCatalog.cs | 23 +- MiGu.Server/Configs/DeploymentProfile.cs | 23 ++ .../Controllers/SignalDataController.cs | 112 +++++++ MiGu.Server/Controllers/WizardController.cs | 7 +- .../Dashboard/DashboardShortcutCatalog.cs | 1 + MiGu.Server/MiGu.Server.csproj | 4 +- MiGu.Server/Program.cs | 2 + MiGu.Server/Signal/SignalDataStore.cs | 112 +++++++ .../Signal/SignalModelSchemaResolver.cs | 275 ++++++++++++++++++ MiGu.Server/Signal/SignalTableManifest.cs | 40 +++ .../Signal/SignalTableManifestLoader.cs | 174 +++++++++++ .../simple-platform-vue/src/api/signalData.ts | 69 +++++ .../simple-platform-vue/src/api/wizard.ts | 21 +- .../simple-platform-vue/src/config/navMenu.ts | 19 +- .../src/config/quickEntries.ts | 2 +- .../simple-platform-vue/src/router/index.ts | 7 + .../simple-platform-vue/src/stores/auth.ts | 17 ++ .../simple-platform-vue/src/types/wizard.ts | 2 +- .../src/views/WizardView.vue | 20 +- .../src/views/admin/DataCenterView.vue | 266 +++++++++++++++++ 21 files changed, 1188 insertions(+), 16 deletions(-) create mode 100644 MiGu.Server/Controllers/SignalDataController.cs create mode 100644 MiGu.Server/Signal/SignalDataStore.cs create mode 100644 MiGu.Server/Signal/SignalModelSchemaResolver.cs create mode 100644 MiGu.Server/Signal/SignalTableManifest.cs create mode 100644 MiGu.Server/Signal/SignalTableManifestLoader.cs create mode 100644 frontends/apps/simple-platform-vue/src/api/signalData.ts create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/DataCenterView.vue diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index e37191f..98a4676 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -44,6 +44,9 @@ public static class PageCatalog new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform), new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform), + // ── 管理端 / Platform:数据中心(scene.signal,磁导航选型时显示) ── + new("admin-data-center", "数据中心", "数据中心", ScopePlatform), + // ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ── new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform), new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform), @@ -86,6 +89,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", }; /// 判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。 diff --git a/MiGu.Server/Configs/DeploymentCatalog.cs b/MiGu.Server/Configs/DeploymentCatalog.cs index e893611..c1acef8 100644 --- a/MiGu.Server/Configs/DeploymentCatalog.cs +++ b/MiGu.Server/Configs/DeploymentCatalog.cs @@ -37,21 +37,40 @@ public static class DeploymentCatalog ["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" }, }; - /// 所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。 + /// + /// 场景插件 → 平台页面 Key。磁导航选型会并入 , + /// 从而点亮「数据中心」菜单(PLC 握手 + 磁条交管配置)。 + /// + public static readonly IReadOnlyDictionary SceneToPages = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" }, + }; + + /// 所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。 public static IReadOnlyCollection TailorablePages() { var set = new HashSet(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; } - /// 当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。 + /// 当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。 public static IReadOnlyCollection EnabledPages(DeploymentProfile dp) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); if (dp == null) return set; foreach (var m in dp.Modules ?? new List()) 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; } diff --git a/MiGu.Server/Configs/DeploymentProfile.cs b/MiGu.Server/Configs/DeploymentProfile.cs index 5128002..1436c89 100644 --- a/MiGu.Server/Configs/DeploymentProfile.cs +++ b/MiGu.Server/Configs/DeploymentProfile.cs @@ -64,4 +64,27 @@ public record DeploymentProfile( } return result; } + + /// 信号交互场景 id(PLC 握手 + 磁条交管)。 + public const string SignalSceneId = "scene.signal"; + + /// 设备驱动场景 id(门/充电桩等,各类项目通用)。 + public const string DeviceSceneId = "scene.device"; + + /// 是否选了磁导航。 + public bool UsesMagnetic() => + NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true; + + /// + /// 写入 active-scenes.json 的完整场景集合:导航插件 + 磁导航时并入 scene.signal + scene.device。 + /// + public IReadOnlyList ToLauncherSceneIds() + { + var result = ToActiveSceneIds().ToList(); + if (UsesMagnetic() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase)) + result.Add(SignalSceneId); + if (!result.Contains(DeviceSceneId, StringComparer.OrdinalIgnoreCase)) + result.Add(DeviceSceneId); + return result; + } } diff --git a/MiGu.Server/Controllers/SignalDataController.cs b/MiGu.Server/Controllers/SignalDataController.cs new file mode 100644 index 0000000..cba2279 --- /dev/null +++ b/MiGu.Server/Controllers/SignalDataController.cs @@ -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; + +/// +/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。 +/// 文件落在 SimpleLite 工作目录 Config/Signal/*.json,不依赖 SimpleLite 进程是否在跑。 +/// +[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); +} diff --git a/MiGu.Server/Controllers/WizardController.cs b/MiGu.Server/Controllers/WizardController.cs index 56354a4..cc076d2 100644 --- a/MiGu.Server/Controllers/WizardController.cs +++ b/MiGu.Server/Controllers/WizardController.cs @@ -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(); + // 磁导航选型时 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 { diff --git a/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs index 2e01644..e416095 100644 --- a/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs +++ b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs @@ -36,6 +36,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), diff --git a/MiGu.Server/MiGu.Server.csproj b/MiGu.Server/MiGu.Server.csproj index 8b27a3a..d39329d 100644 --- a/MiGu.Server/MiGu.Server.csproj +++ b/MiGu.Server/MiGu.Server.csproj @@ -1,7 +1,8 @@  - net8.0 + net8.0-windows + x64 enable enable MiGu.Server @@ -24,6 +25,7 @@ + diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index e0d93cc..a35058e 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -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(builder.Configuration.GetSection("SimpleLite")); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // OTA(WatchDog 编排):包库 / 任务 / 出站客户端 builder.Services.Configure(builder.Configuration.GetSection("Ota")); diff --git a/MiGu.Server/Signal/SignalDataStore.cs b/MiGu.Server/Signal/SignalDataStore.cs new file mode 100644 index 0000000..230b56a --- /dev/null +++ b/MiGu.Server/Signal/SignalDataStore.cs @@ -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? Options = null, string? Group = null); + +public sealed record SignalTableDef( + string Id, + string Title, + string Category, + string FileName, + IReadOnlyList Columns); + +/// +/// 读写 SimpleLite 工作目录 Config/Signal/*.json,供迷毂「数据中心」表格编辑。 +/// 表结构优先从 StandardScene.Signal.dll 反射;无插件时才读 signal-tables.json。 +/// +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? _cachedTables; + private long _cachedSignature; + + public SignalDataStore(SimpleLiteLauncher launcher) => _launcher = launcher; + + public IReadOnlyList 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)); + } +} diff --git a/MiGu.Server/Signal/SignalModelSchemaResolver.cs b/MiGu.Server/Signal/SignalModelSchemaResolver.cs new file mode 100644 index 0000000..e651f59 --- /dev/null +++ b/MiGu.Server/Signal/SignalModelSchemaResolver.cs @@ -0,0 +1,275 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json.Serialization; +using StandardScene.Signal.Model; + +namespace MiGu.Server.Signal; + +/// +/// 从 StandardScene.Signal 程序集反射数据中心表和列。 +/// 优先使用 MiGu.Server 编译期引用的程序集;否则再从 SimpleLite plugins 加载。 +/// +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 ResolveTables(string? pluginsDir, string? workingDirectory = null) + { + var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory); + if (assembly == null) + return Array.Empty(); + + try + { + return BuildTablesFromAssembly(assembly); + } + catch + { + return Array.Empty(); + } + } + + public static IReadOnlyList ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null) + { + if (string.IsNullOrWhiteSpace(modelName)) + return Array.Empty(); + + var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory); + if (assembly == null) + return Array.Empty(); + + try + { + var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true); + if (type == null) + return Array.Empty(); + + return DiscoverColumns(type); + } + catch + { + return Array.Empty(); + } + } + + 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 BuildProbeDirs(string? pluginsDir, string? workingDirectory) + { + var dirs = new List(); + 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 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 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()?.Category ?? ""; + if (string.IsNullOrWhiteSpace(title)) + title = type.GetCustomAttribute()?.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 SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t != null)!; + } + } + + private static IReadOnlyList DiscoverColumns(Type type) + { + var list = new List(); + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) + .Where(p => p.GetCustomAttribute()?.Browsable != false) + .Where(p => !IsJsonIgnored(p)) + .OrderBy(p => p.MetadataToken)) + { + var label = prop.GetCustomAttribute()?.DisplayName; + if (string.IsNullOrWhiteSpace(label)) + label = prop.Name; + + var columnType = MapType(prop.PropertyType); + string[]? options = null; + var group = prop.GetCustomAttribute(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() != 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"; + } +} diff --git a/MiGu.Server/Signal/SignalTableManifest.cs b/MiGu.Server/Signal/SignalTableManifest.cs new file mode 100644 index 0000000..47e1d3a --- /dev/null +++ b/MiGu.Server/Signal/SignalTableManifest.cs @@ -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 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? 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; } +} diff --git a/MiGu.Server/Signal/SignalTableManifestLoader.cs b/MiGu.Server/Signal/SignalTableManifestLoader.cs new file mode 100644 index 0000000..016bcbd --- /dev/null +++ b/MiGu.Server/Signal/SignalTableManifestLoader.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using MiGu.Server.Launcher; + +namespace MiGu.Server.Signal; + +/// +/// 优先从 plugins/StandardScene.Signal.dll 反射表和列(Model 上的 SignalTable / DisplayName)。 +/// 没有插件 DLL 时才读 signal-tables.json 或内置清单。 +/// +public static class SignalTableManifestLoader +{ + private const string ManifestFileName = "signal-tables.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public static IReadOnlyList 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(json, JsonOptions); + if (manifest?.Tables is { Count: > 0 }) + return manifest; + } + catch { /* try next */ } + } + + return JsonSerializer.Deserialize(EmbeddedFallbackJson, JsonOptions) + ?? new SignalTableManifest(); + } + + private static IEnumerable 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 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(); + } + + 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" + } + ] + } + """; +} diff --git a/frontends/apps/simple-platform-vue/src/api/signalData.ts b/frontends/apps/simple-platform-vue/src/api/signalData.ts new file mode 100644 index 0000000..2fd9cc4 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/signalData.ts @@ -0,0 +1,69 @@ +import http from './http' + +const MOCK = import.meta.env.VITE_USE_MOCK === 'true' + +export interface SignalColumn { + key: string + label: string + type: 'string' | 'int' | 'bool' | 'enum' | string + options?: string[] | null + group?: string | null +} + +export interface SignalTableSummary { + id: string + title: string + category: string + fileName: string +} + +export interface SignalTableDto extends SignalTableSummary { + exists: boolean + error?: string | null + columns: SignalColumn[] + rows: Record[] +} + +export interface SignalDataListDto { + signalEnabled: boolean + workingDirectory?: string | null + tables: SignalTableSummary[] +} + +export async function listSignalTables(): Promise { + if (MOCK) { + return { + signalEnabled: true, + workingDirectory: 'D:\\工作\\stand\\SimpleLite', + tables: [ + { id: 'stations', title: 'PLC机构', category: 'PLC数据管理', fileName: 'stations.json' }, + { id: 'docks', title: '机构工位', category: 'PLC数据管理', fileName: 'station-docks.json' }, + { id: 'handshake', title: '握手点', category: '握手点数据管理', fileName: 'handshake-points.json' }, + { id: 'release', title: '放行点', category: '放行点数据管理', fileName: 'release-points.json' }, + { id: 'mag-control', title: '磁条管控区', category: '磁条交管', fileName: 'mag-control-areas.json' } + ] + } + } + const { data } = await http.get('/signal-data', { params: { summary: true } }) + return data +} + +export async function getSignalTable(id: string): Promise { + if (MOCK) { + const list = await listSignalTables() + const meta = list.tables.find((t) => t.id === id) + if (!meta) throw new Error(`未知数据表:${id}`) + return { ...meta, exists: true, columns: [], rows: [] } + } + const { data } = await http.get(`/signal-data/${id}`) + return data +} + +export async function saveSignalTable(id: string, rows: Record[]): Promise { + if (MOCK) { + const t = await getSignalTable(id) + return { ...t, rows: JSON.parse(JSON.stringify(rows)) } + } + const { data } = await http.put(`/signal-data/${id}`, { rows }) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/api/wizard.ts b/frontends/apps/simple-platform-vue/src/api/wizard.ts index 95ade2d..a85c35a 100644 --- a/frontends/apps/simple-platform-vue/src/api/wizard.ts +++ b/frontends/apps/simple-platform-vue/src/api/wizard.ts @@ -29,6 +29,25 @@ const MOCK_OPTIONS: WizardOptions = { } } +const NAV_SCENE: Record = { + magnetic: 'scene.mag', + qrcode: 'scene.qrlidar', + laser: 'scene.qrlidar' +} + +function toLauncherSceneIds(kinds: string[]): string[] { + const result: string[] = [] + for (const k of kinds) { + const id = NAV_SCENE[k] ?? `scene.${k}` + if (!result.includes(id)) result.push(id) + } + if (kinds.some((k) => k.toLowerCase() === 'magnetic') && !result.includes('scene.signal')) { + result.push('scene.signal') + } + if (result.length > 0 && !result.includes('scene.device')) result.push('scene.device') + return result +} + let mockProfile: DeploymentProfileDto = { configured: false, platformType: 'standard', @@ -61,7 +80,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise `scene.${k}`) + activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? []) } return mockProfile } diff --git a/frontends/apps/simple-platform-vue/src/config/navMenu.ts b/frontends/apps/simple-platform-vue/src/config/navMenu.ts index 1bfd9ae..10cc879 100644 --- a/frontends/apps/simple-platform-vue/src/config/navMenu.ts +++ b/frontends/apps/simple-platform-vue/src/config/navMenu.ts @@ -7,6 +7,7 @@ import { DocumentCopy, EditPen, Files, + Grid, Histogram, Link, List, @@ -59,6 +60,16 @@ export const ADMIN_MENU: NavMenuItem[] = [ { path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' } ] }, + { + path: '/admin/data-center', label: '数据中心', icon: Grid, key: 'admin-data-center', group: '数据中心', + children: [ + { path: '/admin/data-center/stations', label: 'PLC机构', icon: Cpu, key: 'admin-data-center', group: '数据中心' }, + { path: '/admin/data-center/docks', label: '机构工位', icon: Connection, key: 'admin-data-center', group: '数据中心' }, + { path: '/admin/data-center/handshake', label: '握手点', icon: Connection, key: 'admin-data-center', group: '数据中心' }, + { path: '/admin/data-center/release', label: '放行点', icon: Promotion, key: 'admin-data-center', group: '数据中心' }, + { path: '/admin/data-center/mag-control', label: '磁条管控区', icon: Operation, key: 'admin-data-center', group: '数据中心' } + ] + }, { path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心', children: [ @@ -84,8 +95,12 @@ export const MONITOR_MENU: NavMenuItem[] = [ export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] { const out: NavMenuItem[] = [] for (const item of items) { - if (item.children?.length) out.push(...flattenNavMenu(item.children)) - else if (item.key) out.push(item) + if (item.children?.length) { + if (item.key) out.push({ ...item, children: undefined }) + out.push(...flattenNavMenu(item.children)) + } else if (item.key) { + out.push(item) + } } return out } diff --git a/frontends/apps/simple-platform-vue/src/config/quickEntries.ts b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts index a71ae8a..0ed788c 100644 --- a/frontends/apps/simple-platform-vue/src/config/quickEntries.ts +++ b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts @@ -107,7 +107,7 @@ function buildCatalog(scope: Scope): Map { const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU for (const item of flattenNavMenu(menu)) { const q = menuItemToQuick(item) - if (q) map.set(q.key, q) + if (q && !map.has(q.key)) map.set(q.key, q) } return map } diff --git a/frontends/apps/simple-platform-vue/src/router/index.ts b/frontends/apps/simple-platform-vue/src/router/index.ts index 4521811..01d132a 100644 --- a/frontends/apps/simple-platform-vue/src/router/index.ts +++ b/frontends/apps/simple-platform-vue/src/router/index.ts @@ -41,6 +41,13 @@ const routes: RouteRecordRaw[] = [ { 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: 'data-center', redirect: '/admin/data-center/stations' }, + { + path: 'data-center/:tableId', + name: 'admin-data-center', + component: () => import('@/views/admin/DataCenterView.vue'), + meta: { title: '数据中心' } + }, // ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ── { path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } }, { path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } }, diff --git a/frontends/apps/simple-platform-vue/src/stores/auth.ts b/frontends/apps/simple-platform-vue/src/stores/auth.ts index f2bb522..57caab9 100644 --- a/frontends/apps/simple-platform-vue/src/stores/auth.ts +++ b/frontends/apps/simple-platform-vue/src/stores/auth.ts @@ -185,6 +185,23 @@ export const useAuthStore = defineStore('auth', { /** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */ markWizardDone() { this.needsWizard = false + }, + /** 向导保存后刷新 allowedPages(数据中心等按选型裁剪的页),失败不登出。 */ + async refreshPermissions() { + try { + const me = await apiGetMe() + this.user = me.user + this.scope = me.scope + this.runMode = me.runMode + this.effectivePermissions = me.effectivePermissions + this.needsWizard = me.needsWizard ?? false + localStorage.setItem(USER_KEY, JSON.stringify(me.user)) + localStorage.setItem(SCOPE_KEY, me.scope) + localStorage.setItem(RUN_MODE_KEY, me.runMode) + localStorage.setItem(PERM_KEY, JSON.stringify(me.effectivePermissions)) + } catch { + /* 保存已成功,权限下次进页 / 刷新再对齐 */ + } } } }) diff --git a/frontends/apps/simple-platform-vue/src/types/wizard.ts b/frontends/apps/simple-platform-vue/src/types/wizard.ts index 3c84ad3..b617f80 100644 --- a/frontends/apps/simple-platform-vue/src/types/wizard.ts +++ b/frontends/apps/simple-platform-vue/src/types/wizard.ts @@ -47,7 +47,7 @@ export interface DeploymentProfileDto { navigationKinds: string[] scenarios: string[] updatedBy: string - /** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.magnetic)。 */ + /** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.mag / scene.qrlidar / scene.signal)。 */ activeSceneIds: string[] /** 被部署画像裁剪隐藏的页面 Key。 */ hiddenPages: string[] diff --git a/frontends/apps/simple-platform-vue/src/views/WizardView.vue b/frontends/apps/simple-platform-vue/src/views/WizardView.vue index 93e8338..f5c5bcd 100644 --- a/frontends/apps/simple-platform-vue/src/views/WizardView.vue +++ b/frontends/apps/simple-platform-vue/src/views/WizardView.vue @@ -117,11 +117,22 @@ const scenarioTemplates = computed(() => options.value?. // 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。 const NAV_SCENE: Record = { - magnetic: 'scene.magnetic', - qrcode: 'scene.qrcode', - laser: 'scene.laser' + magnetic: 'scene.mag', + qrcode: 'scene.qrlidar', + laser: 'scene.qrlidar' } -const activeScenes = computed(() => sel.navigationKinds.map((k) => NAV_SCENE[k] ?? `scene.${k}`)) +const activeScenes = computed(() => { + const scenes: string[] = [] + for (const k of sel.navigationKinds) { + const id = NAV_SCENE[k] ?? `scene.${k}` + if (!scenes.includes(id)) scenes.push(id) + } + if (sel.navigationKinds.includes('magnetic') && !scenes.includes('scene.signal')) { + scenes.push('scene.signal') + } + if (scenes.length > 0 && !scenes.includes('scene.device')) scenes.push('scene.device') + return scenes +}) const canSave = computed(() => sel.navigationKinds.length > 0) @@ -161,6 +172,7 @@ async function save() { scenarios: sel.scenarios }) auth.markWizardDone() + await auth.refreshPermissions() ElMessage.success('部署配置已保存') router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard') } catch (e) { diff --git a/frontends/apps/simple-platform-vue/src/views/admin/DataCenterView.vue b/frontends/apps/simple-platform-vue/src/views/admin/DataCenterView.vue new file mode 100644 index 0000000..1005edb --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/DataCenterView.vue @@ -0,0 +1,266 @@ + + + + +