新增磁导航内部交管和信号交互界面
This commit is contained in:
@@ -44,6 +44,9 @@ public static class PageCatalog
|
|||||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||||
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||||
|
|
||||||
|
// ── 管理端 / Platform:数据中心(scene.signal,磁导航选型时显示) ──
|
||||||
|
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||||
@@ -86,6 +89,11 @@ public static class PageCatalog
|
|||||||
["admin-config-map-monitor"] = "admin-config-ops-center",
|
["admin-config-map-monitor"] = "admin-config-ops-center",
|
||||||
["admin-config-system"] = "admin-config-system-center",
|
["admin-config-system"] = "admin-config-system-center",
|
||||||
["admin-config-auth"] = "admin-config-system-center",
|
["admin-config-auth"] = "admin-config-system-center",
|
||||||
|
["admin-data-center-stations"] = "admin-data-center",
|
||||||
|
["admin-data-center-docks"] = "admin-data-center",
|
||||||
|
["admin-data-center-handshake"] = "admin-data-center",
|
||||||
|
["admin-data-center-release"] = "admin-data-center",
|
||||||
|
["admin-data-center-mag-control"] = "admin-data-center",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||||
|
|||||||
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
|
|||||||
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
/// <summary>
|
||||||
|
/// 场景插件 → 平台页面 Key。磁导航选型会并入 <see cref="DeploymentProfile.SignalSceneId"/>,
|
||||||
|
/// 从而点亮「数据中心」菜单(PLC 握手 + 磁条交管配置)。
|
||||||
|
/// </summary>
|
||||||
|
public static readonly IReadOnlyDictionary<string, string[]> SceneToPages =
|
||||||
|
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。</summary>
|
||||||
public static IReadOnlyCollection<string> TailorablePages()
|
public static IReadOnlyCollection<string> TailorablePages()
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
foreach (var v in 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;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
|
||||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
if (dp == null) return set;
|
if (dp == null) return set;
|
||||||
foreach (var m in dp.Modules ?? new List<string>())
|
foreach (var m in dp.Modules ?? new List<string>())
|
||||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
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;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,27 @@ public record DeploymentProfile(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>信号交互场景 id(PLC 握手 + 磁条交管)。</summary>
|
||||||
|
public const string SignalSceneId = "scene.signal";
|
||||||
|
|
||||||
|
/// <summary>设备驱动场景 id(门/充电桩等,各类项目通用)。</summary>
|
||||||
|
public const string DeviceSceneId = "scene.device";
|
||||||
|
|
||||||
|
/// <summary>是否选了磁导航。</summary>
|
||||||
|
public bool UsesMagnetic() =>
|
||||||
|
NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 写入 active-scenes.json 的完整场景集合:导航插件 + 磁导航时并入 scene.signal + scene.device。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MiGu.Server.Configs;
|
||||||
|
using MiGu.Server.Signal;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
|
||||||
|
/// 文件落在 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,不依赖 SimpleLite 进程是否在跑。
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = "PlatformScope")]
|
||||||
|
[Route("api/signal-data")]
|
||||||
|
public sealed class SignalDataController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SignalDataStore _store;
|
||||||
|
private readonly ConfigStore _config;
|
||||||
|
|
||||||
|
public SignalDataController(SignalDataStore store, ConfigStore config)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult List([FromQuery] bool summary = false)
|
||||||
|
{
|
||||||
|
var enabled = SignalEnabled();
|
||||||
|
var tables = summary
|
||||||
|
? _store.GetTables().Select(ProjectSummary).ToList()
|
||||||
|
: _store.GetTables().Select(ProjectTable).ToList();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
signalEnabled = enabled,
|
||||||
|
workingDirectory = _store.ResolveWorkingDirectory(),
|
||||||
|
tables
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public IActionResult Get(string id)
|
||||||
|
{
|
||||||
|
var table = _store.Find(id);
|
||||||
|
if (table == null)
|
||||||
|
return NotFound(new { message = $"未知数据表:{id}" });
|
||||||
|
return Ok(ProjectTable(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public IActionResult Save(string id, [FromBody] JsonElement body)
|
||||||
|
{
|
||||||
|
var table = _store.Find(id);
|
||||||
|
if (table == null)
|
||||||
|
return NotFound(new { message = $"未知数据表:{id}" });
|
||||||
|
|
||||||
|
if (!body.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||||
|
return BadRequest(new { message = "请求体需要 rows 数组" });
|
||||||
|
|
||||||
|
JsonArray rows;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = JsonNode.Parse(rowsEl.GetRawText()) as JsonArray ?? new JsonArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = $"rows 不是合法 JSON 数组:{ex.Message}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_store.SaveRows(table, rows);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { message = $"保存失败:{ex.Message}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ProjectTable(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ProjectSummary(SignalTableDef table) => new
|
||||||
|
{
|
||||||
|
id = table.Id,
|
||||||
|
title = table.Title,
|
||||||
|
category = table.Category,
|
||||||
|
fileName = table.FileName
|
||||||
|
};
|
||||||
|
|
||||||
|
private object ProjectTable(SignalTableDef table)
|
||||||
|
{
|
||||||
|
var (path, error) = _store.ResolveFile(table, createDir: false);
|
||||||
|
var exists = path != null && System.IO.File.Exists(path);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
id = table.Id,
|
||||||
|
title = table.Title,
|
||||||
|
category = table.Category,
|
||||||
|
fileName = table.FileName,
|
||||||
|
exists,
|
||||||
|
error,
|
||||||
|
columns = table.Columns,
|
||||||
|
rows = _store.LoadRows(table)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool SignalEnabled() =>
|
||||||
|
_config.GetDeployment().ToLauncherSceneIds()
|
||||||
|
.Contains(DeploymentProfile.SignalSceneId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -84,9 +84,8 @@ public class WizardController : ControllerBase
|
|||||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||||
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||||
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
// 磁导航选型时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手 + 磁条交管)。
|
||||||
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
var sceneIds = profile.ToLauncherSceneIds();
|
||||||
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
|
||||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||||
|
|
||||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||||
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
|
|||||||
navigationKinds = dp.NavigationKinds,
|
navigationKinds = dp.NavigationKinds,
|
||||||
scenarios = dp.Scenarios,
|
scenarios = dp.Scenarios,
|
||||||
updatedBy = dp.UpdatedBy,
|
updatedBy = dp.UpdatedBy,
|
||||||
activeSceneIds = dp.ToActiveSceneIds(),
|
activeSceneIds = dp.ToLauncherSceneIds(),
|
||||||
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
||||||
activeScenesWrite = write == null ? null : new
|
activeScenesWrite = write == null ? null : new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public static class DashboardShortcutCatalog
|
|||||||
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
||||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||||
new("admin-simple-fields", "admin-simple-fields", 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-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
||||||
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||||
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>MiGu.Server</RootNamespace>
|
<RootNamespace>MiGu.Server</RootNamespace>
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
|
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\StandardSence\StandardScene.Signal\StandardScene.Signal.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using MiGu.Server.Configs;
|
|||||||
using MiGu.Server.Launcher;
|
using MiGu.Server.Launcher;
|
||||||
using MiGu.Server.OpenApi;
|
using MiGu.Server.OpenApi;
|
||||||
using MiGu.Server.Ota;
|
using MiGu.Server.Ota;
|
||||||
|
using MiGu.Server.Signal;
|
||||||
using MiGu.DB.Kernel.Hosting;
|
using MiGu.DB.Kernel.Hosting;
|
||||||
using MiGu.Server.Persistence;
|
using MiGu.Server.Persistence;
|
||||||
using Yarp.ReverseProxy.Transforms;
|
using Yarp.ReverseProxy.Transforms;
|
||||||
@@ -264,6 +265,7 @@ builder.Services.AddPlatformPersistence(builder.Configuration);
|
|||||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||||
|
builder.Services.AddSingleton<SignalDataStore>();
|
||||||
|
|
||||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||||
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
public sealed record SignalColumn(string Key, string Label, string Type, IReadOnlyList<string>? Options = null, string? Group = null);
|
||||||
|
|
||||||
|
public sealed record SignalTableDef(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
string Category,
|
||||||
|
string FileName,
|
||||||
|
IReadOnlyList<SignalColumn> Columns);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读写 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
|
||||||
|
/// 表结构优先从 StandardScene.Signal.dll 反射;无插件时才读 signal-tables.json。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SignalDataStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions FileJson = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly object FileLock = new();
|
||||||
|
|
||||||
|
private readonly SimpleLiteLauncher _launcher;
|
||||||
|
private IReadOnlyList<SignalTableDef>? _cachedTables;
|
||||||
|
private long _cachedSignature;
|
||||||
|
|
||||||
|
public SignalDataStore(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||||
|
|
||||||
|
public IReadOnlyList<SignalTableDef> GetTables()
|
||||||
|
{
|
||||||
|
var sig = SignalTableManifestLoader.ComputeManifestSignature(_launcher);
|
||||||
|
if (_cachedTables == null || sig != _cachedSignature)
|
||||||
|
{
|
||||||
|
_cachedTables = SignalTableManifestLoader.Load(_launcher);
|
||||||
|
_cachedSignature = sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cachedTables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignalTableDef? Find(string id) =>
|
||||||
|
GetTables().FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public string? ResolveWorkingDirectory() => _launcher.ResolveWorkingDirectory();
|
||||||
|
|
||||||
|
public (string? Path, string? Error) ResolveFile(SignalTableDef table, bool createDir)
|
||||||
|
{
|
||||||
|
var wd = _launcher.ResolveWorkingDirectory();
|
||||||
|
if (string.IsNullOrWhiteSpace(wd))
|
||||||
|
return (null, "未找到 SimpleLite 工作目录,无法定位 Config/Signal");
|
||||||
|
|
||||||
|
var dest = Path.GetFullPath(Path.Combine(wd, "Config", "Signal", table.FileName));
|
||||||
|
if (File.Exists(dest))
|
||||||
|
return (dest, null);
|
||||||
|
|
||||||
|
if (createDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(dest);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dest, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonArray LoadRows(SignalTableDef table)
|
||||||
|
{
|
||||||
|
var (path, _) = ResolveFile(table, createDir: false);
|
||||||
|
if (path == null || !File.Exists(path))
|
||||||
|
return new JsonArray();
|
||||||
|
|
||||||
|
lock (FileLock)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return new JsonArray();
|
||||||
|
var node = JsonNode.Parse(json);
|
||||||
|
if (node is JsonArray arr)
|
||||||
|
return arr;
|
||||||
|
return new JsonArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveRows(SignalTableDef table, JsonArray rows)
|
||||||
|
{
|
||||||
|
var (path, error) = ResolveFile(table, createDir: true);
|
||||||
|
if (path == null)
|
||||||
|
throw new InvalidOperationException(error ?? "无法解析信号配置文件路径");
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
lock (FileLock)
|
||||||
|
File.WriteAllText(path, rows.ToJsonString(FileJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using StandardScene.Signal.Model;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从 StandardScene.Signal 程序集反射数据中心表和列。
|
||||||
|
/// 优先使用 MiGu.Server 编译期引用的程序集;否则再从 SimpleLite plugins 加载。
|
||||||
|
/// </summary>
|
||||||
|
public static class SignalModelSchemaResolver
|
||||||
|
{
|
||||||
|
private const string ModelNamespace = "StandardScene.Signal.Model";
|
||||||
|
private const string AssemblyFileName = "StandardScene.Signal.dll";
|
||||||
|
|
||||||
|
public static string? FindAssemblyPath(string? pluginsDir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pluginsDir))
|
||||||
|
return null;
|
||||||
|
var path = Path.Combine(pluginsDir, AssemblyFileName);
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<SignalTableDef> ResolveTables(string? pluginsDir, string? workingDirectory = null)
|
||||||
|
{
|
||||||
|
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
|
||||||
|
if (assembly == null)
|
||||||
|
return Array.Empty<SignalTableDef>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return BuildTablesFromAssembly(assembly);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<SignalTableDef>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<SignalColumn> ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modelName))
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
|
||||||
|
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
|
||||||
|
if (assembly == null)
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
|
||||||
|
if (type == null)
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
|
||||||
|
return DiscoverColumns(type);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly? TryGetSignalAssembly(string? pluginsDir, string? workingDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var referenced = typeof(PlcStationModel).Assembly;
|
||||||
|
if (HasModelTypes(referenced))
|
||||||
|
return referenced;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* 未引用插件工程时继续走 LoadFrom */
|
||||||
|
}
|
||||||
|
|
||||||
|
var assemblyPath = FindAssemblyPath(pluginsDir);
|
||||||
|
if (assemblyPath == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var probeDirs = BuildProbeDirs(pluginsDir, workingDirectory);
|
||||||
|
ResolveEventHandler? handler = null;
|
||||||
|
handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve += handler;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = Assembly.LoadFrom(assemblyPath);
|
||||||
|
return HasModelTypes(loaded) ? loaded : null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handler != null)
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve -= handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasModelTypes(Assembly assembly)
|
||||||
|
{
|
||||||
|
return SafeGetTypes(assembly).Any(t =>
|
||||||
|
t != null && string.Equals(t.Namespace, ModelNamespace, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> BuildProbeDirs(string? pluginsDir, string? workingDirectory)
|
||||||
|
{
|
||||||
|
var dirs = new List<string>();
|
||||||
|
void Add(string? dir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dir)) return;
|
||||||
|
var full = Path.GetFullPath(dir);
|
||||||
|
if (Directory.Exists(full) && !dirs.Contains(full, StringComparer.OrdinalIgnoreCase))
|
||||||
|
dirs.Add(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(pluginsDir);
|
||||||
|
Add(workingDirectory);
|
||||||
|
if (!string.IsNullOrWhiteSpace(pluginsDir))
|
||||||
|
Add(Path.GetDirectoryName(pluginsDir));
|
||||||
|
Add(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly? ResolveAssembly(string? assemblyName, IReadOnlyList<string> probeDirs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assemblyName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string simpleName;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
simpleName = new AssemblyName(assemblyName).Name ?? assemblyName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
simpleName = assemblyName.Split(',')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dir in probeDirs)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(dir, simpleName + ".dll");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Assembly.LoadFrom(path);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* try next dir */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SignalTableDef> BuildTablesFromAssembly(Assembly assembly)
|
||||||
|
{
|
||||||
|
var found = new List<(int Order, SignalTableDef Table)>();
|
||||||
|
foreach (var type in SafeGetTypes(assembly))
|
||||||
|
{
|
||||||
|
if (type == null || !string.Equals(type.Namespace, ModelNamespace, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attr = type.GetCustomAttributes(inherit: false)
|
||||||
|
.FirstOrDefault(a => a.GetType().Name == "SignalTableAttribute");
|
||||||
|
if (attr == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attrType = attr.GetType();
|
||||||
|
var id = (attrType.GetProperty("Id")?.GetValue(attr) as string ?? "").Trim();
|
||||||
|
var fileName = (attrType.GetProperty("FileName")?.GetValue(attr) as string ?? "").Trim();
|
||||||
|
var title = (attrType.GetProperty("Title")?.GetValue(attr) as string ?? "").Trim();
|
||||||
|
var order = attrType.GetProperty("Order")?.GetValue(attr) as int? ?? 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var category = type.GetCustomAttribute<CategoryAttribute>()?.Category ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
title = type.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? id;
|
||||||
|
|
||||||
|
found.Add((order, new SignalTableDef(id, title, category, fileName, DiscoverColumns(type))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.OrderBy(x => x.Order).ThenBy(x => x.Table.Id).Select(x => x.Table).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return assembly.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
return ex.Types.Where(t => t != null)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SignalColumn> DiscoverColumns(Type type)
|
||||||
|
{
|
||||||
|
var list = new List<SignalColumn>();
|
||||||
|
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||||
|
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
|
||||||
|
.Where(p => !IsJsonIgnored(p))
|
||||||
|
.OrderBy(p => p.MetadataToken))
|
||||||
|
{
|
||||||
|
var label = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;
|
||||||
|
if (string.IsNullOrWhiteSpace(label))
|
||||||
|
label = prop.Name;
|
||||||
|
|
||||||
|
var columnType = MapType(prop.PropertyType);
|
||||||
|
string[]? options = null;
|
||||||
|
var group = prop.GetCustomAttribute<CategoryAttribute>(inherit: false)?.Category;
|
||||||
|
if (string.IsNullOrWhiteSpace(group))
|
||||||
|
group = null;
|
||||||
|
|
||||||
|
var select = ReadSelectOptions(prop);
|
||||||
|
if (select is { Length: > 0 })
|
||||||
|
{
|
||||||
|
columnType = "enum";
|
||||||
|
options = select;
|
||||||
|
}
|
||||||
|
else if (columnType == "enum" && (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType).IsEnum)
|
||||||
|
{
|
||||||
|
options = Enum.GetNames(Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(new SignalColumn(prop.Name, label, columnType, options, group));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsJsonIgnored(PropertyInfo prop)
|
||||||
|
{
|
||||||
|
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() != null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
foreach (var attr in prop.GetCustomAttributes(inherit: true))
|
||||||
|
{
|
||||||
|
if (attr.GetType().FullName == "Newtonsoft.Json.JsonIgnoreAttribute")
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[]? ReadSelectOptions(PropertyInfo prop)
|
||||||
|
{
|
||||||
|
var attr = prop.GetCustomAttributes(inherit: false)
|
||||||
|
.FirstOrDefault(a => a.GetType().Name == "SignalSelectAttribute");
|
||||||
|
if (attr == null)
|
||||||
|
return null;
|
||||||
|
var options = attr.GetType().GetProperty("Options")?.GetValue(attr) as string[];
|
||||||
|
return options is { Length: > 0 } ? options : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapType(Type type)
|
||||||
|
{
|
||||||
|
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
if (underlying == typeof(bool))
|
||||||
|
return "bool";
|
||||||
|
if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) ||
|
||||||
|
underlying == typeof(byte) || underlying == typeof(uint) || underlying == typeof(ulong))
|
||||||
|
return "int";
|
||||||
|
if (underlying.IsEnum)
|
||||||
|
return "enum";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
public sealed class SignalTableManifest
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
public List<SignalTableManifestEntry> Tables { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SignalTableManifestEntry
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
|
||||||
|
public string FileName { get; set; } = "";
|
||||||
|
|
||||||
|
public string Model { get; set; } = "";
|
||||||
|
|
||||||
|
public List<SignalTableColumnEntry>? Columns { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SignalTableColumnEntry
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
|
||||||
|
public string Type { get; set; } = "string";
|
||||||
|
|
||||||
|
[JsonPropertyName("options")]
|
||||||
|
public string[]? Options { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("group")]
|
||||||
|
public string? Group { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先从 plugins/StandardScene.Signal.dll 反射表和列(Model 上的 SignalTable / DisplayName)。
|
||||||
|
/// 没有插件 DLL 时才读 signal-tables.json 或内置清单。
|
||||||
|
/// </summary>
|
||||||
|
public static class SignalTableManifestLoader
|
||||||
|
{
|
||||||
|
private const string ManifestFileName = "signal-tables.json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IReadOnlyList<SignalTableDef> Load(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
var workingDirectory = launcher.ResolveWorkingDirectory();
|
||||||
|
var pluginsDir = launcher.ResolvePluginsDir();
|
||||||
|
var fromPlugin = SignalModelSchemaResolver.ResolveTables(pluginsDir, workingDirectory);
|
||||||
|
if (fromPlugin.Count > 0)
|
||||||
|
return fromPlugin;
|
||||||
|
|
||||||
|
var manifest = TryLoadManifest(launcher);
|
||||||
|
return manifest.Tables
|
||||||
|
.Where(t => !string.IsNullOrWhiteSpace(t.Id) && !string.IsNullOrWhiteSpace(t.FileName))
|
||||||
|
.Select(e => ToDef(e, pluginsDir, workingDirectory))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long ComputeManifestSignature(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
long sig = 0;
|
||||||
|
foreach (var path in ResolveManifestPaths(launcher))
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
sig ^= info.LastWriteTimeUtc.Ticks;
|
||||||
|
sig ^= info.Length;
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginsDir = launcher.ResolvePluginsDir();
|
||||||
|
var dll = pluginsDir == null ? null : Path.Combine(pluginsDir, "StandardScene.Signal.dll");
|
||||||
|
if (dll != null && File.Exists(dll))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(dll);
|
||||||
|
sig ^= info.LastWriteTimeUtc.Ticks;
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignalTableManifest TryLoadManifest(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
foreach (var path in ResolveManifestPaths(launcher))
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var manifest = JsonSerializer.Deserialize<SignalTableManifest>(json, JsonOptions);
|
||||||
|
if (manifest?.Tables is { Count: > 0 })
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
catch { /* try next */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<SignalTableManifest>(EmbeddedFallbackJson, JsonOptions)
|
||||||
|
?? new SignalTableManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ResolveManifestPaths(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
var wd = launcher.ResolveWorkingDirectory();
|
||||||
|
if (!string.IsNullOrWhiteSpace(wd))
|
||||||
|
yield return Path.Combine(wd, "Config", "Signal", ManifestFileName);
|
||||||
|
|
||||||
|
var plugins = launcher.ResolvePluginsDir();
|
||||||
|
if (string.IsNullOrWhiteSpace(plugins)) yield break;
|
||||||
|
|
||||||
|
yield return Path.Combine(plugins, ManifestFileName);
|
||||||
|
yield return Path.Combine(plugins, "Config", "Signal", ManifestFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignalTableDef ToDef(SignalTableManifestEntry entry, string? pluginsDir, string? workingDirectory)
|
||||||
|
{
|
||||||
|
var columns = BuildColumns(entry, pluginsDir, workingDirectory);
|
||||||
|
return new SignalTableDef(
|
||||||
|
entry.Id.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(entry.Title) ? entry.Id.Trim() : entry.Title.Trim(),
|
||||||
|
entry.Category ?? "",
|
||||||
|
entry.FileName.Trim(),
|
||||||
|
columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SignalColumn> BuildColumns(
|
||||||
|
SignalTableManifestEntry entry,
|
||||||
|
string? pluginsDir,
|
||||||
|
string? workingDirectory)
|
||||||
|
{
|
||||||
|
var reflected = SignalModelSchemaResolver.ResolveColumns(entry.Model, pluginsDir, workingDirectory);
|
||||||
|
if (reflected.Count > 0)
|
||||||
|
return reflected;
|
||||||
|
|
||||||
|
if (entry.Columns is { Count: > 0 })
|
||||||
|
{
|
||||||
|
return entry.Columns
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c.Key))
|
||||||
|
.Select(c => new SignalColumn(
|
||||||
|
c.Key.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(c.Label) ? c.Key.Trim() : c.Label.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(c.Type) ? "string" : c.Type.Trim(),
|
||||||
|
c.Options,
|
||||||
|
string.IsNullOrWhiteSpace(c.Group) ? null : c.Group.Trim()))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string EmbeddedFallbackJson = """
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"id": "stations",
|
||||||
|
"title": "PLC机构",
|
||||||
|
"category": "PLC数据管理",
|
||||||
|
"fileName": "stations.json",
|
||||||
|
"model": "PlcStationModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "docks",
|
||||||
|
"title": "机构工位",
|
||||||
|
"category": "PLC数据管理",
|
||||||
|
"fileName": "station-docks.json",
|
||||||
|
"model": "PlcStationDockModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "handshake",
|
||||||
|
"title": "握手点",
|
||||||
|
"category": "握手点数据管理",
|
||||||
|
"fileName": "handshake-points.json",
|
||||||
|
"model": "HandshakePointModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "release",
|
||||||
|
"title": "放行点",
|
||||||
|
"category": "放行点数据管理",
|
||||||
|
"fileName": "release-points.json",
|
||||||
|
"model": "ReleasePointModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mag-control",
|
||||||
|
"title": "磁条管控区",
|
||||||
|
"category": "磁条交管",
|
||||||
|
"fileName": "mag-control-areas.json",
|
||||||
|
"model": "MagControlAreaModel"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -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<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalDataListDto {
|
||||||
|
signalEnabled: boolean
|
||||||
|
workingDirectory?: string | null
|
||||||
|
tables: SignalTableSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSignalTables(): Promise<SignalDataListDto> {
|
||||||
|
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<SignalDataListDto>('/signal-data', { params: { summary: true } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSignalTable(id: string): Promise<SignalTableDto> {
|
||||||
|
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<SignalTableDto>(`/signal-data/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSignalTable(id: string, rows: Record<string, unknown>[]): Promise<SignalTableDto> {
|
||||||
|
if (MOCK) {
|
||||||
|
const t = await getSignalTable(id)
|
||||||
|
return { ...t, rows: JSON.parse(JSON.stringify(rows)) }
|
||||||
|
}
|
||||||
|
const { data } = await http.put<SignalTableDto>(`/signal-data/${id}`, { rows })
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -29,6 +29,25 @@ const MOCK_OPTIONS: WizardOptions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NAV_SCENE: Record<string, string> = {
|
||||||
|
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 = {
|
let mockProfile: DeploymentProfileDto = {
|
||||||
configured: false,
|
configured: false,
|
||||||
platformType: 'standard',
|
platformType: 'standard',
|
||||||
@@ -61,7 +80,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise<Deploym
|
|||||||
navigationKinds: req.navigationKinds ?? [],
|
navigationKinds: req.navigationKinds ?? [],
|
||||||
scenarios: req.scenarios ?? [],
|
scenarios: req.scenarios ?? [],
|
||||||
configured: true,
|
configured: true,
|
||||||
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
|
activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? [])
|
||||||
}
|
}
|
||||||
return mockProfile
|
return mockProfile
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DocumentCopy,
|
DocumentCopy,
|
||||||
EditPen,
|
EditPen,
|
||||||
Files,
|
Files,
|
||||||
|
Grid,
|
||||||
Histogram,
|
Histogram,
|
||||||
Link,
|
Link,
|
||||||
List,
|
List,
|
||||||
@@ -59,6 +60,16 @@ export const ADMIN_MENU: NavMenuItem[] = [
|
|||||||
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' }
|
{ 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: '平台配置中心',
|
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
|
||||||
children: [
|
children: [
|
||||||
@@ -84,8 +95,12 @@ export const MONITOR_MENU: NavMenuItem[] = [
|
|||||||
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
||||||
const out: NavMenuItem[] = []
|
const out: NavMenuItem[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.children?.length) out.push(...flattenNavMenu(item.children))
|
if (item.children?.length) {
|
||||||
else if (item.key) out.push(item)
|
if (item.key) out.push({ ...item, children: undefined })
|
||||||
|
out.push(...flattenNavMenu(item.children))
|
||||||
|
} else if (item.key) {
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function buildCatalog(scope: Scope): Map<string, QuickEntryDef> {
|
|||||||
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
|
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
|
||||||
for (const item of flattenNavMenu(menu)) {
|
for (const item of flattenNavMenu(menu)) {
|
||||||
const q = menuItemToQuick(item)
|
const q = menuItemToQuick(item)
|
||||||
if (q) map.set(q.key, q)
|
if (q && !map.has(q.key)) map.set(q.key, q)
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
||||||
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||||
|
{ 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)。 ──
|
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
||||||
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
||||||
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
||||||
|
|||||||
@@ -185,6 +185,23 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
/** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */
|
/** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */
|
||||||
markWizardDone() {
|
markWizardDone() {
|
||||||
this.needsWizard = false
|
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 {
|
||||||
|
/* 保存已成功,权限下次进页 / 刷新再对齐 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export interface DeploymentProfileDto {
|
|||||||
navigationKinds: string[]
|
navigationKinds: string[]
|
||||||
scenarios: string[]
|
scenarios: string[]
|
||||||
updatedBy: string
|
updatedBy: string
|
||||||
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.magnetic)。 */
|
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.mag / scene.qrlidar / scene.signal)。 */
|
||||||
activeSceneIds: string[]
|
activeSceneIds: string[]
|
||||||
/** 被部署画像裁剪隐藏的页面 Key。 */
|
/** 被部署画像裁剪隐藏的页面 Key。 */
|
||||||
hiddenPages: string[]
|
hiddenPages: string[]
|
||||||
|
|||||||
@@ -117,11 +117,22 @@ const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.
|
|||||||
|
|
||||||
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
|
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
|
||||||
const NAV_SCENE: Record<string, string> = {
|
const NAV_SCENE: Record<string, string> = {
|
||||||
magnetic: 'scene.magnetic',
|
magnetic: 'scene.mag',
|
||||||
qrcode: 'scene.qrcode',
|
qrcode: 'scene.qrlidar',
|
||||||
laser: 'scene.laser'
|
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)
|
const canSave = computed(() => sel.navigationKinds.length > 0)
|
||||||
|
|
||||||
@@ -161,6 +172,7 @@ async function save() {
|
|||||||
scenarios: sel.scenarios
|
scenarios: sel.scenarios
|
||||||
})
|
})
|
||||||
auth.markWizardDone()
|
auth.markWizardDone()
|
||||||
|
await auth.refreshPermissions()
|
||||||
ElMessage.success('部署配置已保存')
|
ElMessage.success('部署配置已保存')
|
||||||
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div class="data-center-page">
|
||||||
|
<el-card v-loading="loading" shadow="never" class="page-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ table?.title ?? title }}</h2>
|
||||||
|
<p>
|
||||||
|
读写 SimpleLite 工作目录 <code>Config/Signal</code>;改完后到信号交互进程点重新加载配置。
|
||||||
|
点位为 BOOL 时按「字节 + 位」直接读写。
|
||||||
|
<span v-if="table">
|
||||||
|
({{ table.fileName }}{{ table.exists ? '' : ' · 文件尚未创建,保存后写入' }})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" :disabled="loading" @click="openDialog()">新增</el-button>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="loadError"
|
||||||
|
type="warning"
|
||||||
|
:title="loadError"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table :data="rows" border size="small" height="560" empty-text="暂无数据">
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
:prop="col.key"
|
||||||
|
:label="col.label"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="col.type === 'bool'" :type="truthy(row[col.key]) ? 'success' : 'info'" size="small">
|
||||||
|
{{ truthy(row[col.key]) ? '是' : '否' }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>{{ formatCell(row[col.key]) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row, $index }">
|
||||||
|
<el-button size="small" link @click="openDialog(row, $index)">编辑</el-button>
|
||||||
|
<el-button size="small" link type="danger" @click="removeRow($index)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editingIndex >= 0 ? '编辑' : '新增'" width="640px" destroy-on-close>
|
||||||
|
<el-form label-width="148px">
|
||||||
|
<template v-for="group in formGroups" :key="group.name || '_default'">
|
||||||
|
<div v-if="group.name" class="form-group-title">{{ group.name }}</div>
|
||||||
|
<el-form-item v-for="col in group.columns" :key="col.key" :label="col.label">
|
||||||
|
<el-switch v-if="col.type === 'bool'" v-model="form[col.key]" />
|
||||||
|
<el-select
|
||||||
|
v-else-if="col.type === 'enum'"
|
||||||
|
v-model="form[col.key]"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="opt in col.options ?? []" :key="opt" :label="opt" :value="opt" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number
|
||||||
|
v-else-if="col.type === 'int'"
|
||||||
|
v-model="form[col.key]"
|
||||||
|
:controls="false"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<el-input v-else v-model="form[col.key]" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="commitDialog">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getSignalTable, saveSignalTable, type SignalColumn, type SignalTableDto } from '@/api/signalData'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const title = computed(() => (route.meta.title as string | undefined) ?? '数据中心')
|
||||||
|
const tableId = computed(() => String(route.params.tableId ?? route.meta.tableId ?? ''))
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const table = ref<SignalTableDto | null>(null)
|
||||||
|
const rows = ref<Record<string, unknown>[]>([])
|
||||||
|
const columns = computed<SignalColumn[]>(() => table.value?.columns ?? [])
|
||||||
|
|
||||||
|
const formGroups = computed(() => {
|
||||||
|
const map = new Map<string, SignalColumn[]>()
|
||||||
|
for (const col of columns.value) {
|
||||||
|
const name = col.group?.trim() || ''
|
||||||
|
if (!map.has(name)) map.set(name, [])
|
||||||
|
map.get(name)!.push(col)
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([name, cols]) => ({ name, columns: cols }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingIndex = ref(-1)
|
||||||
|
const form = reactive<Record<string, unknown>>({})
|
||||||
|
|
||||||
|
function truthy(v: unknown): boolean {
|
||||||
|
return v === true || v === 'true' || v === 1 || v === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(v: unknown): string {
|
||||||
|
if (v == null) return ''
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyValue(col: SignalColumn): unknown {
|
||||||
|
if (col.type === 'bool') return false
|
||||||
|
if (col.type === 'int') return 0
|
||||||
|
if (col.type === 'enum') return col.options?.[0] ?? ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(src?: Record<string, unknown>) {
|
||||||
|
for (const key of Object.keys(form)) delete form[key]
|
||||||
|
for (const col of columns.value) {
|
||||||
|
const raw = src?.[col.key]
|
||||||
|
if (raw !== undefined && raw !== null) {
|
||||||
|
form[col.key] = col.type === 'int' ? Number(raw) : raw
|
||||||
|
} else {
|
||||||
|
form[col.key] = emptyValue(col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!tableId.value) return
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const data = await getSignalTable(tableId.value)
|
||||||
|
table.value = data
|
||||||
|
rows.value = Array.isArray(data.rows) ? data.rows.map((r) => ({ ...r })) : []
|
||||||
|
if (data.error) loadError.value = data.error
|
||||||
|
} catch (e) {
|
||||||
|
table.value = null
|
||||||
|
rows.value = []
|
||||||
|
loadError.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(row?: Record<string, unknown>, index?: number) {
|
||||||
|
editingIndex.value = index ?? -1
|
||||||
|
fillForm(row)
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(next: Record<string, unknown>[]) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const data = await saveSignalTable(tableId.value, next)
|
||||||
|
table.value = data
|
||||||
|
rows.value = Array.isArray(data.rows) ? data.rows.map((r) => ({ ...r })) : next
|
||||||
|
ElMessage.success('已保存到信号配置 JSON')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitDialog() {
|
||||||
|
const item: Record<string, unknown> = {}
|
||||||
|
for (const col of columns.value) {
|
||||||
|
let v = form[col.key]
|
||||||
|
if (col.type === 'int') {
|
||||||
|
const n = Number(v)
|
||||||
|
v = Number.isFinite(n) ? Math.trunc(n) : 0
|
||||||
|
} else if (col.type === 'bool') {
|
||||||
|
v = truthy(v)
|
||||||
|
} else {
|
||||||
|
v = v == null ? '' : String(v)
|
||||||
|
}
|
||||||
|
item[col.key] = v
|
||||||
|
}
|
||||||
|
const next = rows.value.map((r) => ({ ...r }))
|
||||||
|
if (editingIndex.value >= 0) next[editingIndex.value] = item
|
||||||
|
else next.push(item)
|
||||||
|
await persist(next)
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRow(index: number) {
|
||||||
|
const row = rows.value[index]
|
||||||
|
const label = columns.value[0] ? String(row?.[columns.value[0].key] ?? index + 1) : String(index + 1)
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除「${label}」?`, '确认', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = rows.value.filter((_, i) => i !== index)
|
||||||
|
await persist(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(tableId, () => { void load() }, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.data-center-page {
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.page-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.page-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.page-header code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.form-group-title {
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.form-group-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user