Merge remote-tracking branch 'origin/main'

This commit is contained in:
黄兆尉
2026-08-26 13:56:20 +08:00
57 changed files with 5028 additions and 76 deletions
+1
View File
@@ -41,3 +41,4 @@ Desktop.ini
# 临时脚本 / 工具产物(不应入库)
.codex-temp/
frontends/apps/simple-platform-vue/imgui.ini
/.cursor/plans
+10
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),
@@ -42,8 +43,12 @@ public static class PageCatalog
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
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),
@@ -86,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,
@@ -8,7 +8,8 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"wcs-proto:selfcheck": "npx --yes tsx src/wcs-proto/selfcheck.ts"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -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: [
@@ -56,9 +58,20 @@ export const ADMIN_MENU: NavMenuItem[] = [
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编辑' },
{ path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编辑' },
{ path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编辑' },
{ path: '/admin/wcs-template-proto', label: 'WCS模板原型', icon: SetUp, key: 'admin-wcs-template-proto', 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: '平台配置中心',
children: [
@@ -84,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 command="logout" divided>退出登录</el-dropdown-item>
@@ -180,6 +181,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' },
@@ -22,6 +23,7 @@ const PAGES: PageDef[] = [
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-wcs-template-proto', label: 'WCS模板原型', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-vehicle-hub', 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: '报警管理' } },
@@ -39,8 +40,16 @@ const routes: RouteRecordRaw[] = [
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
{ 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: '车辆运维' } },
@@ -107,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,40 +205,56 @@ 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
loading.value = true
try {
const resp = await auth.login({
username: form.username,
password: form.password,
scope: form.scope,
launchMode: form.launchMode
try {
await formRef.value.validate()
} catch {
return
}
loading.value = true
try {
const resp = await auth.login({
username: form.username,
password: form.password,
scope: form.scope,
launchMode: form.launchMode
})
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
if (resp.launchWarning) {
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
} else if (resp.runMode === 'Detached') {
ElMessage({
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
type: 'warning',
duration: 6000,
showClose: true
})
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
if (resp.launchWarning) {
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
} else if (resp.runMode === 'Detached') {
ElMessage({
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
type: 'warning',
duration: 6000,
showClose: true
})
}
const target = (route.query.redirect as string | undefined) ?? (form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map')
router.push(target)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
ElMessage.error(`登录失败:${msg}`)
} finally {
loading.value = false
}
})
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,
@@ -0,0 +1,297 @@
<script setup lang="ts">
/**
* WCS 任务模板引擎原型
* - 主能力无代码拖拽流程图设计任务模板对齐设计文档 §4
* - 辅能力运行实例假数据台演示脚本自检
*/
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createWorld } from '@/wcs-proto/runtime/world'
import { DEMO_EVENT } from '@/wcs-proto/mock/seed'
import WcsTemplateDesigner from '@/wcs-proto/designer/WcsTemplateDesigner.vue'
const world = createWorld()
const { state, reservations } = world
const mainTab = ref<'design' | 'runtime'>('design')
const runtimeTab = ref('instances')
const eventForm = reactive({
eventId: DEMO_EVENT.eventId,
materialCode: 'M001',
qty: 1,
lineId: 'L1'
})
const selectedInstanceId = ref<string | null>(null)
const selectedInstance = computed(() => state.instances.find((i) => i.id === selectedInstanceId.value))
const reservationRows = computed(() => reservations.list())
function onInstanceRowClick(row: { id: string }) {
selectedInstanceId.value = row.id
}
function onAffinityInput(row: { materialAffinity: string[] }, v: string) {
row.materialAffinity = v.split(/[,]/).map((s) => s.trim()).filter(Boolean)
}
function onForceChange(storageId: string, val: string | number | boolean) {
if (val) world.forceReserve(storageId)
else world.clearForce(storageId)
}
function doTrigger() {
const inst = world.trigger({
eventId: eventForm.eventId,
source: 'mes.materialCall',
payload: {
materialCode: eventForm.materialCode,
qty: eventForm.qty,
lineId: eventForm.lineId
}
})
mainTab.value = 'runtime'
runtimeTab.value = 'instances'
if (inst) {
selectedInstanceId.value = inst.id
ElMessage.info(`${inst.status} · ${inst.templateName}`)
} else {
ElMessage.warning('无匹配模板')
}
}
function resetAll() {
world.reset()
selectedInstanceId.value = null
ElMessage.success('已重置假数据与实例')
}
function runDemos() {
const results = world.runDemoScripts()
mainTab.value = 'runtime'
runtimeTab.value = 'demos'
const fail = results.filter((r) => !r.ok)
if (fail.length) ElMessage.error(`${fail.length} 条演示失败`)
else ElMessage.success('7 条演示脚本全部通过')
}
</script>
<template>
<div class="wcs-proto-page">
<header class="page-head">
<div>
<h2>WCS 任务模板引擎 · 原型</h2>
<p class="sub">无代码拖拽设计模板 试算选位 模拟触发运行假数据不接真实 MES/AGV</p>
</div>
<div class="head-actions">
<el-radio-group v-model="mainTab" size="small">
<el-radio-button value="design">模板设计</el-radio-button>
<el-radio-button value="runtime">运行与演示</el-radio-button>
</el-radio-group>
<el-button size="small" @click="resetAll">重置世界</el-button>
<el-button size="small" type="warning" @click="runDemos"> 7 条脚本</el-button>
</div>
</header>
<div v-show="mainTab === 'design'" class="design-pane">
<WcsTemplateDesigner :world="world" />
</div>
<div v-show="mainTab === 'runtime'" class="runtime-pane">
<el-row :gutter="12" class="event-bar">
<el-col :span="4">
<el-input v-model="eventForm.eventId" placeholder="eventId" size="small" />
</el-col>
<el-col :span="3">
<el-input v-model="eventForm.materialCode" placeholder="物料" size="small" />
</el-col>
<el-col :span="3">
<el-input v-model="eventForm.lineId" placeholder="产线" size="small" />
</el-col>
<el-col :span="3">
<el-input-number v-model="eventForm.qty" :min="1" size="small" controls-position="right" />
</el-col>
<el-col :span="6" class="event-actions">
<el-button type="primary" size="small" @click="doTrigger">模拟触发</el-button>
<el-switch v-model="state.lookupBroken" active-text="lookup 故障" />
</el-col>
</el-row>
<el-tabs v-model="runtimeTab">
<el-tab-pane label="实例" name="instances">
<el-row :gutter="12">
<el-col :span="14">
<el-table
:data="state.instances"
size="small"
highlight-current-row
@row-click="onInstanceRowClick"
>
<el-table-column prop="id" label="实例" width="100" />
<el-table-column prop="templateName" label="模板" />
<el-table-column prop="status" label="状态" width="130" />
<el-table-column label="路径" min-width="140">
<template #default="{ row }">
<span v-if="row.sourceId">{{ row.sourceId }} {{ row.targetId }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button
v-if="row.status === 'Dispatched'"
size="small"
@click.stop="world.dispatch(row.id, 'start')"
>开始</el-button>
<el-button
v-if="row.status === 'Dispatched' || row.status === 'InTransit'"
size="small"
type="success"
@click.stop="world.dispatch(row.id, 'complete')"
>完成</el-button>
<el-button
v-if="!['Completed','Failed','Cancelled','IgnoredDuplicate'].includes(row.status)"
size="small"
type="danger"
@click.stop="world.cancel(row.id)"
>取消</el-button>
</template>
</el-table-column>
</el-table>
</el-col>
<el-col :span="10">
<template v-if="selectedInstance">
<h4>时间线</h4>
<el-timeline>
<el-timeline-item v-for="(t, i) in selectedInstance.timeline" :key="i" :timestamp="t.at">
{{ t.status }} <span v-if="t.note">· {{ t.note }}</span>
</el-timeline-item>
</el-timeline>
<h4>explain</h4>
<pre class="explain">{{ JSON.stringify(selectedInstance.explain, null, 2) }}</pre>
</template>
<el-empty v-else description="选择一条实例" />
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="假数据台" name="mock">
<el-table :data="state.storages" size="small" max-height="360">
<el-table-column prop="storageId" label="ID" width="90" />
<el-table-column prop="areaId" label="库区" width="90" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column label="物料亲和" min-width="120">
<template #default="{ row }">
<el-input
size="small"
:model-value="row.materialAffinity.join(',')"
@update:model-value="(v: string) => onAffinityInput(row, v)"
/>
</template>
</el-table-column>
<el-table-column label="禁用" width="70">
<template #default="{ row }">
<el-switch v-model="row.disabled" size="small" />
</template>
</el-table-column>
<el-table-column label="强制预占" width="90">
<template #default="{ row }">
<el-switch
:model-value="!!row.forceReserved"
size="small"
@change="(v) => onForceChange(row.storageId, v)"
/>
</template>
</el-table-column>
</el-table>
<h4>预占表</h4>
<el-table :data="reservationRows" size="small">
<el-table-column prop="storageId" label="库位" />
<el-table-column prop="instanceId" label="实例" />
<el-table-column prop="status" label="状态" />
</el-table>
</el-tab-pane>
<el-tab-pane label="演示脚本" name="demos">
<el-table :data="state.demoResults" size="small">
<el-table-column prop="id" label="ID" width="120" />
<el-table-column prop="name" label="名称" width="140" />
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.ok ? 'success' : 'danger'" size="small">{{ row.ok ? '通过' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" label="说明" />
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<style scoped>
.wcs-proto-page {
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
padding: 10px 12px 0;
box-sizing: border-box;
overflow: hidden;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
flex-shrink: 0;
}
.page-head h2 {
margin: 0 0 2px;
font-size: 18px;
}
.sub {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.design-pane {
flex: 1;
min-height: 0;
border: 1px solid var(--el-border-color);
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
}
.runtime-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding-bottom: 16px;
}
.event-bar {
margin-bottom: 8px;
align-items: center;
}
.event-actions {
display: flex;
align-items: center;
gap: 8px;
}
.explain {
background: var(--el-fill-color-light);
padding: 10px;
border-radius: 6px;
font-size: 12px;
max-height: 280px;
overflow: auto;
}
h4 {
margin: 12px 0 8px;
font-size: 14px;
}
</style>
@@ -0,0 +1,41 @@
# WCS 任务模板引擎 · 原型
不接真实 MES / 仓库事务 / AGV,用假数据跑通操作逻辑:
触发 → 参数绑定 → 过滤 / 打分 / 降级 → 成对预占(失败全体回滚)→ 下发 / 取消善后。
## 入口
- 页面:管理员菜单 **设计与编辑 → WCS模板原型**
- 路由:`/admin/wcs-template-proto`
- 自检:`pnpm wcs-proto:selfcheck`(在 `simple-platform-vue` 目录)
## 目录
| 路径 | 说明 |
|---|---|
| `types.ts` | DSL 与运行时类型 |
| `mock/seed.ts` | 库位、参数目录、预置模板 |
| `engine/*` | 条件求值、选位、预占、校验、编排 |
| `runtime/world.ts` | 内存世界 + 7 条演示脚本 |
| `selfcheck.ts` | 无 UI 自检 |
## 页面能力
### 模板设计(主界面,对齐设计文档 §4)
- **左侧**:模板列表 + 可拖拽控件库(触发 / 参数 / 过滤 / 打分 / 预占 / 动作…)
- **中间**:Vue Flow 流水线画布(源策略左列、宿策略右列、主轴为触发→绑定→预占→动作)
- **右侧**:点选节点后用表单编辑(条件行、硬性/弹性、打分函数等,默认不写脚本)
- **底部**:试算台(样例参数 → 候选库位 / 降级说明)
- **高级**:抽屉中可查看/粘贴 DSL JSON
### 运行与演示
1. **实例**:模拟触发后推进开始/完成/取消
2. **假数据台**:改库位亲和、禁用、强制预占
3. **演示脚本**:一键跑通 7 条验收用例
## 下一步(P0
把 Mock 换成真实 ParamProvider、仓库读模型、预占落库与 DispatchAdapter;编排与 DSL 尽量不改。
@@ -0,0 +1,92 @@
<template>
<div class="wcs-node" :class="{ selected }" :style="{ '--node-color': color }">
<Handle type="target" :position="Position.Top" class="h-in" />
<div class="head">
<span class="dot" />
<span class="type">{{ typeLabel }}</span>
<el-tag v-if="severityTag" size="small" :type="severityTag === '硬性' ? 'danger' : 'warning'" class="sev">
{{ severityTag }}
</el-tag>
</div>
<div class="label">{{ data.label }}</div>
<div class="summary">{{ data.summary }}</div>
<Handle type="source" :position="Position.Bottom" class="h-out" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { WCS_NODE_MAP } from './nodeCatalog'
import type { WcsFlowNodeData } from './flowBridge'
const props = defineProps<{
id: string
selected?: boolean
data: WcsFlowNodeData
}>()
const color = computed(() => WCS_NODE_MAP[props.data.type]?.color ?? '#909399')
const typeLabel = computed(() => WCS_NODE_MAP[props.data.type]?.label ?? props.data.type)
const severityTag = computed(() => {
const s = props.data.summary
if (s.startsWith('硬性')) return '硬性'
if (s.startsWith('弹性')) return '弹性'
return ''
})
</script>
<style scoped>
.wcs-node {
min-width: 168px;
max-width: 220px;
padding: 10px 12px;
border-radius: 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-left: 4px solid var(--node-color);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
font-size: 12px;
}
.wcs-node.selected {
border-color: var(--node-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--node-color) 35%, transparent);
}
.head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-color);
}
.type {
color: var(--el-text-color-secondary);
font-size: 11px;
}
.sev {
margin-left: auto;
}
.label {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 2px;
word-break: break-all;
}
.summary {
color: var(--el-text-color-secondary);
line-height: 1.35;
word-break: break-all;
}
.h-in,
.h-out {
width: 8px !important;
height: 8px !important;
background: var(--node-color) !important;
border: 2px solid #fff !important;
}
</style>
@@ -0,0 +1,401 @@
<template>
<div class="inspector">
<template v-if="!node || !refKind">
<el-empty description="点选画布节点,或从左侧拖入积木" :image-size="72" />
</template>
<template v-else-if="refKind === 'trigger'">
<h4>触发</h4>
<el-form label-position="top" size="small">
<el-form-item label="类型">
<el-select v-model="tpl.trigger.type" style="width: 100%">
<el-option label="事件" value="event" />
<el-option label="人工" value="manual" />
</el-select>
</el-form-item>
<el-form-item label="事件源">
<el-input v-model="tpl.trigger.source" placeholder="mes.materialCall" />
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="tpl.meta.priority" :min="1" :max="999" style="width: 100%" />
</el-form-item>
<el-form-item label="互斥组">
<el-input v-model="tpl.meta.mutexGroup" placeholder="line-side-replenish" />
</el-form-item>
</el-form>
</template>
<template v-else-if="refKind === 'binding' && binding">
<h4>参数绑定</h4>
<el-form label-position="top" size="small">
<el-form-item label="写入上下文名 (as)">
<el-input v-model="binding.as" />
</el-form-item>
<el-form-item label="来自字段目录">
<el-select v-model="binding.from" filterable style="width: 100%" @change="onFromChange">
<el-option
v-for="f in paramFields"
:key="`${f.module}.${f.path}`"
:label="`${f.module}.${f.path} · ${f.description}`"
:value="`${f.module}.${f.path}`"
/>
</el-select>
</el-form-item>
<el-form-item label="必填">
<el-switch v-model="binding.required" />
</el-form-item>
<el-form-item v-if="binding.resolve === 'lookup'" label="查询键模板">
<el-input v-model="lookupTemplate" placeholder="line:{lineId}" @change="syncLookupKey" />
</el-form-item>
<el-form-item v-if="binding.resolve === 'lookup'" label="失败策略">
<el-select v-model="binding.onError" style="width: 100%">
<el-option label="用缓存" value="cached" />
<el-option label="挂起" value="suspend" />
<el-option label="默认值" value="default" />
<el-option label="失败" value="fail" />
</el-select>
</el-form-item>
<el-button type="danger" plain size="small" @click="removeBinding">删除此绑定</el-button>
</el-form>
</template>
<template v-else-if="refKind === 'filter' && filterGroup">
<h4>过滤条件 · {{ sideLabel }}</h4>
<el-form label-position="top" size="small">
<el-form-item label="组 ID">
<el-input v-model="filterGroup.id" disabled />
</el-form-item>
<el-form-item label="级别">
<el-radio-group v-model="filterGroup.severity">
<el-radio value="hard">硬性不可降级放开</el-radio>
<el-radio value="soft">弹性可降级</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div class="cmp-title">条件行组内 AND</div>
<div v-for="(row, i) in compareRows" :key="i" class="cmp-row">
<el-select v-model="row.left" size="small" filterable allow-create style="width: 100%">
<el-option v-for="f in wcsFields" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="row.op" size="small" style="width: 100%">
<el-option v-for="op in ops" :key="op" :label="op" :value="op" />
</el-select>
<el-input v-model="row.right" size="small" placeholder="常量或 context.xxx" />
<el-button size="small" text type="danger" @click="removeCompare(i)"></el-button>
</div>
<el-button size="small" @click="addCompare">加条件</el-button>
<el-button size="small" type="danger" plain @click="removeFilter">删除此过滤组</el-button>
</template>
<template v-else-if="refKind === 'score' && scoreRule">
<h4>打分 · {{ sideLabel }}</h4>
<el-form label-position="top" size="small">
<el-form-item label="函数">
<el-select v-model="scoreRule.function" style="width: 100%">
<el-option label="距离更近" value="nearer_to_ref" />
<el-option label="先入先出" value="fifo_age" />
<el-option label="字段匹配加分" value="field_match_bonus" />
<el-option label="固定分" value="constant" />
</el-select>
</el-form-item>
<el-form-item label="权重">
<el-input-number v-model="scoreRule.weight" :min="0.1" :step="0.5" style="width: 100%" />
</el-form-item>
<el-form-item v-if="scoreRule.function === 'nearer_to_ref'" label="参照点 X">
<el-input-number v-model="(scoreRule.params as any).refX" style="width: 100%" />
</el-form-item>
<el-form-item v-if="scoreRule.function === 'nearer_to_ref'" label="参照点 Y">
<el-input-number v-model="(scoreRule.params as any).refY" style="width: 100%" />
</el-form-item>
<el-form-item v-if="scoreRule.function === 'constant'" label="分值">
<el-input-number v-model="(scoreRule.params as any).value" style="width: 100%" />
</el-form-item>
<el-button type="danger" plain size="small" @click="removeScore">删除此打分</el-button>
</el-form>
</template>
<template v-else-if="refKind === 'allocate'">
<h4>成对预占</h4>
<el-alert type="info" :closable="false" show-icon class="mb" title="源/宿任一失败则全体回滚并释放预占" />
<el-form label-position="top" size="small">
<el-form-item label="源 TopN">
<el-input-number v-model="tpl.locationStrategies.source.allocate.topN" :min="1" :max="50" style="width: 100%" />
</el-form-item>
<el-form-item label="宿 TopN">
<el-input-number v-model="tpl.locationStrategies.target.allocate.topN" :min="1" :max="50" style="width: 100%" />
</el-form-item>
</el-form>
</template>
<template v-else-if="refKind === 'action'">
<h4>任务动作</h4>
<el-form label-position="top" size="small">
<el-form-item label="任务类型">
<el-input model-value="transport" disabled />
</el-form-item>
<el-form-item label="自动派车">
<el-switch v-model="tpl.blueprint.options.autoDispatch" />
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="tpl.blueprint.options.priority" :min="1" :max="999" style="width: 100%" />
</el-form-item>
</el-form>
</template>
<template v-else-if="refKind === 'policy'">
<h4>运行策略</h4>
<el-form label-position="top" size="small">
<el-form-item label="预占超时(秒)">
<el-input-number v-model="tpl.policy.reservationTtlSec" :min="30" :max="3600" style="width: 100%" />
</el-form-item>
<el-form-item label="选位最大尝试">
<el-input-number v-model="tpl.policy.allocateMaxAttempts" :min="1" :max="20" style="width: 100%" />
</el-form-item>
<el-form-item label="无候选时">
<el-select v-model="tpl.policy.onNoCandidate" style="width: 100%">
<el-option label="告警" value="raise_alert" />
<el-option label="失败" value="fail" />
</el-select>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-empty description="此节点无需配置" :image-size="64" />
</template>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import type { CompareOp, ExprNode, FilterGroup, TaskTemplateDsl } from '../types'
import { PARAM_FIELDS } from '../mock/seed'
import type { WcsFlowNode } from './flowBridge'
const props = defineProps<{
tpl: TaskTemplateDsl
node: WcsFlowNode | null
}>()
const emit = defineEmits<{
change: []
removeFocus: []
}>()
const paramFields = PARAM_FIELDS
const ops: CompareOp[] = ['eq', 'ne', 'in', 'contains', 'gt', 'gte', 'lt', 'lte', 'exists']
const wcsFields = [
'wcs.disabled',
'wcs.storageType',
'wcs.status',
'wcs.materialAffinity',
'wcs.allowInbound',
'wcs.allowOutbound',
'wcs.lineId',
'wcs.areaId',
'wcs.x',
'wcs.y'
]
const nodeData = computed(() => props.node?.data)
const refKind = computed(() => nodeData.value?.ref?.kind)
const side = computed(() => nodeData.value?.ref?.side)
const sideLabel = computed(() => (side.value === 'target' ? '宿' : '源'))
const binding = computed(() => {
const idx = nodeData.value?.ref?.index
if (idx == null || idx < 0) return null
return props.tpl.bindings[idx] ?? null
})
const filterGroup = computed(() => {
const r = nodeData.value?.ref
if (r?.kind !== 'filter' || !r.side || !r.id) return null
return props.tpl.locationStrategies[r.side].filterGroups.find((g) => g.id === r.id) ?? null
})
const scoreRule = computed(() => {
const r = nodeData.value?.ref
if (r?.kind !== 'score' || !r.side || !r.id) return null
return props.tpl.locationStrategies[r.side].scores.find((s) => s.id === r.id) ?? null
})
watch(
scoreRule,
(s) => {
if (!s) return
if (s.function === 'nearer_to_ref') {
if (s.params.refX == null) s.params.refX = 50
if (s.params.refY == null) s.params.refY = 10
}
if (s.function === 'constant' && s.params.value == null) s.params.value = 50
},
{ immediate: true }
)
type CmpRow = { left: string; op: CompareOp; right: string }
const compareRows = reactive<CmpRow[]>([])
function parseRight(raw: string): { ref?: string; const?: unknown } {
const t = raw.trim()
if (t.startsWith('context.') || t.startsWith('wcs.')) return { ref: t }
if (t === 'true') return { const: true }
if (t === 'false') return { const: false }
if (/^-?\d+(\.\d+)?$/.test(t)) return { const: Number(t) }
if (t.startsWith('[') && t.endsWith(']')) {
try {
return { const: JSON.parse(t) }
} catch {
/* fallthrough */
}
}
return { const: t }
}
function syncExprFromRows(g: FilterGroup) {
g.expr = {
type: 'group',
op: 'and',
children: compareRows.map((row) => {
const right = parseRight(row.right)
const node: ExprNode = {
type: 'compare',
left: { ref: row.left },
op: row.op,
right: 'ref' in right && right.ref ? { ref: right.ref } : { const: right.const as never }
}
return node
})
}
emit('change')
}
function loadCompareRows(g: FilterGroup | null) {
compareRows.splice(0, compareRows.length)
if (!g || g.expr.type !== 'group') return
for (const c of g.expr.children) {
if (c.type !== 'compare') continue
const left = 'ref' in c.left ? c.left.ref : ''
let right = ''
if (c.right) {
if ('ref' in c.right) right = c.right.ref
else right = typeof c.right.const === 'string' ? c.right.const : JSON.stringify(c.right.const)
}
compareRows.push({ left, op: c.op, right })
}
}
watch(
() => filterGroup.value,
(g) => loadCompareRows(g),
{ immediate: true }
)
watch(
compareRows,
() => {
if (filterGroup.value) syncExprFromRows(filterGroup.value)
},
{ deep: true }
)
function addCompare() {
compareRows.push({ left: 'wcs.status', op: 'eq', right: 'EmptyContainer' })
}
function removeCompare(i: number) {
compareRows.splice(i, 1)
}
const lookupTemplate = computed({
get: () => binding.value?.key?.template ?? '',
set: (v: string) => {
if (!binding.value) return
if (!binding.value.key) binding.value.key = { template: v, args: { lineId: 'context.lineId' } }
else binding.value.key.template = v
emit('change')
}
})
function syncLookupKey() {
emit('change')
}
function onFromChange(from: string) {
const b = binding.value
if (!b) return
const def = PARAM_FIELDS.find((f) => `${f.module}.${f.path}` === from)
if (def) {
b.resolve = def.resolveMode
if (def.resolveMode === 'lookup' && !b.key) {
b.key = { template: 'line:{lineId}', args: { lineId: 'context.lineId' } }
b.onError = b.onError ?? 'default'
}
}
emit('change')
}
function removeBinding() {
const idx = props.node?.data?.ref?.index
if (idx == null || idx < 0) return
props.tpl.bindings.splice(idx, 1)
emit('removeFocus')
emit('change')
}
function removeFilter() {
const r = props.node?.data?.ref
if (r?.kind !== 'filter' || !r.side || !r.id) return
const st = props.tpl.locationStrategies[r.side]
st.filterGroups = st.filterGroups.filter((g) => g.id !== r.id)
for (const step of st.degradeChain) {
step.requireGroups = step.requireGroups.filter((id) => id !== r.id)
step.drop = (step.drop ?? []).filter((id) => id !== r.id)
}
emit('removeFocus')
emit('change')
}
function removeScore() {
const r = props.node?.data?.ref
if (r?.kind !== 'score' || !r.side || !r.id) return
const st = props.tpl.locationStrategies[r.side]
st.scores = st.scores.filter((s) => s.id !== r.id)
emit('removeFocus')
emit('change')
}
// touch emit on simple field edits via watchers on tpl is hard; inspector mutates tpl in place
watch(
() => props.tpl,
() => emit('change'),
{ deep: true }
)
</script>
<style scoped>
.inspector {
padding: 10px 12px 20px;
overflow: auto;
height: 100%;
box-sizing: border-box;
}
h4 {
margin: 0 0 10px;
font-size: 14px;
}
.cmp-title {
font-size: 12px;
font-weight: 600;
margin: 8px 0 6px;
}
.cmp-row {
display: grid;
grid-template-columns: 1fr 90px 1fr auto;
gap: 4px;
margin-bottom: 6px;
align-items: center;
}
.mb {
margin-bottom: 10px;
}
</style>
@@ -0,0 +1,133 @@
<template>
<div class="palette">
<div class="hint">拖到画布添加积木过滤/打分可指定源或宿</div>
<el-radio-group v-model="dropSide" size="small" class="side">
<el-radio-button value="source">源侧</el-radio-button>
<el-radio-button value="target">宿侧</el-radio-button>
</el-radio-group>
<div v-for="group in grouped" :key="group.category" class="group">
<div class="group-title">
<span class="gdot" :style="{ background: group.color }" />
{{ group.category }}
</div>
<div
v-for="item in group.items"
:key="item.type"
class="item"
draggable="true"
@dragstart="onDragStart($event, item.type)"
>
<span class="idot" :style="{ background: item.color }" />
<div>
<div class="ilab">{{ item.label }}</div>
<div class="idesc">{{ item.description }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WCS_CATEGORY_ORDER, WCS_NODE_CATALOG, type WcsNodeType } from './nodeCatalog'
const dropSide = ref<'source' | 'target'>('source')
const emit = defineEmits<{ 'update:side': [side: 'source' | 'target'] }>()
watch(dropSide, (v) => emit('update:side', v), { immediate: true })
const CATEGORY_COLORS: Record<string, string> = {
触发与参数: '#409eff',
库位策略: '#e6a23c',
任务与策略: '#13c2c2',
流程: '#909399'
}
const grouped = computed(() => {
const by = new Map<string, typeof WCS_NODE_CATALOG>()
for (const item of WCS_NODE_CATALOG) {
const list = by.get(item.category) ?? []
list.push(item)
by.set(item.category, list)
}
return WCS_CATEGORY_ORDER.filter((c) => by.has(c)).map((category) => ({
category,
color: CATEGORY_COLORS[category] ?? '#909399',
items: by.get(category) ?? []
}))
})
function onDragStart(e: DragEvent, type: WcsNodeType) {
if (!e.dataTransfer) return
e.dataTransfer.setData('application/wcs-node', type)
e.dataTransfer.setData('application/wcs-side', dropSide.value)
e.dataTransfer.effectAllowed = 'copyMove'
}
</script>
<style scoped>
.palette {
padding: 8px 10px 16px;
overflow: auto;
height: 100%;
box-sizing: border-box;
}
.hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
line-height: 1.4;
}
.side {
margin-bottom: 12px;
width: 100%;
}
.side :deep(.el-radio-button) {
flex: 1;
}
.side :deep(.el-radio-button__inner) {
width: 100%;
}
.group {
margin-bottom: 12px;
}
.group-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-regular);
margin-bottom: 6px;
}
.gdot,
.idot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.item {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px;
margin-bottom: 6px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: grab;
background: var(--el-fill-color-blank);
}
.item:hover {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.ilab {
font-size: 13px;
font-weight: 600;
}
.idesc {
font-size: 11px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
</style>
@@ -0,0 +1,351 @@
<template>
<div class="designer">
<header class="toolbar">
<div class="left">
<el-select v-model="templateId" size="small" style="width: 220px" @change="loadTemplate">
<el-option v-for="t in world.state.templates" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-input v-model="draft.name" size="small" class="name" placeholder="模板名称" />
<el-tag size="small" :type="draft.published ? 'success' : 'info'">
{{ draft.published ? '已发布' : '草稿' }}
</el-tag>
</div>
<div class="center">
<el-tag v-for="(iss, i) in issues.slice(0, 3)" :key="i" size="small" :type="iss.level === 'error' ? 'danger' : 'warning'">
{{ iss.message }}
</el-tag>
<el-tag v-if="!issues.length" size="small" type="success">校验通过</el-tag>
</div>
<div class="right">
<el-button size="small" @click="reloadFromWorld">从库重载</el-button>
<el-button size="small" type="primary" plain @click="saveDraft">保存到原型库</el-button>
<el-button size="small" type="primary" @click="publish">发布</el-button>
<el-button size="small" @click="openJson = !openJson">{{ openJson ? '隐藏 JSON' : '高级 JSON' }}</el-button>
</div>
</header>
<div class="body">
<aside class="panel left-panel">
<div class="panel-title">模板列表</div>
<el-menu :default-active="templateId" class="tpl-menu" @select="onSelectMenu">
<el-menu-item v-for="t in world.state.templates" :key="t.id" :index="t.id">
<span>{{ t.name }}</span>
<el-tag size="small" class="pri">P{{ t.meta.priority }}</el-tag>
</el-menu-item>
</el-menu>
<div class="panel-title">控件库</div>
<div class="palette-wrap">
<WcsPalette v-model:side="dropSide" />
</div>
</aside>
<main class="panel center-panel">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
fit-view-on-init
:default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
@drop="onDrop"
@dragover="onDragOver"
@node-click="onNodeClick"
@pane-click="selectedId = null"
>
<Background pattern-color="#e8eaed" :gap="18" />
<Controls position="bottom-right" />
</VueFlow>
<div class="lane-hint">左列=源策略 · 右列=宿策略 · 中轴=触发/绑定/预占/动作</div>
</main>
<aside class="panel right-panel">
<div class="panel-title">属性配置</div>
<WcsNodeInspector
:tpl="draft"
:node="selectedNode"
@change="onInspectorChange"
@remove-focus="onRemoveFocus"
/>
</aside>
</div>
<WcsTrialPanel :run-trial="runTrial" />
<el-drawer v-model="openJson" title="高级:DSL JSON" size="40%">
<el-input v-model="jsonText" type="textarea" :rows="28" class="mono" />
<div class="drawer-actions">
<el-button @click="applyJson"> JSON 应用到画布</el-button>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
VueFlow,
type Edge,
type Node,
type NodeMouseEvent,
type NodeTypesObject
} from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import type { TaskTemplateDsl, TrialResult } from '../types'
import type { WcsProtoWorld } from '../runtime/world'
import { validateTemplate } from '../engine/validate'
import {
appendDroppedNode,
dslToFlow,
refreshNodeSummaries,
type WcsFlowNode
} from './flowBridge'
import type { WcsNodeType } from './nodeCatalog'
import WcsFlowNodeView from './WcsFlowNode.vue'
import WcsPalette from './WcsPalette.vue'
import WcsNodeInspector from './WcsNodeInspector.vue'
import WcsTrialPanel from './WcsTrialPanel.vue'
const props = defineProps<{
world: WcsProtoWorld
}>()
const nodeTypes = { wcs: markRaw(WcsFlowNodeView) } as NodeTypesObject
const templateId = ref(props.world.state.templates[0]?.id ?? '')
const draft = ref<TaskTemplateDsl>(
JSON.parse(JSON.stringify(props.world.state.templates[0])) as TaskTemplateDsl
)
/** 用宽松 Node[] 避免 Vue Flow 泛型实例化过深 */
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const selectedId = ref<string | null>(null)
const dropSide = ref<'source' | 'target'>('source')
const openJson = ref(false)
const jsonText = ref('')
const selectedNode = computed((): WcsFlowNode | null => {
const n = nodes.value.find((x) => x.id === selectedId.value)
return n ? (n as unknown as WcsFlowNode) : null
})
const issues = computed(() => validateTemplate(draft.value, 'save'))
function rebuildFlow(focusId?: string) {
const flow = dslToFlow(draft.value)
nodes.value = flow.nodes as unknown as Node[]
edges.value = flow.edges
if (focusId) {
nextTick(() => {
selectedId.value = focusId
})
}
}
function loadTemplate() {
const t = props.world.state.templates.find((x) => x.id === templateId.value)
if (!t) return
draft.value = JSON.parse(JSON.stringify(t)) as TaskTemplateDsl
selectedId.value = null
rebuildFlow()
jsonText.value = JSON.stringify(draft.value, null, 2)
}
function onSelectMenu(id: string) {
templateId.value = id
loadTemplate()
}
function reloadFromWorld() {
loadTemplate()
ElMessage.success('已从原型库重载')
}
function saveDraft() {
draft.value.published = false
const r = props.world.saveTemplate(JSON.parse(JSON.stringify(draft.value)) as TaskTemplateDsl)
if (!r.ok) {
ElMessage.error(r.issues.map((i) => i.message).join(''))
return
}
templateId.value = draft.value.id
ElMessage.success('已保存到原型库')
}
function publish() {
const rSave = props.world.saveTemplate(JSON.parse(JSON.stringify(draft.value)) as TaskTemplateDsl)
if (!rSave.ok) {
ElMessage.error(rSave.issues.map((i) => i.message).join(''))
return
}
const r = props.world.publishTemplate(draft.value.id)
if (!r.ok) {
ElMessage.error(r.issues.filter((i) => i.level === 'error').map((i) => i.message).join(''))
return
}
draft.value.published = true
ElMessage.success('发布成功')
}
function onDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
function onDrop(e: DragEvent) {
e.preventDefault()
const type = e.dataTransfer?.getData('application/wcs-node') as WcsNodeType | undefined
if (!type) return
const side = (e.dataTransfer?.getData('application/wcs-side') as 'source' | 'target') || dropSide.value
const { tpl, focusNodeId } = appendDroppedNode(draft.value, type, side)
draft.value = tpl
rebuildFlow(focusNodeId)
ElMessage.success(`已添加:${type}`)
}
function onNodeClick(ev: NodeMouseEvent) {
selectedId.value = ev.node.id
}
function onInspectorChange() {
const refreshed = refreshNodeSummaries(draft.value, nodes.value as unknown as WcsFlowNode[])
nodes.value = refreshed as unknown as Node[]
jsonText.value = JSON.stringify(draft.value, null, 2)
}
function onRemoveFocus() {
selectedId.value = null
rebuildFlow()
}
function runTrial(payload: Record<string, unknown>): TrialResult {
// 稿
const backup = props.world.state.templates.find((t) => t.id === draft.value.id)
const idx = props.world.state.templates.findIndex((t) => t.id === draft.value.id)
const snap = JSON.parse(JSON.stringify(draft.value)) as TaskTemplateDsl
if (idx >= 0) props.world.state.templates[idx] = snap
else props.world.state.templates.push(snap)
try {
return props.world.trial(draft.value.id, payload)
} finally {
if (backup && idx >= 0) props.world.state.templates[idx] = backup
}
}
function applyJson() {
try {
const parsed = JSON.parse(jsonText.value) as TaskTemplateDsl
draft.value = parsed
templateId.value = draft.value.id
rebuildFlow()
ElMessage.success('已应用到画布')
} catch (e) {
ElMessage.error(`JSON 无效: ${e}`)
}
}
watch(openJson, (v) => {
if (v) jsonText.value = JSON.stringify(draft.value, null, 2)
})
loadTemplate()
defineExpose({ loadTemplate, templateId })
</script>
<style scoped>
.designer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--el-bg-color-page);
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color);
background: var(--el-bg-color);
flex-wrap: wrap;
}
.left,
.right,
.center {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.center {
flex: 1;
justify-content: center;
}
.name {
width: 200px;
}
.body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 240px 1fr 300px;
}
.panel {
min-height: 0;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
}
.left-panel {
border-right: 1px solid var(--el-border-color);
}
.right-panel {
border-left: 1px solid var(--el-border-color);
}
.center-panel {
position: relative;
background: #f7f8fa;
}
.panel-title {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--el-border-color-lighter);
color: var(--el-text-color-regular);
}
.tpl-menu {
border-right: none;
max-height: 160px;
overflow: auto;
}
.tpl-menu .pri {
margin-left: 6px;
}
.palette-wrap {
flex: 1;
min-height: 0;
}
.lane-hint {
position: absolute;
left: 12px;
top: 8px;
z-index: 2;
font-size: 11px;
color: var(--el-text-color-secondary);
background: rgba(255, 255, 255, 0.85);
padding: 2px 8px;
border-radius: 4px;
pointer-events: none;
}
.mono :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.drawer-actions {
margin-top: 12px;
}
</style>
@@ -0,0 +1,109 @@
<template>
<div class="trial">
<div class="trial-head">
<strong>试算台</strong>
<span class="sub">注入样例参数查看候选库位与淘汰原因发布前建议必过</span>
<div class="spacer" />
<el-input v-model="materialCode" size="small" style="width: 100px" placeholder="物料" />
<el-input v-model="lineId" size="small" style="width: 80px" placeholder="产线" />
<el-button size="small" type="primary" @click="run">试算</el-button>
</div>
<div v-if="result" class="trial-body">
<el-alert
:type="result.ok ? 'success' : 'warning'"
:closable="false"
show-icon
:title="result.ok ? `选中 ${result.pick?.sourceId} → ${result.pick?.targetId}` : result.error || '失败'"
/>
<el-row :gutter="10" class="cols">
<el-col :span="8">
<div class="cap">源候选</div>
<el-table :data="result.sourceCandidates" size="small" max-height="140">
<el-table-column prop="storageId" label="库位" />
<el-table-column prop="total" label="分" width="60" />
</el-table>
</el-col>
<el-col :span="8">
<div class="cap">宿候选</div>
<el-table :data="result.targetCandidates" size="small" max-height="140">
<el-table-column prop="storageId" label="库位" />
<el-table-column prop="total" label="分" width="60" />
</el-table>
</el-col>
<el-col :span="8">
<div class="cap">降级</div>
<pre>{{ JSON.stringify(result.degrade, null, 2) }}</pre>
</el-col>
</el-row>
</div>
<div v-else class="empty">配置完流水线后点试算验证选位逻辑</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { TrialResult } from '../types'
const props = defineProps<{
runTrial: (payload: Record<string, unknown>) => TrialResult
}>()
const materialCode = ref('M001')
const lineId = ref('L1')
const result = ref<TrialResult | null>(null)
function run() {
result.value = props.runTrial({ materialCode: materialCode.value, qty: 1, lineId: lineId.value })
}
defineExpose({ run })
</script>
<style scoped>
.trial {
border-top: 1px solid var(--el-border-color);
background: var(--el-bg-color);
padding: 8px 12px;
min-height: 48px;
max-height: 260px;
overflow: auto;
}
.trial-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.spacer {
flex: 1;
}
.trial-body {
margin-top: 8px;
}
.cols {
margin-top: 8px;
}
.cap {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
}
pre {
margin: 0;
font-size: 11px;
max-height: 140px;
overflow: auto;
background: var(--el-fill-color-light);
padding: 6px;
border-radius: 4px;
}
.empty {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,331 @@
import type { Edge, Node } from '@vue-flow/core'
import type {
FilterGroup,
ParamBinding,
ScoreRule,
TaskTemplateDsl
} from '../types'
import { WCS_NODE_MAP, type WcsNodeType } from './nodeCatalog'
export type WcsFlowNodeData = {
type: WcsNodeType
label: string
summary: string
/** 绑定在 DSL 中的定位 */
ref?: {
kind: 'meta' | 'trigger' | 'binding' | 'filter' | 'score' | 'allocate' | 'action' | 'policy' | 'end'
side?: 'source' | 'target'
id?: string
index?: number
}
}
export type WcsFlowNode = Node<WcsFlowNodeData>
const COL = { main: 280, source: 80, target: 480 }
const ROW_H = 90
function summaryTrigger(tpl: TaskTemplateDsl): string {
return `${tpl.trigger.type} · ${tpl.trigger.source}`
}
function summaryBinding(b: ParamBinding): string {
return `${b.as}${b.from}`
}
function summaryFilter(g: FilterGroup): string {
const n = g.expr.type === 'group' ? g.expr.children.length : 1
return `${g.severity === 'hard' ? '硬性' : '弹性'} · ${n} 条条件`
}
function summaryScore(s: ScoreRule): string {
return `${s.function} · w=${s.weight}`
}
/** 将 DSL 展开为纵向流水线节点(源/宿分两列展示策略节点) */
export function dslToFlow(tpl: TaskTemplateDsl): { nodes: WcsFlowNode[]; edges: Edge[] } {
const nodes: WcsFlowNode[] = []
const edges: Edge[] = []
let y = 40
const add = (
id: string,
type: WcsNodeType,
label: string,
summary: string,
position: { x: number; y: number },
ref?: WcsFlowNodeData['ref']
) => {
nodes.push({
id,
type: 'wcs',
position,
data: { type, label, summary, ref },
draggable: true
})
}
add('n-trigger', 'trigger', '触发', summaryTrigger(tpl), { x: COL.main, y }, { kind: 'trigger' })
y += ROW_H
const bindIds: string[] = []
tpl.bindings.forEach((b, i) => {
const id = `n-bind-${i}`
bindIds.push(id)
add(id, 'binding', '参数绑定', summaryBinding(b), { x: COL.main, y }, { kind: 'binding', index: i, id: b.as })
y += ROW_H * 0.75
})
if (!tpl.bindings.length) {
add('n-bind-empty', 'binding', '参数绑定', '点击右侧添加绑定', { x: COL.main, y }, { kind: 'binding', index: -1 })
bindIds.push('n-bind-empty')
y += ROW_H
}
const srcStartY = y
let sy = srcStartY
let ty = srcStartY
const srcFilterIds: string[] = []
const tgtFilterIds: string[] = []
tpl.locationStrategies.source.filterGroups.forEach((g) => {
const id = `n-filter-source-${g.id}`
srcFilterIds.push(id)
add(id, 'filter', `源过滤 · ${g.id}`, summaryFilter(g), { x: COL.source, y: sy }, {
kind: 'filter',
side: 'source',
id: g.id
})
sy += ROW_H
})
tpl.locationStrategies.target.filterGroups.forEach((g) => {
const id = `n-filter-target-${g.id}`
tgtFilterIds.push(id)
add(id, 'filter', `宿过滤 · ${g.id}`, summaryFilter(g), { x: COL.target, y: ty }, {
kind: 'filter',
side: 'target',
id: g.id
})
ty += ROW_H
})
const srcScoreIds: string[] = []
const tgtScoreIds: string[] = []
tpl.locationStrategies.source.scores.forEach((s) => {
const id = `n-score-source-${s.id}`
srcScoreIds.push(id)
add(id, 'score', `源打分 · ${s.id}`, summaryScore(s), { x: COL.source, y: sy }, {
kind: 'score',
side: 'source',
id: s.id
})
sy += ROW_H
})
tpl.locationStrategies.target.scores.forEach((s) => {
const id = `n-score-target-${s.id}`
tgtScoreIds.push(id)
add(id, 'score', `宿打分 · ${s.id}`, summaryScore(s), { x: COL.target, y: ty }, {
kind: 'score',
side: 'target',
id: s.id
})
ty += ROW_H
})
y = Math.max(sy, ty) + 20
add(
'n-allocate',
'allocate',
'成对预占',
`topN=${tpl.locationStrategies.source.allocate.topN ?? 5} · 失败全体回滚`,
{ x: COL.main, y },
{ kind: 'allocate' }
)
y += ROW_H
add(
'n-action',
'action',
'任务动作',
`${tpl.blueprint.taskType} · 自动派车=${tpl.blueprint.options.autoDispatch ? '是' : '否'}`,
{ x: COL.main, y },
{ kind: 'action' }
)
y += ROW_H
add(
'n-policy',
'policy',
'运行策略',
`预占${tpl.policy.reservationTtlSec}s · 重试${tpl.policy.allocateMaxAttempts}`,
{ x: COL.main, y },
{ kind: 'policy' }
)
y += ROW_H
add('n-end', 'end', '结束', '流水线完成', { x: COL.main, y }, { kind: 'end' })
const link = (a: string, b: string) => {
edges.push({ id: `e-${a}-${b}`, source: a, target: b, type: 'smoothstep' })
}
// 主链:trigger → binds → allocate → action → policy → end
let prev = 'n-trigger'
for (const id of bindIds) {
link(prev, id)
prev = id
}
const lastBind = prev
// 从最后绑定分叉到源/宿过滤
const firstSrc = srcFilterIds[0] ?? srcScoreIds[0]
const firstTgt = tgtFilterIds[0] ?? tgtScoreIds[0]
if (firstSrc) link(lastBind, firstSrc)
if (firstTgt) link(lastBind, firstTgt)
const chain = (ids: string[]) => {
for (let i = 0; i < ids.length - 1; i++) link(ids[i], ids[i + 1])
}
chain(srcFilterIds)
chain(tgtFilterIds)
if (srcFilterIds.length && srcScoreIds.length) link(srcFilterIds[srcFilterIds.length - 1], srcScoreIds[0])
if (tgtFilterIds.length && tgtScoreIds.length) link(tgtFilterIds[tgtFilterIds.length - 1], tgtScoreIds[0])
chain(srcScoreIds)
chain(tgtScoreIds)
const lastSrc = srcScoreIds[srcScoreIds.length - 1] ?? srcFilterIds[srcFilterIds.length - 1]
const lastTgt = tgtScoreIds[tgtScoreIds.length - 1] ?? tgtFilterIds[tgtFilterIds.length - 1]
if (lastSrc) link(lastSrc, 'n-allocate')
if (lastTgt) link(lastTgt, 'n-allocate')
if (!lastSrc && !lastTgt) link(lastBind, 'n-allocate')
link('n-allocate', 'n-action')
link('n-action', 'n-policy')
link('n-policy', 'n-end')
return { nodes, edges }
}
export function defaultNodeLabel(type: WcsNodeType): string {
return WCS_NODE_MAP[type]?.label ?? type
}
/** 拖入新节点时,往 DSL 追加默认片段并返回新节点 id */
export function appendDroppedNode(
tpl: TaskTemplateDsl,
type: WcsNodeType,
side: 'source' | 'target' = 'source'
): { tpl: TaskTemplateDsl; focusNodeId: string } {
const copy = JSON.parse(JSON.stringify(tpl)) as TaskTemplateDsl
const uid = () => `g${Date.now().toString(36).slice(-5)}`
switch (type) {
case 'binding': {
const as = `field_${copy.bindings.length + 1}`
copy.bindings.push({
as,
from: 'mes.call.materialCode',
required: false,
resolve: 'eventPayload'
})
return { tpl: copy, focusNodeId: `n-bind-${copy.bindings.length - 1}` }
}
case 'filter': {
const id = uid()
const st = copy.locationStrategies[side]
st.filterGroups.push({
id,
severity: 'soft',
expr: {
type: 'group',
op: 'and',
children: [
{
type: 'compare',
left: { ref: 'wcs.disabled' },
op: 'eq',
right: { const: false }
}
]
}
})
// 确保降级链包含新 soft 组的首档
if (!st.degradeChain.length) {
st.degradeChain = [{ attempt: 0, requireGroups: st.filterGroups.map((g) => g.id) }]
} else {
st.degradeChain[0].requireGroups = Array.from(
new Set([...st.degradeChain[0].requireGroups, id])
)
}
return { tpl: copy, focusNodeId: `n-filter-${side}-${id}` }
}
case 'score': {
const id = uid()
copy.locationStrategies[side].scores.push({
id,
function: 'constant',
weight: 1,
params: { value: 50 }
})
return { tpl: copy, focusNodeId: `n-score-${side}-${id}` }
}
case 'trigger':
case 'allocate':
case 'action':
case 'policy':
case 'end':
default:
// 单例节点:不重复添加,仅聚焦
return {
tpl: copy,
focusNodeId:
type === 'trigger'
? 'n-trigger'
: type === 'allocate'
? 'n-allocate'
: type === 'action'
? 'n-action'
: type === 'policy'
? 'n-policy'
: 'n-end'
}
}
}
export function refreshNodeSummaries(tpl: TaskTemplateDsl, nodes: WcsFlowNode[]): WcsFlowNode[] {
return nodes.map((n) => {
const data = n.data
if (!data?.ref) return n
const ref = data.ref
let summary = data.summary
let label = data.label
if (ref.kind === 'trigger') summary = summaryTrigger(tpl)
if (ref.kind === 'binding' && ref.index != null && ref.index >= 0 && tpl.bindings[ref.index]) {
summary = summaryBinding(tpl.bindings[ref.index])
}
if (ref.kind === 'filter' && ref.side && ref.id) {
const g = tpl.locationStrategies[ref.side].filterGroups.find((x) => x.id === ref.id)
if (g) {
label = `${ref.side === 'source' ? '源' : '宿'}过滤 · ${g.id}`
summary = summaryFilter(g)
}
}
if (ref.kind === 'score' && ref.side && ref.id) {
const s = tpl.locationStrategies[ref.side].scores.find((x) => x.id === ref.id)
if (s) {
label = `${ref.side === 'source' ? '源' : '宿'}打分 · ${s.id}`
summary = summaryScore(s)
}
}
if (ref.kind === 'allocate') {
summary = `topN=${tpl.locationStrategies.source.allocate.topN ?? 5} · 失败全体回滚`
}
if (ref.kind === 'action') {
summary = `${tpl.blueprint.taskType} · 自动派车=${tpl.blueprint.options.autoDispatch ? '是' : '否'}`
}
if (ref.kind === 'policy') {
summary = `预占${tpl.policy.reservationTtlSec}s · 重试${tpl.policy.allocateMaxAttempts}`
}
const next: WcsFlowNode = {
...n,
data: { ...data, label, summary }
}
return next
})
}
@@ -0,0 +1,89 @@
/** 无代码设计器左侧积木目录(对齐设计文档 §4) */
export type WcsNodeType =
| 'trigger'
| 'binding'
| 'filter'
| 'score'
| 'allocate'
| 'action'
| 'policy'
| 'end'
export interface WcsPaletteItem {
type: WcsNodeType
label: string
category: string
description: string
color: string
/** 拖入后默认侧(过滤/打分) */
defaultSide?: 'source' | 'target'
}
export const WCS_CATEGORY_ORDER = ['触发与参数', '库位策略', '任务与策略', '流程'] as const
export const WCS_NODE_CATALOG: WcsPaletteItem[] = [
{
type: 'trigger',
label: '触发',
category: '触发与参数',
description: '事件 / 人工触发',
color: '#67c23a'
},
{
type: 'binding',
label: '参数绑定',
category: '触发与参数',
description: '从 MES/APS/WMS 引入字段',
color: '#409eff'
},
{
type: 'filter',
label: '过滤条件',
category: '库位策略',
description: '硬性/弹性筛选库位',
color: '#e6a23c',
defaultSide: 'source'
},
{
type: 'score',
label: '打分偏好',
category: '库位策略',
description: 'FIFO / 距离等排序',
color: '#f56c6c',
defaultSide: 'source'
},
{
type: 'allocate',
label: '成对预占',
category: '库位策略',
description: '源+宿同时占位',
color: '#9b59b6'
},
{
type: 'action',
label: '任务动作',
category: '任务与策略',
description: '生成搬运并派车',
color: '#13c2c2'
},
{
type: 'policy',
label: '运行策略',
category: '任务与策略',
description: '超时、重试、无候选',
color: '#909399'
},
{
type: 'end',
label: '结束',
category: '流程',
description: '流水线终点',
color: '#606266'
}
]
export const WCS_NODE_MAP = Object.fromEntries(WCS_NODE_CATALOG.map((n) => [n.type, n])) as Record<
WcsNodeType,
WcsPaletteItem
>
@@ -0,0 +1,118 @@
import type { ParamBinding, ParamFieldDef } from '../types'
import { LOOKUP_TABLE, PARAM_FIELDS } from '../mock/seed'
export interface AssembleResult {
ok: boolean
context: Record<string, unknown>
suspend?: boolean
error?: string
explain: Array<{ as: string; from: string; value?: unknown; error?: string }>
}
const cache = new Map<string, { value: unknown; expireAt: number }>()
export function clearLookupCache() {
cache.clear()
}
function payloadKey(from: string): string {
// mes.call.materialCode → materialCode (last segment) also try full after module
const parts = from.split('.')
if (parts.length >= 2) return parts[parts.length - 1]
return from
}
function buildLookupKey(template: string, args: Record<string, string>, context: Record<string, unknown>): string {
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
const path = args[name]
if (!path) return ''
const ctxPath = path.startsWith('context.') ? path.slice(8) : path
return String(context[ctxPath] ?? '')
})
}
export function assembleContext(
bindings: ParamBinding[],
eventPayload: Record<string, unknown>,
options?: { lookupBroken?: boolean; fields?: ParamFieldDef[] }
): AssembleResult {
const fields = options?.fields ?? PARAM_FIELDS
const context: Record<string, unknown> = {}
const explain: AssembleResult['explain'] = []
const byFrom = new Map(fields.map((f) => [`${f.module}.${f.path}`, f]))
const ordered = [...bindings].sort((a, b) => {
const ra = a.resolve ?? byFrom.get(a.from)?.resolveMode ?? 'eventPayload'
const rb = b.resolve ?? byFrom.get(b.from)?.resolveMode ?? 'eventPayload'
const rank = (r: string) => (r === 'eventPayload' ? 0 : r === 'session' ? 1 : 2)
return rank(ra) - rank(rb)
})
for (const b of ordered) {
const def = byFrom.get(b.from)
const mode = b.resolve ?? def?.resolveMode ?? 'eventPayload'
try {
if (mode === 'eventPayload' || mode === 'session') {
const key = payloadKey(b.from)
let value = eventPayload[key]
if (value === undefined && b.from.includes('.')) {
// also allow nested payload.mes.call.materialCode style
value = eventPayload[b.from]
}
if (value === undefined) value = b.default
if (value === undefined && b.required) {
explain.push({ as: b.as, from: b.from, error: 'required_missing' })
return { ok: false, context, error: `缺少必填参数 ${b.as}`, explain }
}
context[b.as] = value
explain.push({ as: b.as, from: b.from, value })
continue
}
// lookup
if (options?.lookupBroken) {
const onError = b.onError ?? 'fail'
if (onError === 'cached') {
const ck = b.key ? buildLookupKey(b.key.template, b.key.args, context) : b.from
const hit = cache.get(ck)
if (hit && hit.expireAt > Date.now()) {
context[b.as] = hit.value
explain.push({ as: b.as, from: b.from, value: hit.value, error: 'used_cache_after_error' })
continue
}
}
if (onError === 'default') {
context[b.as] = b.default
explain.push({ as: b.as, from: b.from, value: b.default, error: 'lookup_broken_default' })
continue
}
if (onError === 'suspend') {
explain.push({ as: b.as, from: b.from, error: 'lookup_suspend' })
return { ok: false, context, suspend: true, error: 'lookup 暂不可用', explain }
}
explain.push({ as: b.as, from: b.from, error: 'lookup_fail' })
return { ok: false, context, error: `lookup 失败: ${b.as}`, explain }
}
if (!b.key) {
explain.push({ as: b.as, from: b.from, error: 'lookup_key_missing' })
return { ok: false, context, error: `lookup 缺少 key: ${b.as}`, explain }
}
const lk = buildLookupKey(b.key.template, b.key.args, context)
const value = LOOKUP_TABLE[lk] ?? b.default
if (value === undefined && b.required) {
explain.push({ as: b.as, from: b.from, error: 'lookup_miss' })
return { ok: false, context, error: `lookup 无结果: ${b.as}`, explain }
}
context[b.as] = value
const ttl = (b.cacheTtlSec ?? 60) * 1000
cache.set(lk, { value, expireAt: Date.now() + ttl })
explain.push({ as: b.as, from: b.from, value })
} catch (e) {
explain.push({ as: b.as, from: b.from, error: String(e) })
return { ok: false, context, error: String(e), explain }
}
}
return { ok: true, context, explain }
}
@@ -0,0 +1,136 @@
import type { CompareOp, ExprNode, StorageLoc, ValueRef } from '../types'
function readPath(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split('.')
let cur: unknown = obj
for (const p of parts) {
if (cur == null || typeof cur !== 'object') return undefined
cur = (cur as Record<string, unknown>)[p]
}
return cur
}
export function locToWcs(loc: StorageLoc): Record<string, unknown> {
return {
storageId: loc.storageId,
areaId: loc.areaId,
areaType: loc.areaType,
storageType: loc.storageType,
status: loc.status,
materialAffinity: loc.materialAffinity,
disabled: loc.disabled,
lineId: loc.lineId,
allowInbound: loc.allowInbound,
allowOutbound: loc.allowOutbound,
containerType: loc.containerType,
batchNo: loc.batchNo,
qty: loc.qty,
inboundAt: loc.inboundAt,
x: loc.x,
y: loc.y,
reserved: !!loc.forceReserved
}
}
function resolveValue(
side: ValueRef | undefined,
wcs: Record<string, unknown>,
context: Record<string, unknown>
): unknown {
if (!side) return undefined
if ('const' in side) return side.const
const path = side.ref
if (path.startsWith('wcs.')) return readPath({ wcs }, path) ?? readPath(wcs, path.slice(4))
if (path.startsWith('context.')) return readPath({ context }, path) ?? readPath(context, path.slice(8))
return undefined
}
function isEmpty(v: unknown): boolean {
return v === undefined || v === null || v === ''
}
export interface CompareFail {
reason: string
op: CompareOp
}
export function evalCompare(
node: Extract<ExprNode, { type: 'compare' }>,
wcs: Record<string, unknown>,
context: Record<string, unknown>
): { ok: boolean; fail?: CompareFail } {
const left = resolveValue(node.left, wcs, context)
const right = resolveValue(node.right, wcs, context)
const op = node.op
if (op === 'exists') return { ok: !isEmpty(left) }
if (op === 'notExists') return { ok: isEmpty(left) }
if (isEmpty(left) || (node.right && isEmpty(right) && op !== 'eq')) {
// eq with explicit null const still allowed; otherwise null → false
if (!(op === 'eq' && node.right && 'const' in node.right && node.right.const === null)) {
return { ok: false, fail: { reason: 'null_operand', op } }
}
}
switch (op) {
case 'eq': return { ok: left === right, fail: left === right ? undefined : { reason: `${fmt(left)}${fmt(right)}`, op } }
case 'ne': return { ok: left !== right }
case 'in': {
const arr = Array.isArray(right) ? right : []
const ok = arr.includes(left as never)
return { ok, fail: ok ? undefined : { reason: `${fmt(left)} not in ${fmt(arr)}`, op } }
}
case 'notIn': {
const arr = Array.isArray(right) ? right : []
return { ok: !arr.includes(left as never) }
}
case 'contains': {
const arr = Array.isArray(left) ? left : []
const ok = arr.includes(right as never)
return { ok, fail: ok ? undefined : { reason: `${fmt(arr)} 不含 ${fmt(right)}`, op } }
}
case 'notContains': {
const arr = Array.isArray(left) ? left : []
return { ok: !arr.includes(right as never) }
}
case 'gt': return { ok: Number(left) > Number(right) }
case 'gte': return { ok: Number(left) >= Number(right) }
case 'lt': return { ok: Number(left) < Number(right) }
case 'lte': return { ok: Number(left) <= Number(right) }
case 'between': {
const arr = Array.isArray(right) ? right : []
const n = Number(left)
return { ok: n >= Number(arr[0]) && n <= Number(arr[1]) }
}
case 'matchesRef': return { ok: left === right }
default: return { ok: false, fail: { reason: `unknown_op:${op}`, op } }
}
}
function fmt(v: unknown): string {
try { return JSON.stringify(v) } catch { return String(v) }
}
export function evalExpr(
node: ExprNode,
wcs: Record<string, unknown>,
context: Record<string, unknown>
): { ok: boolean; failReason?: string } {
if (node.type === 'compare') {
const r = evalCompare(node, wcs, context)
return { ok: r.ok, failReason: r.fail?.reason }
}
if (node.op === 'and') {
for (const c of node.children) {
const r = evalExpr(c, wcs, context)
if (!r.ok) return r
}
return { ok: true }
}
for (const c of node.children) {
const r = evalExpr(c, wcs, context)
if (r.ok) return { ok: true }
}
return { ok: false, failReason: 'or_group_all_failed' }
}
@@ -0,0 +1,135 @@
import type { LocationStrategy, ScoreRule, StorageLoc } from '../types'
import { evalExpr, locToWcs } from './expr'
export interface ScoredCandidate {
loc: StorageLoc
total: number
scores: Record<string, number>
}
export interface StrategyRunResult {
candidates: ScoredCandidate[]
eliminated: Array<{ storageId: string; groupId?: string; reason: string; attempt: number }>
degrade: Array<{ attempt: number; requireGroups: string[]; candidateCount: number }>
pick?: StorageLoc
}
function scoreOne(loc: StorageLoc, rule: ScoreRule, all: StorageLoc[]): number {
const p = rule.params
switch (rule.function) {
case 'constant':
return Number(p.value ?? 0)
case 'field_match_bonus': {
// not fully wired in proto scores of seed; keep simple
return 0
}
case 'fifo_age': {
const field = String(p.timeField ?? 'inboundAt')
const t = (loc as unknown as Record<string, unknown>)[field]
if (!t || typeof t !== 'string') return 0
const times = all
.map((x) => (x as unknown as Record<string, unknown>)[field])
.filter((x): x is string => typeof x === 'string')
.map((x) => new Date(x).getTime())
if (!times.length) return 0
const oldest = Math.min(...times)
const newest = Math.max(...times)
const cur = new Date(t).getTime()
if (newest === oldest) return 100
return ((newest - cur) / (newest - oldest)) * 100
}
case 'nearer_to_ref': {
const refX = Number(p.refX ?? 0)
const refY = Number(p.refY ?? 0)
const dist = Math.hypot(loc.x - refX, loc.y - refY)
const dists = all.map((x) => Math.hypot(x.x - refX, x.y - refY))
const min = Math.min(...dists)
const max = Math.max(...dists)
if (max === min) return 100
return (1 - (dist - min) / (max - min)) * 100
}
default:
return 0
}
}
function passGroups(
loc: StorageLoc,
strategy: LocationStrategy,
groupIds: string[],
context: Record<string, unknown>
): { ok: boolean; groupId?: string; reason?: string } {
const wcs = locToWcs(loc)
for (const gid of groupIds) {
const g = strategy.filterGroups.find((x) => x.id === gid)
if (!g) continue
const r = evalExpr(g.expr, wcs, context)
if (!r.ok) return { ok: false, groupId: gid, reason: r.failReason ?? 'filter_fail' }
}
return { ok: true }
}
export function runLocationStrategy(
strategy: LocationStrategy,
universe: StorageLoc[],
context: Record<string, unknown>,
reservedIds: Set<string>
): StrategyRunResult {
const eliminated: StrategyRunResult['eliminated'] = []
const degrade: StrategyRunResult['degrade'] = []
const chain = strategy.degradeChain.length
? [...strategy.degradeChain].sort((a, b) => a.attempt - b.attempt)
: [{ attempt: 0, requireGroups: strategy.filterGroups.map((g) => g.id) }]
let lastCandidates: ScoredCandidate[] = []
for (const step of chain) {
// hard safety: never drop hard groups even if misconfigured
const hardIds = strategy.filterGroups.filter((g) => g.severity === 'hard').map((g) => g.id)
const require = Array.from(new Set([...hardIds, ...step.requireGroups]))
const passed: StorageLoc[] = []
for (const loc of universe) {
if (reservedIds.has(loc.storageId) || loc.forceReserved) {
eliminated.push({ storageId: loc.storageId, reason: 'already_reserved', attempt: step.attempt })
continue
}
const r = passGroups(loc, strategy, require, context)
if (!r.ok) {
eliminated.push({
storageId: loc.storageId,
groupId: r.groupId,
reason: r.reason ?? 'fail',
attempt: step.attempt
})
continue
}
passed.push(loc)
}
const scored = passed.map((loc) => {
const scores: Record<string, number> = {}
let total = 0
for (const rule of strategy.scores) {
const s = scoreOne(loc, rule, passed)
scores[rule.id] = Math.round(s * 100) / 100
total += s * rule.weight
}
return { loc, total, scores }
})
scored.sort((a, b) => b.total - a.total || a.loc.storageId.localeCompare(b.loc.storageId))
const topN = strategy.allocate.topN ?? 5
lastCandidates = scored.slice(0, topN)
degrade.push({ attempt: step.attempt, requireGroups: require, candidateCount: lastCandidates.length })
if (lastCandidates.length) {
return {
candidates: lastCandidates,
eliminated,
degrade,
pick: lastCandidates[0].loc
}
}
}
return { candidates: lastCandidates, eliminated, degrade }
}
@@ -0,0 +1,282 @@
import type {
ExplainSection,
InstanceStatus,
TaskInstance,
TaskTemplateDsl,
TrialResult,
TriggerEvent
} from '../types'
import { assembleContext } from './context'
import { runLocationStrategy } from './match'
import type { ReservationStore } from './reservation'
import type { StorageLoc } from '../types'
function now() {
return new Date().toISOString()
}
function pushTimeline(inst: TaskInstance, status: InstanceStatus, note?: string) {
inst.status = status
inst.updatedAt = now()
inst.timeline.push({ at: inst.updatedAt, status, note })
}
export function trialRun(
tpl: TaskTemplateDsl,
payload: Record<string, unknown>,
storages: StorageLoc[],
reserved: Set<string>,
lookupBroken?: boolean
): TrialResult {
const assembled = assembleContext(tpl.bindings, payload, { lookupBroken })
const explain: ExplainSection = { bindings: assembled.explain }
if (!assembled.ok) {
return {
ok: false,
context: assembled.context,
sourceCandidates: [],
targetCandidates: [],
eliminated: [],
degrade: [],
error: assembled.error,
explain
}
}
const src = runLocationStrategy(tpl.locationStrategies.source, storages, assembled.context, reserved)
const tgt = runLocationStrategy(tpl.locationStrategies.target, storages, assembled.context, reserved)
explain.filter = { source: src.eliminated.slice(0, 40), target: tgt.eliminated.slice(0, 40) }
explain.degrade = { source: src.degrade, target: tgt.degrade }
explain.score = {
source: src.candidates.map((c) => ({ id: c.loc.storageId, total: c.total, scores: c.scores })),
target: tgt.candidates.map((c) => ({ id: c.loc.storageId, total: c.total, scores: c.scores }))
}
const eliminated = [
...src.eliminated.map((e) => ({ ...e, strategy: 'source' })),
...tgt.eliminated.map((e) => ({ ...e, strategy: 'target' }))
]
if (!src.pick || !tgt.pick) {
explain.allocate = { result: 'no_candidate' }
return {
ok: false,
context: assembled.context,
sourceCandidates: src.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })),
targetCandidates: tgt.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })),
eliminated,
degrade: [src.degrade, tgt.degrade],
error: '无可用库位候选',
explain
}
}
explain.allocate = { result: 'ok', sourceId: src.pick.storageId, targetId: tgt.pick.storageId }
return {
ok: true,
context: assembled.context,
sourceCandidates: src.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })),
targetCandidates: tgt.candidates.map((c) => ({ storageId: c.loc.storageId, total: c.total, scores: c.scores })),
eliminated,
degrade: [src.degrade, tgt.degrade],
pick: { sourceId: src.pick.storageId, targetId: tgt.pick.storageId },
explain
}
}
export interface RuntimeDeps {
templates: TaskTemplateDsl[]
storages: StorageLoc[]
reservations: ReservationStore
instances: TaskInstance[]
lookupBroken: boolean
audits: Array<{ at: string; type: string; detail: unknown }>
}
let seq = 1
export function handleTrigger(deps: RuntimeDeps, event: TriggerEvent): TaskInstance | null {
const published = deps.templates.filter((t) => t.published && t.trigger.source === event.source)
const sorted = [...published].sort((a, b) => b.meta.priority - a.meta.priority || a.id.localeCompare(b.id))
// mutex / single winner
const winners: TaskTemplateDsl[] = []
const seenGroup = new Set<string>()
for (const t of sorted) {
const mode = t.meta.routeMode ?? 'single_winner'
const g = t.meta.mutexGroup ?? `__solo__${t.id}`
if (mode === 'single_winner') {
if (seenGroup.has(g)) {
deps.audits.push({
at: now(),
type: 'template_not_selected',
detail: { templateId: t.id, reason: 'mutex_lost', eventId: event.eventId }
})
continue
}
seenGroup.add(g)
winners.push(t)
// 同组只取一个;无组时每个模板自己的 solo 组
continue
}
winners.push(t)
}
// 全局 single_winner:设计默认同一事件只跑优先级最高的一套(跨组也只取第一个 winner)
const chosen = winners[0]
for (const t of winners.slice(1)) {
deps.audits.push({
at: now(),
type: 'template_not_selected',
detail: { templateId: t.id, reason: 'single_winner', eventId: event.eventId, winner: chosen?.id }
})
}
if (!chosen) {
deps.audits.push({ at: now(), type: 'no_template', detail: { eventId: event.eventId } })
return null
}
// idempotency
const keyMode = chosen.trigger.idempotency?.keyMode ?? 'eventId'
const idemKey = keyMode === 'eventId'
? event.eventId
: `${String(event.payload.businessKey ?? event.eventId)}:${chosen.id}`
const existing = deps.instances.find((i) => i.eventId === idemKey || (i.eventId === event.eventId && i.templateId === chosen.id))
if (existing && !['Completed', 'Failed', 'Cancelled', 'IgnoredDuplicate'].includes(existing.status)) {
const dup: TaskInstance = {
id: `inst-${seq++}`,
templateId: chosen.id,
templateName: chosen.name,
eventId: event.eventId,
status: 'IgnoredDuplicate',
context: {},
explain: { arbitration: { winner: chosen.id, duplicateOf: existing.id } },
createdAt: now(),
updatedAt: now(),
timeline: [{ at: now(), status: 'IgnoredDuplicate', note: `重复事件,沿用 ${existing.id}` }]
}
deps.instances.unshift(dup)
return dup
}
if (existing && ['Completed', 'Failed', 'Cancelled'].includes(existing.status)) {
const hit = chosen.trigger.idempotency?.onTerminalHit ?? 'reject'
if (hit === 'reject') {
const dup: TaskInstance = {
id: `inst-${seq++}`,
templateId: chosen.id,
templateName: chosen.name,
eventId: event.eventId,
status: 'IgnoredDuplicate',
context: {},
explain: { arbitration: { winner: chosen.id, rejectedTerminal: existing.id } },
createdAt: now(),
updatedAt: now(),
timeline: [{ at: now(), status: 'IgnoredDuplicate', note: '终态后拒绝重复 eventId' }]
}
deps.instances.unshift(dup)
return dup
}
}
const inst: TaskInstance = {
id: `inst-${seq++}`,
templateId: chosen.id,
templateName: chosen.name,
eventId: event.eventId,
status: 'Pending',
context: {},
explain: { arbitration: { winner: chosen.id, priority: chosen.meta.priority, candidates: published.map((p) => p.id) } },
createdAt: now(),
updatedAt: now(),
timeline: []
}
pushTimeline(inst, 'Pending', '事件入站')
deps.instances.unshift(inst)
// Assembling
pushTimeline(inst, 'Assembling')
const assembled = assembleContext(chosen.bindings, event.payload, { lookupBroken: deps.lookupBroken })
inst.explain.bindings = assembled.explain
if (assembled.suspend) {
pushTimeline(inst, 'Suspended', assembled.error)
return inst
}
if (!assembled.ok) {
inst.error = assembled.error
pushTimeline(inst, 'Failed', assembled.error)
return inst
}
inst.context = assembled.context
// Allocating with retries
pushTimeline(inst, 'Allocating')
const max = chosen.policy.allocateMaxAttempts ?? 5
let lastAllocate: unknown
for (let attempt = 0; attempt < max; attempt++) {
const reserved = deps.reservations.reservedIds()
const src = runLocationStrategy(chosen.locationStrategies.source, deps.storages, inst.context, reserved)
const tgt = runLocationStrategy(chosen.locationStrategies.target, deps.storages, inst.context, reserved)
inst.explain.filter = { sourceElim: src.eliminated.length, targetElim: tgt.eliminated.length }
inst.explain.degrade = { source: src.degrade, target: tgt.degrade }
inst.explain.score = {
sourceTop: src.candidates.map((c) => c.loc.storageId),
targetTop: tgt.candidates.map((c) => c.loc.storageId)
}
if (!src.pick || !tgt.pick) {
lastAllocate = { result: 'no_candidate', attempt }
continue
}
const pair = deps.reservations.tryReservePair(inst.id, src.pick.storageId, tgt.pick.storageId)
if (!pair.ok) {
lastAllocate = {
result: 'pair_partial_rollback',
attempt,
reason: pair.reason,
tried: { sourceId: src.pick.storageId, targetId: tgt.pick.storageId },
heldThenReleased: pair.heldThenReleased
}
inst.explain.allocate = lastAllocate
continue
}
inst.sourceId = src.pick.storageId
inst.targetId = tgt.pick.storageId
inst.explain.allocate = { result: 'ok', sourceId: inst.sourceId, targetId: inst.targetId, attempt }
pushTimeline(inst, 'Reserved', `${inst.sourceId}${inst.targetId}`)
if (chosen.blueprint.options.autoDispatch) {
pushTimeline(inst, 'Dispatched', 'Mock 执行器已接单(待确认完成)')
inst.explain.dispatch = { adapter: 'MockDispatcher', state: 'accepted' }
}
return inst
}
inst.explain.allocate = lastAllocate ?? { result: 'no_candidate' }
inst.error = '选位失败:无候选或预占冲突'
pushTimeline(inst, 'Failed', inst.error)
return inst
}
export function mockDispatchAck(inst: TaskInstance, action: 'start' | 'complete' | 'reject', deps: RuntimeDeps) {
if (action === 'start' && inst.status === 'Dispatched') {
pushTimeline(inst, 'InTransit', '执行中')
return
}
if (action === 'complete' && (inst.status === 'Dispatched' || inst.status === 'InTransit')) {
deps.reservations.releaseByInstance(inst.id)
pushTimeline(inst, 'Completed', '搬运完成,预占已释放')
return
}
if (action === 'reject') {
pushTimeline(inst, 'Compensating', '派车拒绝,善后中')
const released = deps.reservations.releaseByInstance(inst.id)
pushTimeline(inst, 'Failed', `已释放预占: ${released.join(',') || '无'}`)
}
}
export function cancelInstance(inst: TaskInstance, deps: RuntimeDeps) {
if (['Completed', 'Failed', 'Cancelled', 'IgnoredDuplicate'].includes(inst.status)) return
pushTimeline(inst, 'Compensating', '取消善后')
const released = deps.reservations.releaseByInstance(inst.id)
pushTimeline(inst, 'Cancelled', `已释放预占: ${released.join(',') || '无'}`)
}
@@ -0,0 +1,75 @@
import type { Reservation } from '../types'
/** 内存预占:按 storageId 排序加锁,成对失败则全体释放 */
export class ReservationStore {
private rows = new Map<string, Reservation>()
list(): Reservation[] {
return [...this.rows.values()]
}
isReserved(storageId: string): boolean {
return this.rows.has(storageId)
}
reservedIds(): Set<string> {
return new Set(this.rows.keys())
}
/** 成对预占:任一失败则回滚已占 */
tryReservePair(instanceId: string, sourceId: string, targetId: string): {
ok: boolean
reason?: 'conflict' | 'same_slot'
heldThenReleased?: string[]
} {
if (sourceId === targetId) return { ok: false, reason: 'same_slot' }
const ordered = [sourceId, targetId].sort((a, b) => a.localeCompare(b))
const held: string[] = []
const now = new Date().toISOString()
for (const id of ordered) {
if (this.rows.has(id)) {
for (const h of held) this.rows.delete(h)
return { ok: false, reason: 'conflict', heldThenReleased: held }
}
this.rows.set(id, { storageId: id, instanceId, status: 'Held', createdAt: now })
held.push(id)
}
for (const id of held) {
const r = this.rows.get(id)!
r.status = 'Committed'
}
return { ok: true }
}
releaseByInstance(instanceId: string): string[] {
const released: string[] = []
for (const [id, r] of this.rows) {
if (r.instanceId === instanceId) {
this.rows.delete(id)
released.push(id)
}
}
return released
}
/** 演示:手动占住库位(无实例) */
forceHold(storageId: string, tag = 'manual'): boolean {
if (this.rows.has(storageId)) return false
this.rows.set(storageId, {
storageId,
instanceId: `force:${tag}`,
status: 'Committed',
createdAt: new Date().toISOString()
})
return true
}
releaseForce(storageId: string) {
const r = this.rows.get(storageId)
if (r?.instanceId.startsWith('force:')) this.rows.delete(storageId)
}
clear() {
this.rows.clear()
}
}
@@ -0,0 +1,86 @@
import type { TaskTemplateDsl } from '../types'
import { PARAM_FIELDS } from '../mock/seed'
export interface ValidationIssue {
code: string
level: 'error' | 'warn'
message: string
path?: string
}
export function validateTemplate(tpl: TaskTemplateDsl, mode: 'save' | 'publish'): ValidationIssue[] {
const issues: ValidationIssue[] = []
const err = (code: string, message: string, path?: string) =>
issues.push({ code, level: 'error', message, path })
const warn = (code: string, message: string, path?: string) =>
issues.push({ code, level: 'warn', message, path })
if (tpl.schemaVersion !== 1) err('S01', 'schemaVersion 必须为 1')
if (!tpl.name?.trim()) err('S02', '模板名不能为空')
if (typeof tpl.meta?.priority !== 'number') err('S02', 'priority 必须为数字', 'meta.priority')
if (!tpl.locationStrategies?.source || !tpl.locationStrategies?.target) {
err('S03', '必须配置 source 与 target 策略')
}
const asSet = new Set<string>()
for (const b of tpl.bindings ?? []) {
if (asSet.has(b.as)) err('S04', `绑定名重复: ${b.as}`, 'bindings')
asSet.add(b.as)
if (!PARAM_FIELDS.some((f) => `${f.module}.${f.path}` === b.from)) {
err('S05', `未知参数字段: ${b.from}`, `bindings.${b.as}`)
}
if ((b.resolve === 'lookup' || PARAM_FIELDS.find((f) => `${f.module}.${f.path}` === b.from)?.resolveMode === 'lookup') && !b.key) {
err('S06', `lookup 绑定缺少 key: ${b.as}`, `bindings.${b.as}`)
}
}
for (const side of ['source', 'target'] as const) {
const st = tpl.locationStrategies[side]
if (!st) continue
const ids = new Set<string>()
for (const g of st.filterGroups) {
if (ids.has(g.id)) err('S07', `过滤组 id 重复: ${g.id}`, `${side}.filterGroups`)
ids.add(g.id)
if (g.severity !== 'hard' && g.severity !== 'soft') err('S07', `非法 severity: ${g.severity}`)
}
for (const s of st.scores) {
if (!['nearer_to_ref', 'fifo_age', 'field_match_bonus', 'constant'].includes(s.function)) {
err('S09', `未知打分函数: ${s.function}`, `${side}.scores`)
}
if (!(s.weight > 0)) err('S09', `weight 必须 > 0: ${s.id}`, `${side}.scores`)
}
const topN = st.allocate?.topN ?? 5
if (topN < 1 || topN > 100) err('S10', 'topN 应在 1100', `${side}.allocate.topN`)
if (mode === 'publish') {
const hard = st.filterGroups.filter((g) => g.severity === 'hard')
if (side === 'source' && !hard.length) err('P04', '源侧至少要有一个 hard 过滤组', `${side}`)
for (const g of st.filterGroups) {
if (!g.expr || g.expr.type !== 'group') err('P03', `过滤组 ${g.id} 根节点必须是 group`, `${side}.${g.id}`)
}
const chain = st.degradeChain ?? []
for (const step of chain) {
for (const h of hard) {
if (!step.requireGroups.includes(h.id)) {
err('P05', `降级档 ${step.attempt} 未覆盖 hard 组 ${h.id}`, `${side}.degradeChain`)
}
}
for (const d of step.drop ?? []) {
if (hard.some((h) => h.id === d)) err('P05', `不能 drop hard 组 ${d}`, `${side}.degradeChain`)
}
}
if (!st.scores.length) warn('W02', `${side} 未配置打分,将按库位 ID 排序`, side)
}
}
if (mode === 'publish') {
if (!tpl.trigger?.source) err('P01', 'trigger.source 必填')
if (tpl.blueprint?.slots?.from !== 'source' || tpl.blueprint?.slots?.to !== 'target') {
err('P07', 'blueprint.slots 必须指向 source/target')
}
const ttl = tpl.policy?.reservationTtlSec ?? 0
if (ttl < 30 || ttl > 3600) err('P08', 'reservationTtlSec 应在 303600')
}
return issues
}
@@ -0,0 +1,177 @@
import type { ParamFieldDef, StorageLoc, TaskTemplateDsl } from '../types'
export const SEED_AREAS = [
{ areaId: 'A-STOR', name: '原材料存储区', areaType: 'Storage', lineId: undefined as string | undefined },
{ areaId: 'A-LINE1', name: '一线线边', areaType: 'LineSide', lineId: 'L1' },
{ areaId: 'A-LINE2', name: '二线线边', areaType: 'LineSide', lineId: 'L2' },
{ areaId: 'A-BUF', name: '空箱缓冲', areaType: 'Buffer', lineId: undefined }
]
export function createSeedStorages(): StorageLoc[] {
return [
{ storageId: 'S-01', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001', 'M002'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-08-01T08:00:00Z', x: 10, y: 10, qty: 10 },
{ storageId: 'S-02', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-08-03T08:00:00Z', x: 12, y: 10, qty: 8 },
{ storageId: 'S-03', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M003'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-08-02T08:00:00Z', x: 20, y: 10, qty: 5 },
{ storageId: 'S-04', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'EmptyContainer', materialAffinity: ['M001'], disabled: false, allowInbound: true, allowOutbound: true, x: 11, y: 12, qty: 0 },
{ storageId: 'S-05', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001'], disabled: true, allowInbound: false, allowOutbound: false, inboundAt: '2026-08-01T09:00:00Z', x: 15, y: 10, qty: 3 },
{ storageId: 'S-06', areaId: 'A-STOR', areaType: 'Storage', storageType: 'Storage', status: 'FullContainer', materialAffinity: ['M001'], disabled: false, allowInbound: true, allowOutbound: true, inboundAt: '2026-07-20T08:00:00Z', x: 30, y: 30, qty: 12 },
{ storageId: 'T-L1-01', areaId: 'A-LINE1', areaType: 'LineSide', storageType: 'LineSide', status: 'EmptyContainer', materialAffinity: [], disabled: false, lineId: 'L1', allowInbound: true, allowOutbound: true, x: 50, y: 10 },
{ storageId: 'T-L1-02', areaId: 'A-LINE1', areaType: 'LineSide', storageType: 'LineSide', status: 'EmptyContainer', materialAffinity: [], disabled: false, lineId: 'L1', allowInbound: true, allowOutbound: true, x: 52, y: 10 },
{ storageId: 'T-L1-03', areaId: 'A-LINE1', areaType: 'LineSide', storageType: 'LineSide', status: 'FullContainer', materialAffinity: ['M001'], disabled: false, lineId: 'L1', allowInbound: false, allowOutbound: true, x: 54, y: 10 },
{ storageId: 'T-L2-01', areaId: 'A-LINE2', areaType: 'LineSide', storageType: 'LineSide', status: 'EmptyContainer', materialAffinity: [], disabled: false, lineId: 'L2', allowInbound: true, allowOutbound: true, x: 50, y: 40 },
{ storageId: 'B-01', areaId: 'A-BUF', areaType: 'Buffer', storageType: 'Buffer', status: 'Empty', materialAffinity: [], disabled: false, allowInbound: true, allowOutbound: true, x: 5, y: 5 }
]
}
export const PARAM_FIELDS: ParamFieldDef[] = [
{ module: 'mes', path: 'call.materialCode', valueType: 'string', resolveMode: 'eventPayload', description: '叫料物料号' },
{ module: 'mes', path: 'call.qty', valueType: 'number', resolveMode: 'eventPayload', description: '叫料数量' },
{ module: 'aps', path: 'wo.lineId', valueType: 'string', resolveMode: 'eventPayload', description: '工单产线' },
{ module: 'wms', path: 'policy.lineSideZone', valueType: 'string', resolveMode: 'lookup', description: '产线对应线边库区' }
]
export const LOOKUP_TABLE: Record<string, string> = {
'line:L1': 'A-LINE1',
'line:L2': 'A-LINE2'
}
function andGroup(...compares: Array<{ left: string; op: string; right?: unknown; rightRef?: string }>) {
return {
type: 'group' as const,
op: 'and' as const,
children: compares.map((c) => ({
type: 'compare' as const,
left: { ref: c.left },
op: c.op as 'eq',
right: c.rightRef ? { ref: c.rightRef } : { const: c.right as never }
}))
}
}
export function createPresetTemplates(): TaskTemplateDsl[] {
const sourceHard = {
id: 'src-hard',
severity: 'hard' as const,
expr: andGroup(
{ left: 'wcs.disabled', op: 'eq', right: false },
{ left: 'wcs.storageType', op: 'eq', right: 'Storage' },
{ left: 'wcs.materialAffinity', op: 'contains', rightRef: 'context.materialCode' },
{ left: 'wcs.status', op: 'in', right: ['FullContainer'] },
{ left: 'wcs.allowOutbound', op: 'eq', right: true }
)
}
const sourceSoftNear = {
id: 'src-soft-near',
severity: 'soft' as const,
expr: andGroup(
{ left: 'wcs.x', op: 'lt', right: 25 }
)
}
const targetHard = {
id: 'tgt-hard',
severity: 'hard' as const,
expr: andGroup(
{ left: 'wcs.disabled', op: 'eq', right: false },
{ left: 'wcs.storageType', op: 'eq', right: 'LineSide' },
{ left: 'wcs.lineId', op: 'eq', rightRef: 'context.lineId' },
{ left: 'wcs.status', op: 'eq', right: 'EmptyContainer' },
{ left: 'wcs.allowInbound', op: 'eq', right: true }
)
}
const targetSoftZone = {
id: 'tgt-soft-zone',
severity: 'soft' as const,
expr: andGroup(
{ left: 'wcs.areaId', op: 'eq', rightRef: 'context.preferZone' }
)
}
const base = (id: string, name: string, priority: number): TaskTemplateDsl => ({
schemaVersion: 1,
id,
name,
published: true,
meta: { priority, mutexGroup: 'line-side-replenish', routeMode: 'single_winner' },
trigger: {
type: 'event',
source: 'mes.materialCall',
idempotency: { keyMode: 'eventId', onTerminalHit: 'reject' },
merge: { windowMs: 0, keyFrom: [] }
},
bindings: [
{ as: 'materialCode', from: 'mes.call.materialCode', required: true, resolve: 'eventPayload' },
{ as: 'qty', from: 'mes.call.qty', required: false, resolve: 'eventPayload', default: 1 },
{ as: 'lineId', from: 'aps.wo.lineId', required: true, resolve: 'eventPayload' },
{
as: 'preferZone',
from: 'wms.policy.lineSideZone',
required: false,
resolve: 'lookup',
key: { template: 'line:{lineId}', args: { lineId: 'context.lineId' } },
onError: 'default',
default: 'A-LINE1',
cacheTtlSec: 60
}
],
locationStrategies: {
source: {
filterGroups: [sourceHard, sourceSoftNear],
degradeChain: [
{ attempt: 0, requireGroups: ['src-hard', 'src-soft-near'] },
{ attempt: 1, requireGroups: ['src-hard'], drop: ['src-soft-near'] }
],
scores: [
{ id: 's-fifo', function: 'fifo_age', weight: 1, params: { timeField: 'inboundAt' } },
{ id: 's-near', function: 'nearer_to_ref', weight: 2, params: { refX: 50, refY: 10, metric: 'euclid' } }
],
allocate: { mode: 'first', topN: 5 }
},
target: {
filterGroups: [targetHard, targetSoftZone],
degradeChain: [
{ attempt: 0, requireGroups: ['tgt-hard', 'tgt-soft-zone'] },
{ attempt: 1, requireGroups: ['tgt-hard'], drop: ['tgt-soft-zone'] }
],
scores: [
{ id: 't-near', function: 'nearer_to_ref', weight: 1, params: { refX: 50, refY: 10, metric: 'euclid' } }
],
allocate: { mode: 'first', topN: 5 }
}
},
blueprint: {
taskType: 'transport',
slots: { from: 'source', to: 'target' },
options: { autoDispatch: true, priority: 50 }
},
policy: {
reservationTtlSec: 120,
allocateMaxAttempts: 5,
allocateBackoffMs: [0, 0, 0, 0, 0],
onNoCandidate: 'raise_alert',
onRealityDrift: 'fail',
onDispatchReject: 'compensate'
}
})
const a = base('tpl-line-replenish-A', '线边补料-A(高优先)', 200)
const b = base('tpl-line-replenish-B', '线边补料-B(低优先)', 100)
// B 更宽:源不要求 FullContainer,仅 soft 更松
b.locationStrategies.source.filterGroups = [
{
id: 'src-hard',
severity: 'hard',
expr: andGroup(
{ left: 'wcs.disabled', op: 'eq', right: false },
{ left: 'wcs.materialAffinity', op: 'contains', rightRef: 'context.materialCode' }
)
}
]
b.locationStrategies.source.degradeChain = [{ attempt: 0, requireGroups: ['src-hard'] }]
return [a, b]
}
export const DEMO_EVENT = {
eventId: 'evt-demo-001',
source: 'mes.materialCall',
payload: { materialCode: 'M001', qty: 1, lineId: 'L1', priority: 50 }
}
@@ -0,0 +1,203 @@
import { reactive } from 'vue'
import type { StorageLoc, TaskInstance, TaskTemplateDsl, TrialResult, TriggerEvent } from '../types'
import { createPresetTemplates, createSeedStorages, DEMO_EVENT, SEED_AREAS } from '../mock/seed'
import { ReservationStore } from '../engine/reservation'
import { cancelInstance, handleTrigger, mockDispatchAck, trialRun } from '../engine/orchestrator'
import { validateTemplate } from '../engine/validate'
import { clearLookupCache } from '../engine/context'
export interface DemoScriptResult {
id: string
name: string
ok: boolean
detail: string
}
function cloneTemplates(): TaskTemplateDsl[] {
return JSON.parse(JSON.stringify(createPresetTemplates())) as TaskTemplateDsl[]
}
export function createWorld() {
const state = reactive({
areas: SEED_AREAS,
storages: createSeedStorages() as StorageLoc[],
templates: cloneTemplates(),
instances: [] as TaskInstance[],
audits: [] as Array<{ at: string; type: string; detail: unknown }>,
lookupBroken: false,
lastTrial: null as TrialResult | null,
lastTriggerEvent: { ...DEMO_EVENT } as TriggerEvent,
demoResults: [] as DemoScriptResult[]
})
const reservations = new ReservationStore()
function deps() {
return {
templates: state.templates,
storages: state.storages,
reservations,
instances: state.instances,
lookupBroken: state.lookupBroken,
audits: state.audits
}
}
return {
state,
reservations,
reset() {
state.storages = createSeedStorages()
state.templates = cloneTemplates()
state.instances = []
state.audits = []
state.lookupBroken = false
state.lastTrial = null
state.demoResults = []
reservations.clear()
clearLookupCache()
},
saveTemplate(tpl: TaskTemplateDsl) {
const issues = validateTemplate(tpl, 'save').filter((i) => i.level === 'error')
if (issues.length) return { ok: false as const, issues }
const idx = state.templates.findIndex((t) => t.id === tpl.id)
const copy = JSON.parse(JSON.stringify(tpl)) as TaskTemplateDsl
if (idx >= 0) state.templates[idx] = copy
else state.templates.push(copy)
return { ok: true as const, issues: validateTemplate(copy, 'save') }
},
publishTemplate(id: string) {
const tpl = state.templates.find((t) => t.id === id)
if (!tpl) return { ok: false as const, issues: [{ code: 'X', level: 'error' as const, message: '模板不存在' }] }
const issues = validateTemplate(tpl, 'publish')
if (issues.some((i) => i.level === 'error')) return { ok: false as const, issues }
tpl.published = true
return { ok: true as const, issues }
},
trial(templateId: string, payload: Record<string, unknown>) {
const tpl = state.templates.find((t) => t.id === templateId)
if (!tpl) throw new Error('模板不存在')
state.lastTrial = trialRun(tpl, payload, state.storages, reservations.reservedIds(), state.lookupBroken)
return state.lastTrial
},
trigger(event?: TriggerEvent) {
const ev = event ?? (JSON.parse(JSON.stringify(state.lastTriggerEvent)) as TriggerEvent)
state.lastTriggerEvent = ev
return handleTrigger(deps(), ev)
},
dispatch(instanceId: string, action: 'start' | 'complete' | 'reject') {
const inst = state.instances.find((i) => i.id === instanceId)
if (!inst) return
mockDispatchAck(inst, action, deps())
},
cancel(instanceId: string) {
const inst = state.instances.find((i) => i.id === instanceId)
if (!inst) return
cancelInstance(inst, deps())
},
forceReserve(storageId: string) {
const loc = state.storages.find((s) => s.storageId === storageId)
if (loc) loc.forceReserved = true
reservations.forceHold(storageId)
},
clearForce(storageId: string) {
const loc = state.storages.find((s) => s.storageId === storageId)
if (loc) loc.forceReserved = false
reservations.releaseForce(storageId)
},
runDemoScripts(): DemoScriptResult[] {
const results: DemoScriptResult[] = []
const run = (id: string, name: string, fn: () => string | void) => {
this.reset()
try {
const detail = fn() ?? 'ok'
results.push({ id, name, ok: true, detail: String(detail) })
} catch (e) {
results.push({ id, name, ok: false, detail: String(e) })
}
}
run('happy', '快乐路径', () => {
const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-happy' })!
if (inst.status !== 'Dispatched' && inst.status !== 'Reserved') throw new Error(`状态=${inst.status}`)
if (!inst.sourceId || !inst.targetId) throw new Error('未选中库位')
this.dispatch(inst.id, 'start')
this.dispatch(inst.id, 'complete')
// 状态会被 dispatch 就地改写;避免 TS 把前面的联合收窄带到此处
if (String(inst.status) !== 'Completed') throw new Error(`完成失败 ${inst.status}`)
return `${inst.sourceId}${inst.targetId}`
})
run('hard', '硬性不放宽', () => {
for (const s of state.storages) {
s.materialAffinity = s.materialAffinity.filter((m) => m !== 'M001')
}
const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-hard' })!
if (inst.status !== 'Failed') throw new Error(`期望 Failed,实际 ${inst.status}`)
return '硬性无候选 → Failed'
})
run('degrade', '弹性降级', () => {
// 让 soft x<25 失败:近处存储位去掉 M001,保留远处 S-06
for (const s of state.storages) {
if (['S-01', 'S-02', 'S-04'].includes(s.storageId)) s.materialAffinity = ['M999']
}
const trial = this.trial('tpl-line-replenish-A', DEMO_EVENT.payload)
if (!trial.ok || !trial.pick) throw new Error('降级后应仍能选到位')
if (trial.pick.sourceId !== 'S-06') throw new Error(`期望源 S-06,实际 ${trial.pick.sourceId}`)
const deg = trial.explain.degrade as { source: Array<{ attempt: number }> }
if (!deg?.source?.some((d) => d.attempt >= 1)) throw new Error('未见降级 attempt>=1')
return `选中 ${trial.pick.sourceId}${trial.pick.targetId}(经降级)`
})
run('pair_rollback', '成对回滚', () => {
this.forceReserve('T-L1-01')
this.forceReserve('T-L1-02')
const before = reservations.list().length
const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-pair' })!
if (inst.status !== 'Failed') throw new Error(`期望 Failed,实际 ${inst.status}`)
const alloc = inst.explain.allocate as { result?: string }
// 可能 no_candidate(过滤阶段已无终点)或 pair_partial_rollback
if (!alloc || (alloc.result !== 'no_candidate' && alloc.result !== 'pair_partial_rollback')) {
throw new Error(`allocate=${JSON.stringify(alloc)}`)
}
const leaked = reservations.list().filter((r) => r.instanceId === inst.id)
if (leaked.length) throw new Error('存在实例残留预占')
return `无残留预占(手动占位仍 ${before} 条)`
})
run('dedupe', '去重', () => {
const a = this.trigger({ ...DEMO_EVENT, eventId: 'evt-dup' })!
const b = this.trigger({ ...DEMO_EVENT, eventId: 'evt-dup' })!
if (b.status !== 'IgnoredDuplicate') throw new Error(`第二次应为 IgnoredDuplicate,实际 ${b.status}`)
const active = state.instances.filter((i) => i.eventId === 'evt-dup' && i.status !== 'IgnoredDuplicate')
if (active.length !== 1) throw new Error('活跃实例应只有 1 条')
return `原单 ${a.id},重复 ${b.id}`
})
run('arbitrate', '多模板仲裁', () => {
const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-arb' })!
if (inst.templateId !== 'tpl-line-replenish-A') throw new Error(`应选 A,实际 ${inst.templateId}`)
const lost = state.audits.some(
(a) => a.type === 'template_not_selected' && (a.detail as { templateId?: string }).templateId === 'tpl-line-replenish-B'
)
if (!lost) throw new Error('缺少 B 落选审计')
return `胜出 ${inst.templateId}`
})
run('cancel', '取消善后', () => {
const inst = this.trigger({ ...DEMO_EVENT, eventId: 'evt-cancel' })!
if (inst.status !== 'Reserved' && inst.status !== 'Dispatched') throw new Error(inst.status)
this.cancel(inst.id)
if (String(inst.status) !== 'Cancelled') throw new Error(inst.status)
if (reservations.list().some((r) => r.instanceId === inst.id)) throw new Error('预占未释放')
return '已取消并释放预占'
})
state.demoResults = results
return results
}
}
}
export type WcsProtoWorld = ReturnType<typeof createWorld>
@@ -0,0 +1,19 @@
/**
* UI Node tsx 7
* simple-platform-vue npx tsx src/wcs-proto/selfcheck.ts
*/
import { createWorld } from './runtime/world'
const world = createWorld()
const results = world.runDemoScripts()
let failed = 0
for (const r of results) {
const mark = r.ok ? 'PASS' : 'FAIL'
console.log(`[${mark}] ${r.id} ${r.name}: ${r.detail}`)
if (!r.ok) failed++
}
if (failed) {
console.error(`\n${failed}/${results.length} failed`)
process.exit(1)
}
console.log(`\nAll ${results.length} demo scripts passed.`)
@@ -0,0 +1,183 @@
/** WCS 任务模板引擎原型 — 类型与 DSLschemaVersion=1 */
export type Severity = 'hard' | 'soft'
export type ValueRef =
| { ref: string }
| { const: string | number | boolean | Array<string | number | boolean> | null }
export type CompareOp =
| 'eq' | 'ne' | 'in' | 'notIn' | 'contains' | 'notContains'
| 'gt' | 'gte' | 'lt' | 'lte' | 'between' | 'exists' | 'notExists' | 'matchesRef'
export type ExprNode =
| { type: 'group'; op: 'and' | 'or'; children: ExprNode[] }
| { type: 'compare'; left: ValueRef; op: CompareOp; right?: ValueRef }
export interface FilterGroup {
id: string
severity: Severity
expr: ExprNode
}
export interface DegradeStep {
attempt: number
requireGroups: string[]
drop?: string[]
}
export interface ScoreRule {
id: string
function: 'nearer_to_ref' | 'fifo_age' | 'field_match_bonus' | 'constant'
weight: number
params: Record<string, unknown>
}
export interface LocationStrategy {
filterGroups: FilterGroup[]
degradeChain: DegradeStep[]
scores: ScoreRule[]
allocate: { mode: 'first' | 'topN_split'; topN?: number }
}
export interface ParamBinding {
as: string
from: string
required?: boolean
resolve?: 'eventPayload' | 'lookup' | 'session'
key?: { template: string; args: Record<string, string> }
onError?: 'cached' | 'suspend' | 'default' | 'fail'
timeoutMs?: number
cacheTtlSec?: number
default?: unknown
}
export interface TaskTemplateDsl {
schemaVersion: 1
id: string
name: string
published: boolean
meta: {
priority: number
mutexGroup?: string
routeMode?: 'single_winner' | 'multi'
}
trigger: {
type: 'event' | 'manual'
source: string
idempotency?: { keyMode: 'eventId' | 'businessKey+templateId'; onTerminalHit?: 'reject' | 'new_if_terminal' }
merge?: { windowMs: number; keyFrom: string[] }
}
bindings: ParamBinding[]
locationStrategies: {
source: LocationStrategy
target: LocationStrategy
}
blueprint: {
taskType: 'transport'
slots: { from: 'source'; to: 'target' }
options: { autoDispatch: boolean; priority?: number }
}
policy: {
reservationTtlSec: number
allocateMaxAttempts: number
allocateBackoffMs: number[]
onNoCandidate: 'raise_alert' | 'fail'
onRealityDrift?: 'fail' | 'reallocate'
onDispatchReject?: 'compensate'
}
}
export interface StorageLoc {
storageId: string
areaId: string
areaType: string
storageType: string
status: string
materialAffinity: string[]
disabled: boolean
lineId?: string
allowInbound: boolean
allowOutbound: boolean
containerType?: string
batchNo?: string
qty?: number
inboundAt?: string
x: number
y: number
/** 演示用:被手动占住 */
forceReserved?: boolean
}
export interface ParamFieldDef {
module: string
path: string
valueType: 'string' | 'number' | 'bool'
resolveMode: 'eventPayload' | 'lookup' | 'session'
description: string
}
export type InstanceStatus =
| 'Pending'
| 'Assembling'
| 'Suspended'
| 'Allocating'
| 'Reserved'
| 'Dispatched'
| 'InTransit'
| 'Completed'
| 'Compensating'
| 'Failed'
| 'Cancelled'
| 'IgnoredDuplicate'
export interface ExplainSection {
arbitration?: unknown
bindings?: unknown
filter?: unknown
degrade?: unknown
score?: unknown
allocate?: unknown
dispatch?: unknown
[k: string]: unknown
}
export interface TaskInstance {
id: string
templateId: string
templateName: string
eventId: string
status: InstanceStatus
context: Record<string, unknown>
sourceId?: string
targetId?: string
explain: ExplainSection
createdAt: string
updatedAt: string
error?: string
timeline: Array<{ at: string; status: InstanceStatus; note?: string }>
}
export interface Reservation {
storageId: string
instanceId: string
status: 'Held' | 'Committed'
createdAt: string
}
export interface TriggerEvent {
eventId: string
source: string
payload: Record<string, unknown>
}
export interface TrialResult {
ok: boolean
context: Record<string, unknown>
sourceCandidates: Array<{ storageId: string; total: number; scores: Record<string, number> }>
targetCandidates: Array<{ storageId: string; total: number; scores: Record<string, number> }>
eliminated: Array<{ storageId: string; strategy: string; groupId?: string; reason: string }>
degrade: unknown[]
pick?: { sourceId: string; targetId: string }
error?: string
explain: ExplainSection
}