This commit is contained in:
2026-08-25 11:21:21 +08:00
36 changed files with 1763 additions and 75 deletions
+9
View File
@@ -29,6 +29,7 @@ public static class PageCatalog
{
// ── 管理端 / Platform:概览 ──
new("admin-dashboard", "总览", "概览", ScopePlatform),
new("admin-setup", "初始配置", "概览", ScopePlatform),
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
new("admin-tasks", "任务管理", "概览", ScopePlatform),
new("admin-alarms", "报警管理", "概览", ScopePlatform),
@@ -45,6 +46,9 @@ public static class PageCatalog
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
@@ -87,6 +91,11 @@ public static class PageCatalog
["admin-config-map-monitor"] = "admin-config-ops-center",
["admin-config-system"] = "admin-config-system-center",
["admin-config-auth"] = "admin-config-system-center",
["admin-data-center-stations"] = "admin-data-center",
["admin-data-center-docks"] = "admin-data-center",
["admin-data-center-handshake"] = "admin-data-center",
["admin-data-center-release"] = "admin-data-center",
["admin-data-center-mag-control"] = "admin-data-center",
};
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
+6
View File
@@ -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)
+21 -2
View File
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
/// <summary>
/// 场景插件 → 平台页面 Key。选 SPS / Pack 时 ToLauncherSceneIds 会并入
/// <see cref="DeploymentProfile.SignalSceneId"/>,从而点亮「数据中心」。
/// </summary>
public static readonly IReadOnlyDictionary<string, string[]> SceneToPages =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。</summary>
public static IReadOnlyCollection<string> TailorablePages()
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
foreach (var v in SceneToPages.Values) foreach (var p in v) set.Add(p);
return set;
}
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (dp == null) return set;
foreach (var m in dp.Modules ?? new List<string>())
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
var activeScenes = dp.ToLauncherSceneIds();
foreach (var (sceneId, pages) in SceneToPages)
{
if (!activeScenes.Contains(sceneId, StringComparer.OrdinalIgnoreCase)) continue;
foreach (var p in pages) set.Add(p);
}
return set;
}
+32
View File
@@ -64,4 +64,36 @@ public record DeploymentProfile(
}
return result;
}
/// <summary>信号交互场景 idPLC 握手)。仅 SPS / Pack 业务场景时加载。</summary>
public const string SignalSceneId = "scene.signal";
/// <summary>设备驱动场景 id(门/充电桩等,各类项目通用)。</summary>
public const string DeviceSceneId = "scene.device";
public const string ScenarioSps = "tpl-sps";
public const string ScenarioPack = "tpl-pack";
/// <summary>是否选了磁导航。</summary>
public bool UsesMagnetic() =>
NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true;
/// <summary>是否选了需要 scene.signal 的业务场景(SPS 物料配送 / 电池 Pack 产线)。</summary>
public bool UsesSignalPlugin() =>
Scenarios?.Any(s =>
string.Equals(s, ScenarioSps, StringComparison.OrdinalIgnoreCase)
|| string.Equals(s, ScenarioPack, StringComparison.OrdinalIgnoreCase)) == true;
/// <summary>
/// 写入 active-scenes.json 的完整场景集合:导航插件 + SPS/Pack 时并入 scene.signal + scene.device。
/// </summary>
public IReadOnlyList<string> ToLauncherSceneIds()
{
var result = ToActiveSceneIds().ToList();
if (UsesSignalPlugin() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase))
result.Add(SignalSceneId);
if (!result.Contains(DeviceSceneId, StringComparer.OrdinalIgnoreCase))
result.Add(DeviceSceneId);
return result;
}
}
@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Signal;
namespace MiGu.Server.Controllers;
/// <summary>
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
/// 文件落在 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,不依赖 SimpleLite 进程是否在跑。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
[Route("api/signal-data")]
public sealed class SignalDataController : ControllerBase
{
private readonly SignalDataStore _store;
private readonly ConfigStore _config;
public SignalDataController(SignalDataStore store, ConfigStore config)
{
_store = store;
_config = config;
}
[HttpGet]
public IActionResult List([FromQuery] bool summary = false)
{
var enabled = SignalEnabled();
var tables = summary
? _store.GetTables().Select(ProjectSummary).ToList()
: _store.GetTables().Select(ProjectTable).ToList();
return Ok(new
{
signalEnabled = enabled,
workingDirectory = _store.ResolveWorkingDirectory(),
tables
});
}
[HttpGet("{id}")]
public IActionResult Get(string id)
{
var table = _store.Find(id);
if (table == null)
return NotFound(new { message = $"未知数据表:{id}" });
return Ok(ProjectTable(table));
}
[HttpPut("{id}")]
public IActionResult Save(string id, [FromBody] JsonElement body)
{
var table = _store.Find(id);
if (table == null)
return NotFound(new { message = $"未知数据表:{id}" });
if (!body.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return BadRequest(new { message = "请求体需要 rows 数组" });
JsonArray rows;
try
{
rows = JsonNode.Parse(rowsEl.GetRawText()) as JsonArray ?? new JsonArray();
}
catch (Exception ex)
{
return BadRequest(new { message = $"rows 不是合法 JSON 数组:{ex.Message}" });
}
try
{
_store.SaveRows(table, rows);
}
catch (Exception ex)
{
return StatusCode(500, new { message = $"保存失败:{ex.Message}" });
}
return Ok(ProjectTable(table));
}
private object ProjectSummary(SignalTableDef table) => new
{
id = table.Id,
title = table.Title,
category = table.Category,
fileName = table.FileName
};
private object ProjectTable(SignalTableDef table)
{
var (path, error) = _store.ResolveFile(table, createDir: false);
var exists = path != null && System.IO.File.Exists(path);
return new
{
id = table.Id,
title = table.Title,
category = table.Category,
fileName = table.FileName,
exists,
error,
columns = table.Columns,
rows = _store.LoadRows(table)
};
}
private bool SignalEnabled() =>
_config.GetDeployment().ToLauncherSceneIds()
.Contains(DeploymentProfile.SignalSceneId, StringComparer.OrdinalIgnoreCase);
}
+3 -4
View File
@@ -84,9 +84,8 @@ public class WizardController : ControllerBase
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signalPLC 握手)。
var sceneIds = profile.ToLauncherSceneIds();
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
navigationKinds = dp.NavigationKinds,
scenarios = dp.Scenarios,
updatedBy = dp.UpdatedBy,
activeSceneIds = dp.ToActiveSceneIds(),
activeSceneIds = dp.ToLauncherSceneIds(),
hiddenPages = DeploymentCatalog.HiddenPages(dp),
activeScenesWrite = write == null ? null : new
{
@@ -26,6 +26,7 @@ public static class DashboardShortcutCatalog
private static readonly ShortcutDef[] PlatformShortcuts =
[
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
@@ -36,6 +37,7 @@ public static class DashboardShortcutCatalog
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
+3 -1
View File
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MiGu.Server</RootNamespace>
@@ -24,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
<ProjectReference Include="..\..\..\StandardSence\StandardScene.Signal\StandardScene.Signal.csproj" />
</ItemGroup>
<ItemGroup>
+2
View File
@@ -8,6 +8,7 @@ using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.OpenApi;
using MiGu.Server.Ota;
using MiGu.Server.Signal;
using MiGu.DB.Kernel.Hosting;
using MiGu.Server.Persistence;
using Yarp.ReverseProxy.Transforms;
@@ -264,6 +265,7 @@ builder.Services.AddPlatformPersistence(builder.Configuration);
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
builder.Services.AddSingleton<SignalDataStore>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
+112
View File
@@ -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";
}
}
+40
View File
@@ -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"
}
]
}
""";
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
@@ -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<SetupCarParamRow> {
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<SetupStatus> {
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)
}
}
}
@@ -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,24 @@ const MOCK_OPTIONS: WizardOptions = {
}
}
const NAV_SCENE: Record<string, string> = {
magnetic: 'scene.mag',
qrcode: 'scene.qrlidar',
laser: 'scene.qrlidar'
}
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)
}
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
}
let mockProfile: DeploymentProfileDto = {
configured: false,
platformType: 'standard',
@@ -61,7 +79,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise<Deploym
navigationKinds: req.navigationKinds ?? [],
scenarios: req.scenarios ?? [],
configured: true,
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? [], req.scenarios ?? [])
}
return mockProfile
}
@@ -0,0 +1,40 @@
<template>
<el-alert v-if="fromSetup" class="setup-guide" type="warning" show-icon :closable="false">
<template #title>
<div class="setup-guide-row">
<div>
<div class="setup-guide-title">{{ title }}</div>
<div v-if="desc" class="setup-guide-desc">{{ desc }}</div>
</div>
<el-button size="small" type="primary" plain @click="back">返回初始配置</el-button>
</div>
</template>
</el-alert>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
defineProps<{
title: string
desc?: string
}>()
const route = useRoute()
const router = useRouter()
const fromSetup = computed(() => route.query.setup === '1')
function back() {
router.push('/admin/setup')
}
</script>
<style scoped>
.setup-guide { margin-bottom: 10px; flex-shrink: 0; }
.setup-guide-row {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
}
.setup-guide-title { font-weight: 600; }
.setup-guide-desc { margin-top: 4px; font-size: 12.5px; line-height: 1.55; font-weight: 400; opacity: 0.9; }
</style>
@@ -7,6 +7,7 @@ import {
DocumentCopy,
EditPen,
Files,
Grid,
Histogram,
Link,
List,
@@ -37,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: [
@@ -60,6 +62,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: [
@@ -85,8 +97,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
}
@@ -107,7 +107,7 @@ function buildCatalog(scope: Scope): Map<string, QuickEntryDef> {
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
}
@@ -80,6 +80,7 @@
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="setup">初始配置</el-dropdown-item>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
<el-dropdown-item command="status">服务状态</el-dropdown-item>
<el-dropdown-item divided disabled class="legacy-theme-label">高级 · 兼容主题</el-dropdown-item>
@@ -193,6 +194,8 @@ function onUserCommand(cmd: string) {
router.push('/login')
} else if (cmd === 'status') {
router.push('/status')
} else if (cmd === 'setup') {
router.push('/admin/setup')
} else if (cmd === 'wizard') {
router.push('/wizard')
}
@@ -11,6 +11,7 @@ import type {
const PAGES: PageDef[] = [
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
{ key: 'admin-setup', label: '初始配置', group: '概览', scope: 'Platform' },
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
{ key: 'admin-alarms', label: '报警管理', group: '概览', scope: 'Platform' },
@@ -29,6 +29,7 @@ const routes: RouteRecordRaw[] = [
redirect: '/admin/dashboard',
children: [
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
{ path: 'setup', name: 'admin-setup', component: () => import('@/views/admin/SetupChecklistView.vue'), meta: { title: '初始配置' } },
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } },
{ path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } },
@@ -42,6 +43,13 @@ const routes: RouteRecordRaw[] = [
{ path: 'wcs-template-proto', name: 'admin-wcs-template-proto', component: () => import('@/views/admin/WcsTemplateProtoView.vue'), meta: { title: 'WCS模板引擎原型' } },
{ 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: '车辆运维' } },
@@ -108,9 +116,33 @@ const router = createRouter({
routes
})
function safeRedirect(raw: unknown, fallback: string): string {
const redirect = Array.isArray(raw) ? raw[0] : raw
if (
typeof redirect === 'string' &&
redirect.startsWith('/') &&
!redirect.startsWith('//') &&
!redirect.startsWith('/login')
) {
return redirect
}
return fallback
}
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) return true
if (to.meta.public) {
// 已登录再进登录页:直接送去向导 / 业务页,避免「登录成功仍停在 /login」。
if (to.name === 'login' && auth.isAuthed) {
if (!auth.validated) {
try { await auth.validate() } catch { return true }
}
if (auth.needsWizard) return { name: 'wizard' }
const fallback = auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard'
return { path: safeRedirect(to.query.redirect, fallback) }
}
return true
}
if (!auth.isAuthed) {
return { path: '/login', query: { redirect: to.fullPath } }
}
@@ -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 {
/* 保存已成功,权限下次进页 / 刷新再对齐 */
}
}
}
})
@@ -0,0 +1,22 @@
export interface SetupCarParamRow {
id: number
name: string
address: string
port: string
paramsReady: boolean
}
export interface SetupStatus {
carCount: number
carsWithParams: number
siteCount: number
trackCount: number
carsReady: boolean
mapsReady: boolean
incomplete: boolean
error?: string
cars: SetupCarParamRow[]
navigationKinds: string[]
scenarios: string[]
modules: string[]
}
@@ -47,7 +47,7 @@ export interface DeploymentProfileDto {
navigationKinds: string[]
scenarios: string[]
updatedBy: string
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.magnetic)。 */
/** 由导航 + 业务场景推导的 SimpleLite 激活场景 id(如 scene.mag / scene.qrlidarscene.signal 仅 SPS / Pack)。 */
activeSceneIds: string[]
/** 被部署画像裁剪隐藏的页面 Key。 */
hiddenPages: string[]
@@ -39,7 +39,7 @@
<div class="panel-sub">登录以进入智能调度平台</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk>
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk @submit.prevent="submit">
<el-form-item prop="username">
<el-input v-model="form.username" size="large" placeholder="用户名" autocomplete="username" clearable>
<template #prefix><el-icon><User /></el-icon></template>
@@ -136,7 +136,7 @@
</el-collapse-item>
</el-collapse>
<el-button type="primary" :loading="loading" class="btn-login" size="large" @click="submit">
<el-button type="primary" native-type="submit" :loading="loading" class="btn-login" size="large">
</el-button>
@@ -205,10 +205,28 @@ const rules: FormRules = {
const year = computed(() => new Date().getFullYear())
function postLoginTarget(needsWizard?: boolean): string {
if (needsWizard) return '/wizard'
const raw = route.query.redirect
const redirect = Array.isArray(raw) ? raw[0] : raw
if (
typeof redirect === 'string' &&
redirect.startsWith('/') &&
!redirect.startsWith('//') &&
!redirect.startsWith('/login')
) {
return redirect
}
return form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map'
}
async function submit() {
if (!formRef.value) return
await formRef.value.validate(async (ok) => {
if (!ok) return
try {
await formRef.value.validate()
} catch {
return
}
loading.value = true
try {
const resp = await auth.login({
@@ -230,15 +248,13 @@ async function submit() {
showClose: true
})
}
const target = (route.query.redirect as string | undefined) ?? (form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map')
router.push(target)
await router.push(postLoginTarget(resp.needsWizard))
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
ElMessage.error(`登录失败:${msg}`)
} finally {
loading.value = false
}
})
}
</script>
@@ -11,7 +11,7 @@
</div>
<div class="wz-titles">
<div class="wz-title">平台配置向导</div>
<div class="wz-sub">按需选择导航方式与功能模块系统据此裁剪界面并按需加载内核能力</div>
<div class="wz-sub">先选导航方式再选业务场景与功能模块保存后进入车辆与地图配置</div>
</div>
</div>
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
@@ -23,6 +23,7 @@
<div class="wz-main">
<section class="wz-section">
<div class="wz-section-head">
<span class="step-no">1</span>
<el-icon><Compass /></el-icon><h3>导航方式</h3><span class="req">至少选 1 </span>
</div>
<div class="chip-grid">
@@ -37,7 +38,28 @@
</section>
<section class="wz-section">
<div class="wz-section-head"><el-icon><Box /></el-icon><h3>功能模块</h3></div>
<div class="wz-section-head">
<span class="step-no">2</span>
<el-icon><Histogram /></el-icon><h3>业务场景</h3><span class="opt">可多选可暂不选</span>
</div>
<div class="section-hint">SPS 物料配送电池 Pack 自动化产线才会加载 signal 插件</div>
<div v-if="scenarioTemplates.length" class="chip-grid">
<button
v-for="t in scenarioTemplates" :key="t.id" type="button"
class="chip" :class="{ on: sel.scenarios.includes(t.id) }"
@click="toggle(sel.scenarios, t.id)">
<div class="chip-name">{{ t.name }}</div>
<div class="chip-desc">{{ t.category }}</div>
</button>
</div>
<div v-else class="chip-empty">暂无场景模板可跳过这一步</div>
</section>
<section class="wz-section">
<div class="wz-section-head">
<span class="step-no">3</span>
<el-icon><Box /></el-icon><h3>功能模块</h3><span class="opt">可多选可暂不选</span>
</div>
<div class="chip-grid">
<button
v-for="o in options?.modules ?? []" :key="o.id" type="button"
@@ -48,19 +70,6 @@
</button>
</div>
</section>
<section v-if="scenarioTemplates.length" class="wz-section">
<div class="wz-section-head"><el-icon><Histogram /></el-icon><h3>业务场景</h3></div>
<div class="chip-grid">
<button
v-for="t in scenarioTemplates" :key="t.id" type="button"
class="chip" :class="{ on: sel.scenarios.includes(t.id) }"
@click="toggle(sel.scenarios, t.id)">
<div class="chip-name">{{ t.name }}</div>
<div class="chip-desc">{{ t.category }}</div>
</button>
</div>
</section>
</div>
<aside class="wz-summary">
@@ -80,9 +89,9 @@
<footer class="wz-foot">
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
<div class="foot-right">
<span class="foot-hint">保存后写入部署画像并联动 SimpleLite 选择性加载导航场景</span>
<span class="foot-hint">保存后进入车辆与地图配置不选导航方式无法继续</span>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>完成并进入平台
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>下一步进入配置
</el-button>
</div>
</footer>
@@ -117,11 +126,23 @@ const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.
// id DeploymentProfile.NavKindToSceneId
const NAV_SCENE: Record<string, string> = {
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 SIGNAL_SCENARIOS = ['tpl-sps', 'tpl-pack']
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.scenarios.some((s) => SIGNAL_SCENARIOS.includes(s)) && !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)
@@ -137,8 +158,7 @@ onMounted(async () => {
options.value = opt
sel.platformType = profile.platformType || 'standard'
sel.navigationKinds = [...(profile.navigationKinds ?? [])]
// WMS
sel.modules = profile.modules?.length ? [...profile.modules] : ['wms']
sel.modules = [...(profile.modules ?? [])]
sel.scenarios = [...(profile.scenarios ?? [])]
} catch (e) {
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
@@ -161,8 +181,9 @@ async function save() {
scenarios: sel.scenarios
})
auth.markWizardDone()
ElMessage.success('部署配置已保存')
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
await auth.refreshPermissions()
ElMessage.success('选型已保存,请继续配置车辆与地图')
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/setup')
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
@@ -280,11 +301,28 @@ function onLogout() {
}
.wz-section-head .el-icon { font-size: 18px; color: var(--lg-accent); }
.wz-section-head h3 { margin: 0; font-size: 15px; font-weight: 600; }
.wz-section-head .step-no {
width: 20px; height: 20px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: #fff;
background: var(--lg-primary);
}
.wz-section-head .req {
font-size: 11px; color: var(--lg-accent);
padding: 1px 8px; border-radius: 8px;
border: 1px solid rgba(var(--lg-accent-rgb), 0.4);
}
.wz-section-head .opt {
font-size: 11px; color: rgba(232, 215, 245, 0.7);
padding: 1px 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
}
.chip-empty {
font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 8px 2px;
}
.section-hint {
font-size: 12px; color: rgba(232, 215, 245, 0.6); margin: -4px 0 10px; line-height: 1.5;
}
.chip-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
@@ -1,5 +1,9 @@
<template>
<div class="car-page">
<SetupGuideAlert
title="必须添加车辆并补齐参数"
desc="至少添加 1 辆车,并在车辆属性中填写 IP(字段 address)和端口(字段 Port,默认 5000)。配完后返回初始配置查看进度。"
/>
<el-tabs v-model="tab" class="car-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
<el-tab-pane label="车辆列表" name="list">
<ReflectionManagerPanel
@@ -22,6 +26,7 @@
import { onMounted, ref } from 'vue'
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
import CarStyleEditor from './CarStyleEditor.vue'
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
const tab = ref<'list' | 'style'>('list')
@@ -1,5 +1,21 @@
<template>
<div class="dashboard">
<el-alert
v-if="setupBanner"
class="setup-banner"
type="warning"
show-icon
closable
@close="dismissSetupBanner"
>
<template #title>
<div class="setup-banner-row">
<span>请完成初始配置必须添加车辆并补齐参数同时配置地图站点路径与功能参数</span>
<el-button size="small" type="primary" @click="router.push('/admin/setup')">继续配置</el-button>
</div>
</template>
</el-alert>
<!-- ===== 系统状态栏实时连接 / 关键指标 / 时钟 ===== -->
<section class="status-bar">
<div class="status-left">
@@ -353,11 +369,19 @@ import { useDashboardQuickEntries } from '@/composables/useDashboardQuickEntries
import { useQuickEntryDragSwap } from '@/composables/useQuickEntryDragSwap'
import { fetchAlarmFeed } from '@/api/alarm'
import { listCars, listMissions } from '@/api/projection'
import { loadSetupStatus } from '@/api/setup'
import type { VehicleAlarm } from '@/types/alarm'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
const router = useRouter()
const SETUP_BANNER_KEY = 'simple.setup.bannerDismissed'
const setupBanner = ref(false)
function dismissSetupBanner() {
setupBanner.value = false
try { sessionStorage.setItem(SETUP_BANNER_KEY, '1') } catch { /* ignore */ }
}
const {
resolvedEntries,
@@ -726,6 +750,13 @@ onMounted(async () => {
cars.value = carList
missions.value = missionList
alarms.value = alarmFeed.alarms
try {
const dismissed = sessionStorage.getItem(SETUP_BANNER_KEY) === '1'
if (!dismissed) {
const st = await loadSetupStatus()
setupBanner.value = st.incomplete
}
} catch { /* 清单失败不挡总览 */ }
await nextTick()
renderTrendChart()
renderAgvChart()
@@ -754,6 +785,10 @@ onUnmounted(() => {
.dashboard > * {
flex-shrink: 0;
}
.setup-banner { margin: 12px 16px 0; }
.setup-banner-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
}
/* Hero Banner
* 设计策略Hero 区始终是深色参考 SaaS Dashboard 标杆但色调跟随主题切换
@@ -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>
@@ -13,6 +13,11 @@
</div>
</div>
<SetupGuideAlert
title="请先准备地图"
desc="新增或选用一张地图后,再到场景管理添加站点与路径。"
/>
<el-alert
v-if="directory"
class="dir-tip"
@@ -114,6 +119,7 @@ import { mapsApi, type MapListItem } from '@/api/mapEdit'
import JsonFoldViewer from '@/components/common/JsonFoldViewer.vue'
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
const router = useRouter()
const tableRef = ref<TableInstance>()
@@ -1,5 +1,9 @@
<template>
<div class="scene-mgr-page ops-console-page">
<SetupGuideAlert
title="请配置站点与路径"
desc="在「站点」页添加站点,在「路径」页连接站点。至少各有 1 条后,初始配置中的地图步骤才会完成。"
/>
<el-tabs v-model="activeTab" class="scene-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
<el-tab-pane label="站点" name="site">
<ReflectionManagerPanel
@@ -42,9 +46,15 @@
*/
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
const activeTab = ref<'site' | 'track' | 'special'>('site')
const route = useRoute()
const rawTab = Array.isArray(route.query.tab) ? route.query.tab[0] : route.query.tab
const activeTab = ref<'site' | 'track' | 'special'>(
rawTab === 'track' || rawTab === 'special' ? rawTab : 'site'
)
const sitePanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
const trackPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
@@ -0,0 +1,206 @@
<template>
<div class="setup-page" v-loading="loading">
<header class="setup-head">
<div>
<h1>初始配置</h1>
<p>向导选型已保存请先完成车辆与地图调度才能落地</p>
</div>
<div class="setup-progress">
<span class="pg-num">{{ doneCount }}/{{ steps.length }}</span>
<span class="pg-label">必做步骤</span>
</div>
</header>
<el-alert
v-if="status?.error"
type="error"
:closable="false"
show-icon
:title="`无法读取现场数据:${status.error}`"
/>
<div v-if="profileLine" class="setup-profile">{{ profileLine }}</div>
<article class="setup-card" :class="{ done: status?.carsReady }">
<div class="card-head">
<div class="card-index">1</div>
<div class="card-titles">
<h2>车辆配置</h2>
<el-tag size="small" type="danger" effect="plain">必做</el-tag>
</div>
<el-tag :type="status?.carsReady ? 'success' : 'warning'" size="small">
{{ status?.carsReady ? '已完成' : '未完成' }}
</el-tag>
</div>
<p class="card-lead">
必须至少添加 <b>1 辆车</b>并补齐通讯参数<b>IP</b>字段 address <b>端口</b>字段 Port默认 5000
</p>
<ul class="card-facts">
<li>当前车辆{{ status?.carCount ?? '—' }} </li>
<li>已填 IP + 端口{{ status?.carsWithParams ?? '—' }} </li>
</ul>
<div class="card-actions">
<el-button type="primary" @click="go('/admin/cars?setup=1')">去添加车辆</el-button>
</div>
</article>
<article class="setup-card" :class="{ done: status?.mapsReady }">
<div class="card-head">
<div class="card-index">2</div>
<div class="card-titles">
<h2>地图配置</h2>
<el-tag size="small" type="danger" effect="plain">必做</el-tag>
</div>
<el-tag :type="status?.mapsReady ? 'success' : 'warning'" size="small">
{{ status?.mapsReady ? '已完成' : '未完成' }}
</el-tag>
</div>
<p class="card-lead">按顺序配置站点路径和功能参数至少要有 1 个站点和 1 条路径</p>
<ul class="card-facts">
<li>站点{{ status?.siteCount ?? '—' }}</li>
<li>路径{{ status?.trackCount ?? '—' }}</li>
</ul>
<div class="map-grid">
<button type="button" class="map-link" @click="go('/admin/maps?setup=1')">
<span class="map-link-name">地图管理</span>
<span class="map-link-desc">新增或选用地图文件</span>
</button>
<button type="button" class="map-link" @click="go('/admin/tracks?setup=1&tab=site')">
<span class="map-link-name">站点</span>
<span class="map-link-desc">在场景里添加站点</span>
</button>
<button type="button" class="map-link" @click="go('/admin/tracks?setup=1&tab=track')">
<span class="map-link-name">路径</span>
<span class="map-link-desc">连接站点形成路径</span>
</button>
<button type="button" class="map-link" @click="go('/admin/simple-fields?setup=1')">
<span class="map-link-name">功能参数</span>
<span class="map-link-desc">维护站点 / 车辆字段</span>
</button>
</div>
</article>
<footer class="setup-foot">
<el-button @click="refresh" :loading="loading">刷新进度</el-button>
<el-button type="primary" @click="go('/admin/dashboard')">
{{ status?.incomplete === false ? '进入总览' : '稍后去总览' }}
</el-button>
</footer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { loadSetupStatus } from '@/api/setup'
import type { SetupStatus } from '@/types/setup'
const router = useRouter()
const loading = ref(false)
const status = ref<SetupStatus | null>(null)
const steps = [{ id: 'cars' }, { id: 'maps' }]
const doneCount = computed(() => {
if (!status.value) return 0
return Number(status.value.carsReady) + Number(status.value.mapsReady)
})
const NAV_LABEL: Record<string, string> = {
magnetic: '磁导航',
qrcode: '二维码导航',
laser: '激光导航'
}
const profileLine = computed(() => {
const s = status.value
if (!s) return ''
const nav = s.navigationKinds.map((k) => NAV_LABEL[k] ?? k).join('、') || '未选'
const scene = s.scenarios.length ? s.scenarios.join('、') : '暂不选'
const mods = s.modules.length ? s.modules.join('、') : '暂不选'
return `本次选型:导航 ${nav} · 场景 ${scene} · 模块 ${mods}`
})
async function refresh() {
loading.value = true
try {
status.value = await loadSetupStatus()
} finally {
loading.value = false
}
}
function go(path: string) {
router.push(path)
}
onMounted(() => { void refresh() })
</script>
<style scoped>
.setup-page {
max-width: 920px;
margin: 0 auto;
padding: 8px 4px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.setup-head {
display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
}
.setup-head h1 { margin: 0; font-size: 22px; color: var(--mg-text, #28213a); }
.setup-head p { margin: 6px 0 0; font-size: 13px; color: var(--mg-text-muted, #756d85); }
.setup-progress {
display: flex; flex-direction: column; align-items: flex-end; line-height: 1.2;
}
.pg-num { font-size: 28px; font-weight: 700; color: #7543e8; font-variant-numeric: tabular-nums; }
.pg-label { font-size: 12px; color: #756d85; }
.setup-profile {
font-size: 12.5px; color: #756d85;
padding: 8px 12px; border-radius: 10px;
background: #f6f3fb; border: 1px solid rgba(40, 33, 58, 0.08);
}
.setup-card {
background: #fff;
border: 1px solid rgba(40, 33, 58, 0.08);
border-radius: 14px;
padding: 18px 20px 16px;
}
.setup-card.done { border-color: rgba(82, 196, 26, 0.35); }
.card-head { display: flex; align-items: center; gap: 12px; }
.card-index {
width: 28px; height: 28px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: #7543e8; color: #fff; font-weight: 700; font-size: 13px;
}
.card-titles { flex: 1; display: flex; align-items: center; gap: 8px; }
.card-titles h2 { margin: 0; font-size: 16px; }
.card-lead { margin: 12px 0 8px; font-size: 13.5px; line-height: 1.65; color: #4a4458; }
.card-facts {
margin: 0 0 14px; padding: 0 0 0 18px;
font-size: 13px; color: #756d85; line-height: 1.7;
}
.card-actions { display: flex; gap: 8px; }
.map-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
.map-link {
appearance: none; cursor: pointer; text-align: left;
border: 1px solid rgba(40, 33, 58, 0.1);
background: #f6f3fb;
border-radius: 12px;
padding: 12px 14px;
color: #28213a;
transition: border-color .15s, transform .15s;
}
.map-link:hover { border-color: #7543e8; transform: translateY(-1px); }
.map-link-name { display: block; font-weight: 600; font-size: 14px; }
.map-link-desc { display: block; margin-top: 4px; font-size: 12px; color: #756d85; }
.setup-foot {
display: flex; justify-content: flex-end; gap: 10px; padding-top: 4px;
}
@media (max-width: 640px) {
.map-grid { grid-template-columns: 1fr; }
.setup-head { flex-direction: column; align-items: flex-start; }
}
</style>
@@ -15,6 +15,11 @@
</div>
</template>
<SetupGuideAlert
title="请配置功能参数"
desc="在此维护站点 / 车辆字段(速度、功能点等)。配完后返回初始配置。"
/>
<div class="filters">
<div class="car-type-filter">
<span class="filter-label">车辆类型</span>
@@ -172,6 +177,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
import * as simpleFieldApi from '@/api/simpleField'
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
import {
SIMPLE_FIELD_CATEGORIES,
buildCarType,