Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -41,3 +41,4 @@ Desktop.ini
|
|||||||
# 临时脚本 / 工具产物(不应入库)
|
# 临时脚本 / 工具产物(不应入库)
|
||||||
.codex-temp/
|
.codex-temp/
|
||||||
frontends/apps/simple-platform-vue/imgui.ini
|
frontends/apps/simple-platform-vue/imgui.ini
|
||||||
|
/.cursor/plans
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public static class PageCatalog
|
|||||||
{
|
{
|
||||||
// ── 管理端 / Platform:概览 ──
|
// ── 管理端 / Platform:概览 ──
|
||||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||||
|
new("admin-setup", "初始配置", "概览", ScopePlatform),
|
||||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||||
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
||||||
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
||||||
@@ -42,8 +43,12 @@ public static class PageCatalog
|
|||||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||||
|
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
|
||||||
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||||
|
|
||||||
|
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
|
||||||
|
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||||
@@ -86,6 +91,11 @@ public static class PageCatalog
|
|||||||
["admin-config-map-monitor"] = "admin-config-ops-center",
|
["admin-config-map-monitor"] = "admin-config-ops-center",
|
||||||
["admin-config-system"] = "admin-config-system-center",
|
["admin-config-system"] = "admin-config-system-center",
|
||||||
["admin-config-auth"] = "admin-config-system-center",
|
["admin-config-auth"] = "admin-config-system-center",
|
||||||
|
["admin-data-center-stations"] = "admin-data-center",
|
||||||
|
["admin-data-center-docks"] = "admin-data-center",
|
||||||
|
["admin-data-center-handshake"] = "admin-data-center",
|
||||||
|
["admin-data-center-release"] = "admin-data-center",
|
||||||
|
["admin-data-center-mag-control"] = "admin-data-center",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ public sealed class RbacStore
|
|||||||
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||||
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||||
r.Pages.Add("admin-simple-fields");
|
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)
|
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||||
|
|||||||
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
|
|||||||
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
/// <summary>
|
||||||
|
/// 场景插件 → 平台页面 Key。选 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()
|
public static IReadOnlyCollection<string> TailorablePages()
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
|
||||||
|
foreach (var v in SceneToPages.Values) foreach (var p in v) set.Add(p);
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
|
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
|
||||||
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
if (dp == null) return set;
|
if (dp == null) return set;
|
||||||
foreach (var m in dp.Modules ?? new List<string>())
|
foreach (var m in dp.Modules ?? new List<string>())
|
||||||
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
|
||||||
|
|
||||||
|
var activeScenes = dp.ToLauncherSceneIds();
|
||||||
|
foreach (var (sceneId, pages) in SceneToPages)
|
||||||
|
{
|
||||||
|
if (!activeScenes.Contains(sceneId, StringComparer.OrdinalIgnoreCase)) continue;
|
||||||
|
foreach (var p in pages) set.Add(p);
|
||||||
|
}
|
||||||
|
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,36 @@ public record DeploymentProfile(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>信号交互场景 id(PLC 握手)。仅 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);
|
||||||
|
}
|
||||||
@@ -84,9 +84,8 @@ public class WizardController : ControllerBase
|
|||||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||||
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||||
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signal(PLC 握手)。
|
||||||
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
var sceneIds = profile.ToLauncherSceneIds();
|
||||||
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
|
||||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||||
|
|
||||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||||
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
|
|||||||
navigationKinds = dp.NavigationKinds,
|
navigationKinds = dp.NavigationKinds,
|
||||||
scenarios = dp.Scenarios,
|
scenarios = dp.Scenarios,
|
||||||
updatedBy = dp.UpdatedBy,
|
updatedBy = dp.UpdatedBy,
|
||||||
activeSceneIds = dp.ToActiveSceneIds(),
|
activeSceneIds = dp.ToLauncherSceneIds(),
|
||||||
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
hiddenPages = DeploymentCatalog.HiddenPages(dp),
|
||||||
activeScenesWrite = write == null ? null : new
|
activeScenesWrite = write == null ? null : new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public static class DashboardShortcutCatalog
|
|||||||
private static readonly ShortcutDef[] PlatformShortcuts =
|
private static readonly ShortcutDef[] PlatformShortcuts =
|
||||||
[
|
[
|
||||||
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
|
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-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
|
||||||
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
|
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
|
||||||
new("admin-map-editor", "admin-map-editor", 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-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
||||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||||
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
|
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
|
||||||
|
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
|
||||||
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
||||||
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||||
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>MiGu.Server</RootNamespace>
|
<RootNamespace>MiGu.Server</RootNamespace>
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
|
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\StandardSence\StandardScene.Signal\StandardScene.Signal.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using MiGu.Server.Configs;
|
|||||||
using MiGu.Server.Launcher;
|
using MiGu.Server.Launcher;
|
||||||
using MiGu.Server.OpenApi;
|
using MiGu.Server.OpenApi;
|
||||||
using MiGu.Server.Ota;
|
using MiGu.Server.Ota;
|
||||||
|
using MiGu.Server.Signal;
|
||||||
using MiGu.DB.Kernel.Hosting;
|
using MiGu.DB.Kernel.Hosting;
|
||||||
using MiGu.Server.Persistence;
|
using MiGu.Server.Persistence;
|
||||||
using Yarp.ReverseProxy.Transforms;
|
using Yarp.ReverseProxy.Transforms;
|
||||||
@@ -264,6 +265,7 @@ builder.Services.AddPlatformPersistence(builder.Configuration);
|
|||||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||||
|
builder.Services.AddSingleton<SignalDataStore>();
|
||||||
|
|
||||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||||
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
public sealed record SignalColumn(string Key, string Label, string Type, IReadOnlyList<string>? Options = null, string? Group = null);
|
||||||
|
|
||||||
|
public sealed record SignalTableDef(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
string Category,
|
||||||
|
string FileName,
|
||||||
|
IReadOnlyList<SignalColumn> Columns);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读写 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
|
||||||
|
/// 表结构优先从 StandardScene.Signal.dll 反射;无插件时才读 signal-tables.json。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SignalDataStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions FileJson = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly object FileLock = new();
|
||||||
|
|
||||||
|
private readonly SimpleLiteLauncher _launcher;
|
||||||
|
private IReadOnlyList<SignalTableDef>? _cachedTables;
|
||||||
|
private long _cachedSignature;
|
||||||
|
|
||||||
|
public SignalDataStore(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||||
|
|
||||||
|
public IReadOnlyList<SignalTableDef> GetTables()
|
||||||
|
{
|
||||||
|
var sig = SignalTableManifestLoader.ComputeManifestSignature(_launcher);
|
||||||
|
if (_cachedTables == null || sig != _cachedSignature)
|
||||||
|
{
|
||||||
|
_cachedTables = SignalTableManifestLoader.Load(_launcher);
|
||||||
|
_cachedSignature = sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cachedTables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignalTableDef? Find(string id) =>
|
||||||
|
GetTables().FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public string? ResolveWorkingDirectory() => _launcher.ResolveWorkingDirectory();
|
||||||
|
|
||||||
|
public (string? Path, string? Error) ResolveFile(SignalTableDef table, bool createDir)
|
||||||
|
{
|
||||||
|
var wd = _launcher.ResolveWorkingDirectory();
|
||||||
|
if (string.IsNullOrWhiteSpace(wd))
|
||||||
|
return (null, "未找到 SimpleLite 工作目录,无法定位 Config/Signal");
|
||||||
|
|
||||||
|
var dest = Path.GetFullPath(Path.Combine(wd, "Config", "Signal", table.FileName));
|
||||||
|
if (File.Exists(dest))
|
||||||
|
return (dest, null);
|
||||||
|
|
||||||
|
if (createDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(dest);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dest, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonArray LoadRows(SignalTableDef table)
|
||||||
|
{
|
||||||
|
var (path, _) = ResolveFile(table, createDir: false);
|
||||||
|
if (path == null || !File.Exists(path))
|
||||||
|
return new JsonArray();
|
||||||
|
|
||||||
|
lock (FileLock)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return new JsonArray();
|
||||||
|
var node = JsonNode.Parse(json);
|
||||||
|
if (node is JsonArray arr)
|
||||||
|
return arr;
|
||||||
|
return new JsonArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveRows(SignalTableDef table, JsonArray rows)
|
||||||
|
{
|
||||||
|
var (path, error) = ResolveFile(table, createDir: true);
|
||||||
|
if (path == null)
|
||||||
|
throw new InvalidOperationException(error ?? "无法解析信号配置文件路径");
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
lock (FileLock)
|
||||||
|
File.WriteAllText(path, rows.ToJsonString(FileJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using StandardScene.Signal.Model;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从 StandardScene.Signal 程序集反射数据中心表和列。
|
||||||
|
/// 优先使用 MiGu.Server 编译期引用的程序集;否则再从 SimpleLite plugins 加载。
|
||||||
|
/// </summary>
|
||||||
|
public static class SignalModelSchemaResolver
|
||||||
|
{
|
||||||
|
private const string ModelNamespace = "StandardScene.Signal.Model";
|
||||||
|
private const string AssemblyFileName = "StandardScene.Signal.dll";
|
||||||
|
|
||||||
|
public static string? FindAssemblyPath(string? pluginsDir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pluginsDir))
|
||||||
|
return null;
|
||||||
|
var path = Path.Combine(pluginsDir, AssemblyFileName);
|
||||||
|
return File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<SignalTableDef> ResolveTables(string? pluginsDir, string? workingDirectory = null)
|
||||||
|
{
|
||||||
|
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
|
||||||
|
if (assembly == null)
|
||||||
|
return Array.Empty<SignalTableDef>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return BuildTablesFromAssembly(assembly);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<SignalTableDef>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<SignalColumn> ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modelName))
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
|
||||||
|
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
|
||||||
|
if (assembly == null)
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
|
||||||
|
if (type == null)
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
|
||||||
|
return DiscoverColumns(type);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly? TryGetSignalAssembly(string? pluginsDir, string? workingDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var referenced = typeof(PlcStationModel).Assembly;
|
||||||
|
if (HasModelTypes(referenced))
|
||||||
|
return referenced;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* 未引用插件工程时继续走 LoadFrom */
|
||||||
|
}
|
||||||
|
|
||||||
|
var assemblyPath = FindAssemblyPath(pluginsDir);
|
||||||
|
if (assemblyPath == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var probeDirs = BuildProbeDirs(pluginsDir, workingDirectory);
|
||||||
|
ResolveEventHandler? handler = null;
|
||||||
|
handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve += handler;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = Assembly.LoadFrom(assemblyPath);
|
||||||
|
return HasModelTypes(loaded) ? loaded : null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handler != null)
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve -= handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasModelTypes(Assembly assembly)
|
||||||
|
{
|
||||||
|
return SafeGetTypes(assembly).Any(t =>
|
||||||
|
t != null && string.Equals(t.Namespace, ModelNamespace, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> BuildProbeDirs(string? pluginsDir, string? workingDirectory)
|
||||||
|
{
|
||||||
|
var dirs = new List<string>();
|
||||||
|
void Add(string? dir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dir)) return;
|
||||||
|
var full = Path.GetFullPath(dir);
|
||||||
|
if (Directory.Exists(full) && !dirs.Contains(full, StringComparer.OrdinalIgnoreCase))
|
||||||
|
dirs.Add(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(pluginsDir);
|
||||||
|
Add(workingDirectory);
|
||||||
|
if (!string.IsNullOrWhiteSpace(pluginsDir))
|
||||||
|
Add(Path.GetDirectoryName(pluginsDir));
|
||||||
|
Add(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly? ResolveAssembly(string? assemblyName, IReadOnlyList<string> probeDirs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assemblyName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string simpleName;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
simpleName = new AssemblyName(assemblyName).Name ?? assemblyName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
simpleName = assemblyName.Split(',')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dir in probeDirs)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(dir, simpleName + ".dll");
|
||||||
|
if (!File.Exists(path))
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Assembly.LoadFrom(path);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* try next dir */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SignalTableDef> BuildTablesFromAssembly(Assembly assembly)
|
||||||
|
{
|
||||||
|
var found = new List<(int Order, SignalTableDef Table)>();
|
||||||
|
foreach (var type in SafeGetTypes(assembly))
|
||||||
|
{
|
||||||
|
if (type == null || !string.Equals(type.Namespace, ModelNamespace, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attr = type.GetCustomAttributes(inherit: false)
|
||||||
|
.FirstOrDefault(a => a.GetType().Name == "SignalTableAttribute");
|
||||||
|
if (attr == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var attrType = attr.GetType();
|
||||||
|
var id = (attrType.GetProperty("Id")?.GetValue(attr) as string ?? "").Trim();
|
||||||
|
var fileName = (attrType.GetProperty("FileName")?.GetValue(attr) as string ?? "").Trim();
|
||||||
|
var title = (attrType.GetProperty("Title")?.GetValue(attr) as string ?? "").Trim();
|
||||||
|
var order = attrType.GetProperty("Order")?.GetValue(attr) as int? ?? 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var category = type.GetCustomAttribute<CategoryAttribute>()?.Category ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
title = type.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? id;
|
||||||
|
|
||||||
|
found.Add((order, new SignalTableDef(id, title, category, fileName, DiscoverColumns(type))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.OrderBy(x => x.Order).ThenBy(x => x.Table.Id).Select(x => x.Table).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return assembly.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
return ex.Types.Where(t => t != null)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SignalColumn> DiscoverColumns(Type type)
|
||||||
|
{
|
||||||
|
var list = new List<SignalColumn>();
|
||||||
|
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||||
|
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
|
||||||
|
.Where(p => !IsJsonIgnored(p))
|
||||||
|
.OrderBy(p => p.MetadataToken))
|
||||||
|
{
|
||||||
|
var label = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;
|
||||||
|
if (string.IsNullOrWhiteSpace(label))
|
||||||
|
label = prop.Name;
|
||||||
|
|
||||||
|
var columnType = MapType(prop.PropertyType);
|
||||||
|
string[]? options = null;
|
||||||
|
var group = prop.GetCustomAttribute<CategoryAttribute>(inherit: false)?.Category;
|
||||||
|
if (string.IsNullOrWhiteSpace(group))
|
||||||
|
group = null;
|
||||||
|
|
||||||
|
var select = ReadSelectOptions(prop);
|
||||||
|
if (select is { Length: > 0 })
|
||||||
|
{
|
||||||
|
columnType = "enum";
|
||||||
|
options = select;
|
||||||
|
}
|
||||||
|
else if (columnType == "enum" && (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType).IsEnum)
|
||||||
|
{
|
||||||
|
options = Enum.GetNames(Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(new SignalColumn(prop.Name, label, columnType, options, group));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsJsonIgnored(PropertyInfo prop)
|
||||||
|
{
|
||||||
|
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() != null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
foreach (var attr in prop.GetCustomAttributes(inherit: true))
|
||||||
|
{
|
||||||
|
if (attr.GetType().FullName == "Newtonsoft.Json.JsonIgnoreAttribute")
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[]? ReadSelectOptions(PropertyInfo prop)
|
||||||
|
{
|
||||||
|
var attr = prop.GetCustomAttributes(inherit: false)
|
||||||
|
.FirstOrDefault(a => a.GetType().Name == "SignalSelectAttribute");
|
||||||
|
if (attr == null)
|
||||||
|
return null;
|
||||||
|
var options = attr.GetType().GetProperty("Options")?.GetValue(attr) as string[];
|
||||||
|
return options is { Length: > 0 } ? options : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapType(Type type)
|
||||||
|
{
|
||||||
|
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
if (underlying == typeof(bool))
|
||||||
|
return "bool";
|
||||||
|
if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) ||
|
||||||
|
underlying == typeof(byte) || underlying == typeof(uint) || underlying == typeof(ulong))
|
||||||
|
return "int";
|
||||||
|
if (underlying.IsEnum)
|
||||||
|
return "enum";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
public sealed class SignalTableManifest
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
public List<SignalTableManifestEntry> Tables { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SignalTableManifestEntry
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
|
||||||
|
public string FileName { get; set; } = "";
|
||||||
|
|
||||||
|
public string Model { get; set; } = "";
|
||||||
|
|
||||||
|
public List<SignalTableColumnEntry>? Columns { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SignalTableColumnEntry
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
|
||||||
|
public string Type { get; set; } = "string";
|
||||||
|
|
||||||
|
[JsonPropertyName("options")]
|
||||||
|
public string[]? Options { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("group")]
|
||||||
|
public string? Group { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Signal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先从 plugins/StandardScene.Signal.dll 反射表和列(Model 上的 SignalTable / DisplayName)。
|
||||||
|
/// 没有插件 DLL 时才读 signal-tables.json 或内置清单。
|
||||||
|
/// </summary>
|
||||||
|
public static class SignalTableManifestLoader
|
||||||
|
{
|
||||||
|
private const string ManifestFileName = "signal-tables.json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IReadOnlyList<SignalTableDef> Load(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
var workingDirectory = launcher.ResolveWorkingDirectory();
|
||||||
|
var pluginsDir = launcher.ResolvePluginsDir();
|
||||||
|
var fromPlugin = SignalModelSchemaResolver.ResolveTables(pluginsDir, workingDirectory);
|
||||||
|
if (fromPlugin.Count > 0)
|
||||||
|
return fromPlugin;
|
||||||
|
|
||||||
|
var manifest = TryLoadManifest(launcher);
|
||||||
|
return manifest.Tables
|
||||||
|
.Where(t => !string.IsNullOrWhiteSpace(t.Id) && !string.IsNullOrWhiteSpace(t.FileName))
|
||||||
|
.Select(e => ToDef(e, pluginsDir, workingDirectory))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long ComputeManifestSignature(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
long sig = 0;
|
||||||
|
foreach (var path in ResolveManifestPaths(launcher))
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
sig ^= info.LastWriteTimeUtc.Ticks;
|
||||||
|
sig ^= info.Length;
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluginsDir = launcher.ResolvePluginsDir();
|
||||||
|
var dll = pluginsDir == null ? null : Path.Combine(pluginsDir, "StandardScene.Signal.dll");
|
||||||
|
if (dll != null && File.Exists(dll))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(dll);
|
||||||
|
sig ^= info.LastWriteTimeUtc.Ticks;
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignalTableManifest TryLoadManifest(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
foreach (var path in ResolveManifestPaths(launcher))
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var manifest = JsonSerializer.Deserialize<SignalTableManifest>(json, JsonOptions);
|
||||||
|
if (manifest?.Tables is { Count: > 0 })
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
catch { /* try next */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<SignalTableManifest>(EmbeddedFallbackJson, JsonOptions)
|
||||||
|
?? new SignalTableManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ResolveManifestPaths(SimpleLiteLauncher launcher)
|
||||||
|
{
|
||||||
|
var wd = launcher.ResolveWorkingDirectory();
|
||||||
|
if (!string.IsNullOrWhiteSpace(wd))
|
||||||
|
yield return Path.Combine(wd, "Config", "Signal", ManifestFileName);
|
||||||
|
|
||||||
|
var plugins = launcher.ResolvePluginsDir();
|
||||||
|
if (string.IsNullOrWhiteSpace(plugins)) yield break;
|
||||||
|
|
||||||
|
yield return Path.Combine(plugins, ManifestFileName);
|
||||||
|
yield return Path.Combine(plugins, "Config", "Signal", ManifestFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignalTableDef ToDef(SignalTableManifestEntry entry, string? pluginsDir, string? workingDirectory)
|
||||||
|
{
|
||||||
|
var columns = BuildColumns(entry, pluginsDir, workingDirectory);
|
||||||
|
return new SignalTableDef(
|
||||||
|
entry.Id.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(entry.Title) ? entry.Id.Trim() : entry.Title.Trim(),
|
||||||
|
entry.Category ?? "",
|
||||||
|
entry.FileName.Trim(),
|
||||||
|
columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SignalColumn> BuildColumns(
|
||||||
|
SignalTableManifestEntry entry,
|
||||||
|
string? pluginsDir,
|
||||||
|
string? workingDirectory)
|
||||||
|
{
|
||||||
|
var reflected = SignalModelSchemaResolver.ResolveColumns(entry.Model, pluginsDir, workingDirectory);
|
||||||
|
if (reflected.Count > 0)
|
||||||
|
return reflected;
|
||||||
|
|
||||||
|
if (entry.Columns is { Count: > 0 })
|
||||||
|
{
|
||||||
|
return entry.Columns
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c.Key))
|
||||||
|
.Select(c => new SignalColumn(
|
||||||
|
c.Key.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(c.Label) ? c.Key.Trim() : c.Label.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(c.Type) ? "string" : c.Type.Trim(),
|
||||||
|
c.Options,
|
||||||
|
string.IsNullOrWhiteSpace(c.Group) ? null : c.Group.Trim()))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<SignalColumn>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string EmbeddedFallbackJson = """
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"id": "stations",
|
||||||
|
"title": "PLC机构",
|
||||||
|
"category": "PLC数据管理",
|
||||||
|
"fileName": "stations.json",
|
||||||
|
"model": "PlcStationModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "docks",
|
||||||
|
"title": "机构工位",
|
||||||
|
"category": "PLC数据管理",
|
||||||
|
"fileName": "station-docks.json",
|
||||||
|
"model": "PlcStationDockModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "handshake",
|
||||||
|
"title": "握手点",
|
||||||
|
"category": "握手点数据管理",
|
||||||
|
"fileName": "handshake-points.json",
|
||||||
|
"model": "HandshakePointModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "release",
|
||||||
|
"title": "放行点",
|
||||||
|
"category": "放行点数据管理",
|
||||||
|
"fileName": "release-points.json",
|
||||||
|
"model": "ReleasePointModel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mag-control",
|
||||||
|
"title": "磁条管控区",
|
||||||
|
"category": "磁条交管",
|
||||||
|
"fileName": "mag-control-areas.json",
|
||||||
|
"model": "MagControlAreaModel"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
},
|
},
|
||||||
"SimpleLite": {
|
"SimpleLite": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
|
"ExecutablePath": "D:\\工作\\stand\\SimpleLite\\SimpleLite.exe",
|
||||||
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
|
"WorkingDirectory": "D:\\工作\\stand\\SimpleLite",
|
||||||
"ProjectionPort": 8222,
|
"ProjectionPort": 8222,
|
||||||
"ReadinessTimeoutMs": 8000,
|
"ReadinessTimeoutMs": 8000,
|
||||||
"FollowParent": false
|
"FollowParent": false
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
||||||
"SimpleLite": {
|
"SimpleLite": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ExecutablePath": "D:\\Code\\Products\\MIGU2.0\\SimpleLite\\SimpleLite.exe",
|
"ExecutablePath": "D:\\工作\\stand\\SimpleLite\\SimpleLite.exe",
|
||||||
"WorkingDirectory": "D:\\Code\\Products\\MIGU2.0\\SimpleLite",
|
"WorkingDirectory": "D:\\工作\\stand\\SimpleLite",
|
||||||
"Arguments": "",
|
"Arguments": "",
|
||||||
"ReadinessTimeoutMs": 15000,
|
"ReadinessTimeoutMs": 15000,
|
||||||
"ReadinessPollIntervalMs": 250,
|
"ReadinessPollIntervalMs": 250,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"wcs-proto:selfcheck": "npx --yes tsx src/wcs-proto/selfcheck.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@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 = {
|
let mockProfile: DeploymentProfileDto = {
|
||||||
configured: false,
|
configured: false,
|
||||||
platformType: 'standard',
|
platformType: 'standard',
|
||||||
@@ -61,7 +79,7 @@ export async function saveWizardProfile(req: SaveWizardRequest): Promise<Deploym
|
|||||||
navigationKinds: req.navigationKinds ?? [],
|
navigationKinds: req.navigationKinds ?? [],
|
||||||
scenarios: req.scenarios ?? [],
|
scenarios: req.scenarios ?? [],
|
||||||
configured: true,
|
configured: true,
|
||||||
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
|
activeSceneIds: toLauncherSceneIds(req.navigationKinds ?? [], req.scenarios ?? [])
|
||||||
}
|
}
|
||||||
return mockProfile
|
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,
|
DocumentCopy,
|
||||||
EditPen,
|
EditPen,
|
||||||
Files,
|
Files,
|
||||||
|
Grid,
|
||||||
Histogram,
|
Histogram,
|
||||||
Link,
|
Link,
|
||||||
List,
|
List,
|
||||||
@@ -37,6 +38,7 @@ export interface NavMenuItem {
|
|||||||
|
|
||||||
export const ADMIN_MENU: NavMenuItem[] = [
|
export const ADMIN_MENU: NavMenuItem[] = [
|
||||||
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
|
{ 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: '概览',
|
path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览',
|
||||||
children: [
|
children: [
|
||||||
@@ -56,9 +58,20 @@ export const ADMIN_MENU: NavMenuItem[] = [
|
|||||||
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编辑' },
|
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编辑' },
|
||||||
{ path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', 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/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/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/data-center', label: '数据中心', icon: Grid, key: 'admin-data-center', group: '数据中心',
|
||||||
|
children: [
|
||||||
|
{ path: '/admin/data-center/stations', label: 'PLC机构', icon: Cpu, key: 'admin-data-center', group: '数据中心' },
|
||||||
|
{ path: '/admin/data-center/docks', label: '机构工位', icon: Connection, key: 'admin-data-center', group: '数据中心' },
|
||||||
|
{ path: '/admin/data-center/handshake', label: '握手点', icon: Connection, key: 'admin-data-center', group: '数据中心' },
|
||||||
|
{ path: '/admin/data-center/release', label: '放行点', icon: Promotion, key: 'admin-data-center', group: '数据中心' },
|
||||||
|
{ path: '/admin/data-center/mag-control', label: '磁条管控区', icon: Operation, key: 'admin-data-center', group: '数据中心' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
|
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
|
||||||
children: [
|
children: [
|
||||||
@@ -84,8 +97,12 @@ export const MONITOR_MENU: NavMenuItem[] = [
|
|||||||
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
||||||
const out: NavMenuItem[] = []
|
const out: NavMenuItem[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.children?.length) out.push(...flattenNavMenu(item.children))
|
if (item.children?.length) {
|
||||||
else if (item.key) out.push(item)
|
if (item.key) out.push({ ...item, children: undefined })
|
||||||
|
out.push(...flattenNavMenu(item.children))
|
||||||
|
} else if (item.key) {
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function buildCatalog(scope: Scope): Map<string, QuickEntryDef> {
|
|||||||
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
|
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
|
||||||
for (const item of flattenNavMenu(menu)) {
|
for (const item of flattenNavMenu(menu)) {
|
||||||
const q = menuItemToQuick(item)
|
const q = menuItemToQuick(item)
|
||||||
if (q) map.set(q.key, q)
|
if (q && !map.has(q.key)) map.set(q.key, q)
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<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 v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
|
||||||
<el-dropdown-item command="status">服务状态</el-dropdown-item>
|
<el-dropdown-item command="status">服务状态</el-dropdown-item>
|
||||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
@@ -180,6 +181,8 @@ function onUserCommand(cmd: string) {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
} else if (cmd === 'status') {
|
} else if (cmd === 'status') {
|
||||||
router.push('/status')
|
router.push('/status')
|
||||||
|
} else if (cmd === 'setup') {
|
||||||
|
router.push('/admin/setup')
|
||||||
} else if (cmd === 'wizard') {
|
} else if (cmd === 'wizard') {
|
||||||
router.push('/wizard')
|
router.push('/wizard')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
|
|
||||||
const PAGES: PageDef[] = [
|
const PAGES: PageDef[] = [
|
||||||
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
|
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
|
||||||
|
{ key: 'admin-setup', label: '初始配置', group: '概览', scope: 'Platform' },
|
||||||
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
|
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
|
||||||
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
|
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
|
||||||
{ key: 'admin-alarms', 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-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-task-templates', 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-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
||||||
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
redirect: '/admin/dashboard',
|
redirect: '/admin/dashboard',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
|
{ 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: '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: '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: '报警管理' } },
|
{ 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: '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: '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: '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: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||||
|
{ path: 'data-center', redirect: '/admin/data-center/stations' },
|
||||||
|
{
|
||||||
|
path: 'data-center/:tableId',
|
||||||
|
name: 'admin-data-center',
|
||||||
|
component: () => import('@/views/admin/DataCenterView.vue'),
|
||||||
|
meta: { title: '数据中心' }
|
||||||
|
},
|
||||||
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
||||||
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
||||||
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
||||||
@@ -107,9 +116,33 @@ const router = createRouter({
|
|||||||
routes
|
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) => {
|
router.beforeEach(async (to) => {
|
||||||
const auth = useAuthStore()
|
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) {
|
if (!auth.isAuthed) {
|
||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,23 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
/** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */
|
/** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */
|
||||||
markWizardDone() {
|
markWizardDone() {
|
||||||
this.needsWizard = false
|
this.needsWizard = false
|
||||||
|
},
|
||||||
|
/** 向导保存后刷新 allowedPages(数据中心等按选型裁剪的页),失败不登出。 */
|
||||||
|
async refreshPermissions() {
|
||||||
|
try {
|
||||||
|
const me = await apiGetMe()
|
||||||
|
this.user = me.user
|
||||||
|
this.scope = me.scope
|
||||||
|
this.runMode = me.runMode
|
||||||
|
this.effectivePermissions = me.effectivePermissions
|
||||||
|
this.needsWizard = me.needsWizard ?? false
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(me.user))
|
||||||
|
localStorage.setItem(SCOPE_KEY, me.scope)
|
||||||
|
localStorage.setItem(RUN_MODE_KEY, me.runMode)
|
||||||
|
localStorage.setItem(PERM_KEY, JSON.stringify(me.effectivePermissions))
|
||||||
|
} catch {
|
||||||
|
/* 保存已成功,权限下次进页 / 刷新再对齐 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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[]
|
navigationKinds: string[]
|
||||||
scenarios: string[]
|
scenarios: string[]
|
||||||
updatedBy: string
|
updatedBy: string
|
||||||
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.magnetic)。 */
|
/** 由导航 + 业务场景推导的 SimpleLite 激活场景 id(如 scene.mag / scene.qrlidar;scene.signal 仅 SPS / Pack)。 */
|
||||||
activeSceneIds: string[]
|
activeSceneIds: string[]
|
||||||
/** 被部署画像裁剪隐藏的页面 Key。 */
|
/** 被部署画像裁剪隐藏的页面 Key。 */
|
||||||
hiddenPages: string[]
|
hiddenPages: string[]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<div class="panel-sub">登录以进入智能调度平台</div>
|
<div class="panel-sub">登录以进入智能调度平台</div>
|
||||||
</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-form-item prop="username">
|
||||||
<el-input v-model="form.username" size="large" placeholder="用户名" autocomplete="username" clearable>
|
<el-input v-model="form.username" size="large" placeholder="用户名" autocomplete="username" clearable>
|
||||||
<template #prefix><el-icon><User /></el-icon></template>
|
<template #prefix><el-icon><User /></el-icon></template>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</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>
|
</el-button>
|
||||||
|
|
||||||
@@ -205,40 +205,56 @@ const rules: FormRules = {
|
|||||||
|
|
||||||
const year = computed(() => new Date().getFullYear())
|
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() {
|
async function submit() {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (ok) => {
|
try {
|
||||||
if (!ok) return
|
await formRef.value.validate()
|
||||||
loading.value = true
|
} catch {
|
||||||
try {
|
return
|
||||||
const resp = await auth.login({
|
}
|
||||||
username: form.username,
|
loading.value = true
|
||||||
password: form.password,
|
try {
|
||||||
scope: form.scope,
|
const resp = await auth.login({
|
||||||
launchMode: form.launchMode
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="wz-titles">
|
<div class="wz-titles">
|
||||||
<div class="wz-title">平台配置向导</div>
|
<div class="wz-title">平台配置向导</div>
|
||||||
<div class="wz-sub">按需选择导航方式与功能模块,系统据此裁剪界面并按需加载内核能力</div>
|
<div class="wz-sub">先选导航方式,再选业务场景与功能模块;保存后进入车辆与地图配置</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
|
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<div class="wz-main">
|
<div class="wz-main">
|
||||||
<section class="wz-section">
|
<section class="wz-section">
|
||||||
<div class="wz-section-head">
|
<div class="wz-section-head">
|
||||||
|
<span class="step-no">1</span>
|
||||||
<el-icon><Compass /></el-icon><h3>导航方式</h3><span class="req">至少选 1 项</span>
|
<el-icon><Compass /></el-icon><h3>导航方式</h3><span class="req">至少选 1 项</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chip-grid">
|
<div class="chip-grid">
|
||||||
@@ -37,7 +38,28 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="wz-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">
|
<div class="chip-grid">
|
||||||
<button
|
<button
|
||||||
v-for="o in options?.modules ?? []" :key="o.id" type="button"
|
v-for="o in options?.modules ?? []" :key="o.id" type="button"
|
||||||
@@ -48,19 +70,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<aside class="wz-summary">
|
<aside class="wz-summary">
|
||||||
@@ -80,9 +89,9 @@
|
|||||||
<footer class="wz-foot">
|
<footer class="wz-foot">
|
||||||
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
|
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
|
||||||
<div class="foot-right">
|
<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-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>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -117,11 +126,23 @@ const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.
|
|||||||
|
|
||||||
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
|
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
|
||||||
const NAV_SCENE: Record<string, string> = {
|
const NAV_SCENE: Record<string, string> = {
|
||||||
magnetic: 'scene.magnetic',
|
magnetic: 'scene.mag',
|
||||||
qrcode: 'scene.qrcode',
|
qrcode: 'scene.qrlidar',
|
||||||
laser: 'scene.laser'
|
laser: 'scene.qrlidar'
|
||||||
}
|
}
|
||||||
const activeScenes = computed(() => sel.navigationKinds.map((k) => NAV_SCENE[k] ?? `scene.${k}`))
|
const 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)
|
const canSave = computed(() => sel.navigationKinds.length > 0)
|
||||||
|
|
||||||
@@ -137,8 +158,7 @@ onMounted(async () => {
|
|||||||
options.value = opt
|
options.value = opt
|
||||||
sel.platformType = profile.platformType || 'standard'
|
sel.platformType = profile.platformType || 'standard'
|
||||||
sel.navigationKinds = [...(profile.navigationKinds ?? [])]
|
sel.navigationKinds = [...(profile.navigationKinds ?? [])]
|
||||||
// WMS 为暂定保留的核心仓储模块,首次进入默认勾选,避免用户误漏。
|
sel.modules = [...(profile.modules ?? [])]
|
||||||
sel.modules = profile.modules?.length ? [...profile.modules] : ['wms']
|
|
||||||
sel.scenarios = [...(profile.scenarios ?? [])]
|
sel.scenarios = [...(profile.scenarios ?? [])]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
|
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
|
||||||
@@ -161,8 +181,9 @@ async function save() {
|
|||||||
scenarios: sel.scenarios
|
scenarios: sel.scenarios
|
||||||
})
|
})
|
||||||
auth.markWizardDone()
|
auth.markWizardDone()
|
||||||
ElMessage.success('部署配置已保存')
|
await auth.refreshPermissions()
|
||||||
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
ElMessage.success('选型已保存,请继续配置车辆与地图')
|
||||||
|
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/setup')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -280,11 +301,28 @@ function onLogout() {
|
|||||||
}
|
}
|
||||||
.wz-section-head .el-icon { font-size: 18px; color: var(--lg-accent); }
|
.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 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 {
|
.wz-section-head .req {
|
||||||
font-size: 11px; color: var(--lg-accent);
|
font-size: 11px; color: var(--lg-accent);
|
||||||
padding: 1px 8px; border-radius: 8px;
|
padding: 1px 8px; border-radius: 8px;
|
||||||
border: 1px solid rgba(var(--lg-accent-rgb), 0.4);
|
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 {
|
.chip-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="car-page">
|
<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-tabs v-model="tab" class="car-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
|
||||||
<el-tab-pane label="车辆列表" name="list">
|
<el-tab-pane label="车辆列表" name="list">
|
||||||
<ReflectionManagerPanel
|
<ReflectionManagerPanel
|
||||||
@@ -22,6 +26,7 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
||||||
import CarStyleEditor from './CarStyleEditor.vue'
|
import CarStyleEditor from './CarStyleEditor.vue'
|
||||||
|
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||||
|
|
||||||
const tab = ref<'list' | 'style'>('list')
|
const tab = ref<'list' | 'style'>('list')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<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">
|
<section class="status-bar">
|
||||||
<div class="status-left">
|
<div class="status-left">
|
||||||
@@ -353,11 +369,19 @@ import { useDashboardQuickEntries } from '@/composables/useDashboardQuickEntries
|
|||||||
import { useQuickEntryDragSwap } from '@/composables/useQuickEntryDragSwap'
|
import { useQuickEntryDragSwap } from '@/composables/useQuickEntryDragSwap'
|
||||||
import { fetchAlarmFeed } from '@/api/alarm'
|
import { fetchAlarmFeed } from '@/api/alarm'
|
||||||
import { listCars, listMissions } from '@/api/projection'
|
import { listCars, listMissions } from '@/api/projection'
|
||||||
|
import { loadSetupStatus } from '@/api/setup'
|
||||||
import type { VehicleAlarm } from '@/types/alarm'
|
import type { VehicleAlarm } from '@/types/alarm'
|
||||||
import type { Car } from '@/types/car'
|
import type { Car } from '@/types/car'
|
||||||
import type { Mission } from '@/types/mission'
|
import type { Mission } from '@/types/mission'
|
||||||
|
|
||||||
const router = useRouter()
|
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 {
|
const {
|
||||||
resolvedEntries,
|
resolvedEntries,
|
||||||
@@ -726,6 +750,13 @@ onMounted(async () => {
|
|||||||
cars.value = carList
|
cars.value = carList
|
||||||
missions.value = missionList
|
missions.value = missionList
|
||||||
alarms.value = alarmFeed.alarms
|
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()
|
await nextTick()
|
||||||
renderTrendChart()
|
renderTrendChart()
|
||||||
renderAgvChart()
|
renderAgvChart()
|
||||||
@@ -754,6 +785,10 @@ onUnmounted(() => {
|
|||||||
.dashboard > * {
|
.dashboard > * {
|
||||||
flex-shrink: 0;
|
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 Banner(深色沉浸背景跨主题响应) ─────────────
|
||||||
* 设计策略:Hero 区始终是深色(参考 SaaS Dashboard 标杆),但色调跟随主题切换。
|
* 设计策略: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SetupGuideAlert
|
||||||
|
title="请先准备地图"
|
||||||
|
desc="新增或选用一张地图后,再到场景管理添加站点与路径。"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="directory"
|
v-if="directory"
|
||||||
class="dir-tip"
|
class="dir-tip"
|
||||||
@@ -114,6 +119,7 @@ import { mapsApi, type MapListItem } from '@/api/mapEdit'
|
|||||||
import JsonFoldViewer from '@/components/common/JsonFoldViewer.vue'
|
import JsonFoldViewer from '@/components/common/JsonFoldViewer.vue'
|
||||||
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
|
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
|
||||||
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
|
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
|
||||||
|
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tableRef = ref<TableInstance>()
|
const tableRef = ref<TableInstance>()
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="scene-mgr-page ops-console-page">
|
<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-tabs v-model="activeTab" class="scene-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
|
||||||
<el-tab-pane label="站点" name="site">
|
<el-tab-pane label="站点" name="site">
|
||||||
<ReflectionManagerPanel
|
<ReflectionManagerPanel
|
||||||
@@ -42,9 +46,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
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 sitePanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
|
||||||
const trackPanelRef = 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<SetupGuideAlert
|
||||||
|
title="请配置功能参数"
|
||||||
|
desc="在此维护站点 / 车辆字段(速度、功能点等)。配完后返回初始配置。"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="car-type-filter">
|
<div class="car-type-filter">
|
||||||
<span class="filter-label">车辆类型:</span>
|
<span class="filter-label">车辆类型:</span>
|
||||||
@@ -172,6 +177,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { Refresh } from '@element-plus/icons-vue'
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
|
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
|
||||||
import * as simpleFieldApi from '@/api/simpleField'
|
import * as simpleFieldApi from '@/api/simpleField'
|
||||||
|
import SetupGuideAlert from '@/components/setup/SetupGuideAlert.vue'
|
||||||
import {
|
import {
|
||||||
SIMPLE_FIELD_CATEGORIES,
|
SIMPLE_FIELD_CATEGORIES,
|
||||||
buildCarType,
|
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 应在 1~100', `${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 应在 30~3600')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 任务模板引擎原型 — 类型与 DSL(schemaVersion=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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user