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 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=A3=81=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E5=86=85=E9=83=A8=E4=BA=A4=E7=AE=A1=E5=92=8C=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=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 @@ + + + + + From c5c15786965609bb9fa0a65faa7a62ccd61db4cb Mon Sep 17 00:00:00 2001 From: ykkokluo <984278063@qq.com> Date: Tue, 25 Aug 2026 08:59:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=A4=E7=AE=A1?= =?UTF-8?q?=E5=92=8C=E4=BF=A1=E5=8F=B7=E4=BA=A4=E4=BA=92=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MiGu.Server/Auth/PageCatalog.cs | 3 +- MiGu.Server/Auth/RbacStore.cs | 6 + MiGu.Server/Configs/DeploymentCatalog.cs | 4 +- MiGu.Server/Configs/DeploymentProfile.cs | 15 +- MiGu.Server/Controllers/WizardController.cs | 2 +- .../Dashboard/DashboardShortcutCatalog.cs | 1 + MiGu.Server/appsettings.Development.json | 4 +- MiGu.Server/appsettings.json | 4 +- .../apps/simple-platform-vue/src/api/setup.ts | 98 +++++++++ .../simple-platform-vue/src/api/wizard.ts | 9 +- .../src/components/setup/SetupGuideAlert.vue | 40 ++++ .../simple-platform-vue/src/config/navMenu.ts | 1 + .../src/layouts/AppShell.vue | 3 + .../apps/simple-platform-vue/src/mock/rbac.ts | 1 + .../simple-platform-vue/src/router/index.ts | 27 ++- .../simple-platform-vue/src/types/setup.ts | 22 ++ .../simple-platform-vue/src/types/wizard.ts | 2 +- .../src/views/LoginView.vue | 80 ++++--- .../src/views/WizardView.vue | 70 ++++-- .../src/views/admin/CarPanelView.vue | 5 + .../src/views/admin/DashboardView.vue | 35 +++ .../src/views/admin/MapManagementView.vue | 6 + .../src/views/admin/SceneManagerView.vue | 12 +- .../src/views/admin/SetupChecklistView.vue | 206 ++++++++++++++++++ .../views/admin/SimpleFieldManagementView.vue | 6 + 25 files changed, 589 insertions(+), 73 deletions(-) create mode 100644 frontends/apps/simple-platform-vue/src/api/setup.ts create mode 100644 frontends/apps/simple-platform-vue/src/components/setup/SetupGuideAlert.vue create mode 100644 frontends/apps/simple-platform-vue/src/types/setup.ts create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/SetupChecklistView.vue diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index 98a4676..96b80dd 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -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), @@ -44,7 +45,7 @@ public static class PageCatalog new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform), new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform), - // ── 管理端 / Platform:数据中心(scene.signal,磁导航选型时显示) ── + // ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ── new("admin-data-center", "数据中心", "数据中心", ScopePlatform), // ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ── diff --git a/MiGu.Server/Auth/RbacStore.cs b/MiGu.Server/Auth/RbacStore.cs index 32a5cdd..1b3ca46 100644 --- a/MiGu.Server/Auth/RbacStore.cs +++ b/MiGu.Server/Auth/RbacStore.cs @@ -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) diff --git a/MiGu.Server/Configs/DeploymentCatalog.cs b/MiGu.Server/Configs/DeploymentCatalog.cs index c1acef8..7434e75 100644 --- a/MiGu.Server/Configs/DeploymentCatalog.cs +++ b/MiGu.Server/Configs/DeploymentCatalog.cs @@ -38,8 +38,8 @@ public static class DeploymentCatalog }; /// - /// 场景插件 → 平台页面 Key。磁导航选型会并入 , - /// 从而点亮「数据中心」菜单(PLC 握手 + 磁条交管配置)。 + /// 场景插件 → 平台页面 Key。选 SPS / Pack 时 ToLauncherSceneIds 会并入 + /// ,从而点亮「数据中心」。 /// public static readonly IReadOnlyDictionary SceneToPages = new Dictionary(StringComparer.OrdinalIgnoreCase) diff --git a/MiGu.Server/Configs/DeploymentProfile.cs b/MiGu.Server/Configs/DeploymentProfile.cs index 1436c89..44887e8 100644 --- a/MiGu.Server/Configs/DeploymentProfile.cs +++ b/MiGu.Server/Configs/DeploymentProfile.cs @@ -65,23 +65,32 @@ public record DeploymentProfile( return result; } - /// 信号交互场景 id(PLC 握手 + 磁条交管)。 + /// 信号交互场景 id(PLC 握手)。仅 SPS / Pack 业务场景时加载。 public const string SignalSceneId = "scene.signal"; /// 设备驱动场景 id(门/充电桩等,各类项目通用)。 public const string DeviceSceneId = "scene.device"; + public const string ScenarioSps = "tpl-sps"; + public const string ScenarioPack = "tpl-pack"; + /// 是否选了磁导航。 public bool UsesMagnetic() => NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true; + /// 是否选了需要 scene.signal 的业务场景(SPS 物料配送 / 电池 Pack 产线)。 + public bool UsesSignalPlugin() => + Scenarios?.Any(s => + string.Equals(s, ScenarioSps, StringComparison.OrdinalIgnoreCase) + || string.Equals(s, ScenarioPack, StringComparison.OrdinalIgnoreCase)) == true; + /// - /// 写入 active-scenes.json 的完整场景集合:导航插件 + 磁导航时并入 scene.signal + scene.device。 + /// 写入 active-scenes.json 的完整场景集合:导航插件 + SPS/Pack 时并入 scene.signal + scene.device。 /// public IReadOnlyList ToLauncherSceneIds() { var result = ToActiveSceneIds().ToList(); - if (UsesMagnetic() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase)) + if (UsesSignalPlugin() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase)) result.Add(SignalSceneId); if (!result.Contains(DeviceSceneId, StringComparer.OrdinalIgnoreCase)) result.Add(DeviceSceneId); diff --git a/MiGu.Server/Controllers/WizardController.cs b/MiGu.Server/Controllers/WizardController.cs index cc076d2..a553c36 100644 --- a/MiGu.Server/Controllers/WizardController.cs +++ b/MiGu.Server/Controllers/WizardController.cs @@ -84,7 +84,7 @@ public class WizardController : ControllerBase // 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载; // 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。 // scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合; - // 磁导航选型时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手 + 磁条交管)。 + // 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手)。 var sceneIds = profile.ToLauncherSceneIds(); var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile"); diff --git a/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs index e416095..eddd773 100644 --- a/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs +++ b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs @@ -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), diff --git a/MiGu.Server/appsettings.Development.json b/MiGu.Server/appsettings.Development.json index 4d88230..f3100fa 100644 --- a/MiGu.Server/appsettings.Development.json +++ b/MiGu.Server/appsettings.Development.json @@ -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 diff --git a/MiGu.Server/appsettings.json b/MiGu.Server/appsettings.json index c0c9dac..e75c225 100644 --- a/MiGu.Server/appsettings.json +++ b/MiGu.Server/appsettings.json @@ -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, diff --git a/frontends/apps/simple-platform-vue/src/api/setup.ts b/frontends/apps/simple-platform-vue/src/api/setup.ts new file mode 100644 index 0000000..632bc63 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/setup.ts @@ -0,0 +1,98 @@ +import { listCars, listSites, listTracks } from '@/api/projection' +import { reflectionApi } from '@/api/reflection' +import { getWizardProfile } from '@/api/wizard' +import type { SetupCarParamRow, SetupStatus } from '@/types/setup' + +const IP_KEYS = ['address', 'ip'] +const PORT_KEYS = ['port', 'magport'] + +function pick(rows: Array<{ key: string; value: string }>, keys: string[]): string { + const set = new Set(keys) + const hit = rows.find((r) => set.has(r.key.toLowerCase())) + return (hit?.value ?? '').trim() +} + +function ipOk(v: string): boolean { + return v.length > 0 && v !== '0.0.0.0' +} + +function portOk(v: string): boolean { + const n = Number(v) + return Number.isFinite(n) && n > 0 && n <= 65535 +} + +async function inspectCarParams(rawId: number, name: string): Promise { + try { + const fields = await reflectionApi.getFields('car', rawId) + const address = pick(fields, IP_KEYS) + const port = pick(fields, PORT_KEYS) + return { id: rawId, name, address, port, paramsReady: ipOk(address) && portOk(port) } + } catch { + return { id: rawId, name, address: '', port: '', paramsReady: false } + } +} + +export async function loadSetupStatus(): Promise { + const empty: SetupStatus = { + carCount: 0, + carsWithParams: 0, + siteCount: 0, + trackCount: 0, + carsReady: false, + mapsReady: false, + incomplete: true, + cars: [], + navigationKinds: [], + scenarios: [], + modules: [] + } + + try { + const [cars, sites, tracks, profile] = await Promise.all([ + listCars().catch(() => []), + listSites().catch(() => []), + listTracks().catch(() => []), + getWizardProfile().catch(() => null) + ]) + + const inspected = await Promise.all( + cars.slice(0, 40).map((c) => { + const id = c.rawId ?? Number(String(c.id).replace(/^C/i, '')) + if (!Number.isFinite(id) || id <= 0) { + return Promise.resolve({ + id: 0, + name: c.name, + address: c.address ?? c.ip ?? '', + port: '', + paramsReady: ipOk(c.address ?? c.ip ?? '') + } satisfies SetupCarParamRow) + } + return inspectCarParams(id, c.name) + }) + ) + + const carsWithParams = inspected.filter((c) => c.paramsReady).length + const couldReadParams = inspected.some((c) => c.address || c.port || c.paramsReady) + const carsReady = cars.length >= 1 && (!couldReadParams || carsWithParams >= 1) + const mapsReady = sites.length >= 1 && tracks.length >= 1 + + return { + carCount: cars.length, + carsWithParams, + siteCount: sites.length, + trackCount: tracks.length, + carsReady, + mapsReady, + incomplete: !(carsReady && mapsReady), + cars: inspected, + navigationKinds: profile?.navigationKinds ?? [], + scenarios: profile?.scenarios ?? [], + modules: profile?.modules ?? [] + } + } catch (e) { + return { + ...empty, + error: e instanceof Error ? e.message : String(e) + } + } +} diff --git a/frontends/apps/simple-platform-vue/src/api/wizard.ts b/frontends/apps/simple-platform-vue/src/api/wizard.ts index a85c35a..a75a749 100644 --- a/frontends/apps/simple-platform-vue/src/api/wizard.ts +++ b/frontends/apps/simple-platform-vue/src/api/wizard.ts @@ -35,15 +35,14 @@ const NAV_SCENE: Record = { laser: 'scene.qrlidar' } -function toLauncherSceneIds(kinds: string[]): string[] { +function toLauncherSceneIds(kinds: string[], scenarios: 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') - } + const wantSignal = scenarios.some((s) => s === 'tpl-sps' || s === 'tpl-pack') + if (wantSignal && !result.includes('scene.signal')) result.push('scene.signal') if (result.length > 0 && !result.includes('scene.device')) result.push('scene.device') return result } @@ -80,7 +79,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/config/navMenu.ts b/frontends/apps/simple-platform-vue/src/config/navMenu.ts index 10cc879..29f2d2d 100644 --- a/frontends/apps/simple-platform-vue/src/config/navMenu.ts +++ b/frontends/apps/simple-platform-vue/src/config/navMenu.ts @@ -38,6 +38,7 @@ export interface NavMenuItem { export const ADMIN_MENU: NavMenuItem[] = [ { path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' }, + { path: '/admin/setup', label: '初始配置', icon: SetUp, key: 'admin-setup', group: '概览' }, { path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览', children: [ diff --git a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue index 24525c2..51895d5 100644 --- a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue +++ b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue @@ -80,6 +80,7 @@