新增磁导航内部交管和信号交互界面
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
Reference in New Issue
Block a user